Chuyển đến nội dung chính

Hướng dẫn hợp đồng cầu nối tiêu chuẩn Optimism

Solidity
cầu nối
lớp 2
Trung gian
Ori Pomerantz
30 tháng 3, 2022
40 số phút đọc

Optimism (opens in a new tab) là một gộp giao dịch lạc quan. Các gộp giao dịch lạc quan có thể xử lý các giao dịch với mức giá thấp hơn nhiều so với Ethereum Mainnet (còn được gọi là lớp 1 hoặc L1) vì các giao dịch chỉ được xử lý bởi một vài nút, thay vì mọi nút trên mạng. Đồng thời, tất cả dữ liệu được ghi vào L1 để mọi thứ có thể được chứng minh và tái tạo lại với tất cả sự đảm bảo về tính toàn vẹn và tính khả dụng của Mainnet.

Để sử dụng tài sản L1 trên Optimism (hoặc bất kỳ L2 nào khác), tài sản cần được bắc cầu. Một cách để thực hiện điều này là người dùng khóa tài sản (ETH và token ERC-20 là những tài sản phổ biến nhất) trên L1, và nhận tài sản tương đương để sử dụng trên L2. Cuối cùng, bất cứ ai sở hữu chúng đều có thể muốn bắc cầu chúng trở lại L1. Khi làm điều này, các tài sản sẽ bị đốt trên L2 và sau đó được trả lại cho người dùng trên L1.

Đây là cách mà cầu nối tiêu chuẩn của Optimism (opens in a new tab) hoạt động. Trong bài viết này, chúng ta sẽ xem qua mã nguồn của cầu nối đó để xem nó hoạt động như thế nào và nghiên cứu nó như một ví dụ về mã Solidity được viết tốt.

Luồng điều khiển

Cầu nối có hai luồng chính:

  • Gửi tiền (từ L1 đến L2)
  • Rút tiền (từ L2 đến L1)

Luồng gửi tiền

Lớp 1

  1. Nếu gửi một ERC-20, người gửi cấp cho cầu nối một khoản phụ cấp để chi tiêu số tiền đang được gửi
  2. Người gửi gọi cầu nối L1 (depositERC20, depositERC20To, depositETH, hoặc depositETHTo)
  3. Cầu nối L1 nắm giữ tài sản được bắc cầu
    • ETH: Tài sản được người gửi chuyển như một phần của lệnh gọi
    • ERC-20: Tài sản được cầu nối tự chuyển cho chính nó bằng cách sử dụng khoản phụ cấp do người gửi cung cấp
  4. Cầu nối L1 sử dụng cơ chế thông điệp liên miền để gọi finalizeDeposit trên cầu nối L2

Lớp 2

  1. Cầu nối L2 xác minh lệnh gọi đến finalizeDeposit là hợp lệ:
    • Đến từ hợp đồng thông điệp liên miền
    • Ban đầu đến từ cầu nối trên L1
  2. Cầu nối L2 kiểm tra xem hợp đồng token ERC-20 trên L2 có phải là hợp đồng chính xác không:
    • Hợp đồng L2 báo cáo rằng đối tác L1 của nó giống với hợp đồng mà các token đến từ đó trên L1
    • Hợp đồng L2 báo cáo rằng nó hỗ trợ giao diện chính xác (sử dụng ERC-165 (opens in a new tab)).
  3. Nếu hợp đồng L2 là hợp đồng chính xác, hãy gọi nó để đúc số lượng token thích hợp vào địa chỉ thích hợp. Nếu không, hãy bắt đầu quy trình rút tiền để cho phép người dùng nhận lại các token trên L1.

Luồng rút tiền

Lớp 2

  1. Người rút gọi cầu nối L2 (withdraw hoặc withdrawTo)
  2. Cầu nối L2 đốt số lượng token thích hợp thuộc về msg.sender
  3. Cầu nối L2 sử dụng cơ chế thông điệp liên miền để gọi finalizeETHWithdrawal hoặc finalizeERC20Withdrawal trên cầu nối L1

Lớp 1

  1. Cầu nối L1 xác minh lệnh gọi đến finalizeETHWithdrawal hoặc finalizeERC20Withdrawal là hợp lệ:
    • Đến từ cơ chế thông điệp liên miền
    • Ban đầu đến từ cầu nối trên L2
  2. Cầu nối L1 chuyển tài sản thích hợp (ETH hoặc ERC-20) đến địa chỉ thích hợp

Mã lớp 1

Đây là mã chạy trên L1, Ethereum Mainnet.

IL1ERC20Bridge

Giao diện này được định nghĩa ở đây (opens in a new tab). Nó bao gồm các hàm và định nghĩa cần thiết để bắc cầu các token ERC-20.

// SPDX-License-Identifier: MIT

Hầu hết mã của Optimism được phát hành theo giấy phép MIT (opens in a new tab).

pragma solidity >0.5.0 <0.9.0;

Tại thời điểm viết bài, phiên bản mới nhất của Solidity là 0.8.12. Cho đến khi phiên bản 0.9.0 được phát hành, chúng tôi không biết liệu mã này có tương thích với nó hay không.

Trong thuật ngữ cầu nối Optimism deposit (gửi tiền) có nghĩa là chuyển từ L1 sang L2, và withdrawal (rút tiền) có nghĩa là chuyển từ L2 sang L1.

        address indexed _l1Token,
        address indexed _l2Token,

Trong hầu hết các trường hợp, địa chỉ của một ERC-20 trên L1 không giống với địa chỉ của ERC-20 tương đương trên L2. Bạn có thể xem danh sách các địa chỉ token ở đây (opens in a new tab). Địa chỉ có chainId 1 là trên L1 (Mainnet) và địa chỉ có chainId 10 là trên L2 (Optimism). Hai giá trị chainId còn lại dành cho mạng thử nghiệm Kovan (42) và mạng thử nghiệm Optimistic Kovan (69).

        address indexed _from,
        address _to,
        uint256 _amount,
        bytes _data
    );

Có thể thêm ghi chú vào các giao dịch chuyển tiền, trong trường hợp đó chúng sẽ được thêm vào các sự kiện báo cáo chúng.

    event ERC20WithdrawalFinalized(
        address indexed _l1Token,
        address indexed _l2Token,
        address indexed _from,
        address _to,
        uint256 _amount,
        bytes _data
    );

Cùng một hợp đồng cầu nối xử lý các giao dịch chuyển tiền theo cả hai hướng. Trong trường hợp của cầu nối L1, điều này có nghĩa là khởi tạo gửi tiền và hoàn tất rút tiền.

Hàm này không thực sự cần thiết, vì trên L2 nó là một hợp đồng được triển khai trước, vì vậy nó luôn ở địa chỉ 0x4200000000000000000000000000000000000010. Nó ở đây để đối xứng với cầu nối L2, vì địa chỉ của cầu nối L1 không dễ dàng để biết.

Tham số _l2Gas là lượng gas L2 mà giao dịch được phép chi tiêu. Lên đến một giới hạn nhất định (cao), điều này là miễn phí (opens in a new tab), vì vậy trừ khi hợp đồng ERC-20 làm điều gì đó thực sự kỳ lạ khi đúc, nó sẽ không phải là một vấn đề. Hàm này xử lý kịch bản phổ biến, trong đó người dùng bắc cầu tài sản đến cùng một địa chỉ trên một chuỗi khối khác.

Hàm này gần như giống hệt với depositERC20, nhưng nó cho phép bạn gửi ERC-20 đến một địa chỉ khác.

Việc rút tiền (và các thông điệp khác từ L2 sang L1) trong Optimism là một quy trình hai bước:

  1. Một giao dịch khởi tạo trên L2.
  2. Một giao dịch hoàn tất hoặc yêu cầu trên L1. Giao dịch này cần phải xảy ra sau khi thời gian thử thách lỗi (opens in a new tab) cho giao dịch L2 kết thúc.

IL1StandardBridge

Giao diện này được định nghĩa ở đây (opens in a new tab). Tệp này chứa các định nghĩa sự kiện và hàm cho ETH. Các định nghĩa này rất tương tự với những định nghĩa trong IL1ERC20Bridge ở trên cho ERC-20.

Giao diện cầu nối được chia thành hai tệp vì một số token ERC-20 yêu cầu xử lý tùy chỉnh và không thể được xử lý bởi cầu nối tiêu chuẩn. Bằng cách này, cầu nối tùy chỉnh xử lý một token như vậy có thể triển khai IL1ERC20Bridge và không phải bắc cầu cả ETH.

Sự kiện này gần như giống hệt phiên bản ERC-20 (ERC20DepositInitiated), ngoại trừ không có địa chỉ token L1 và L2. Điều tương tự cũng đúng với các sự kiện và hàm khác.

CrossDomainEnabled

Hợp đồng này (opens in a new tab) được kế thừa bởi cả hai cầu nối (L1L2) để gửi thông điệp đến lớp kia.

// SPDX-License-Identifier: MIT
pragma solidity >0.5.0 <0.9.0;

/* Interface Imports */
import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";

Giao diện này (opens in a new tab) cho hợp đồng biết cách gửi thông điệp đến lớp kia, sử dụng trình nhắn tin liên miền. Trình nhắn tin liên miền này là một hệ thống hoàn toàn khác, và xứng đáng có một bài viết riêng, mà tôi hy vọng sẽ viết trong tương lai.

Tham số duy nhất mà hợp đồng cần biết, địa chỉ của trình nhắn tin liên miền trên lớp này. Tham số này được thiết lập một lần trong hàm dựng và không bao giờ thay đổi.

Cơ chế nhắn tin liên miền có thể được truy cập bởi bất kỳ hợp đồng nào trên chuỗi khối nơi nó đang chạy (hoặc mainnet Ethereum hoặc Optimism). Nhưng chúng ta cần cầu nối ở mỗi bên chỉ tin cậy một số thông điệp nhất định nếu chúng đến từ cầu nối ở bên kia.

        require(
            msg.sender == address(getCrossDomainMessenger()),
            "OVM_XCHAIN: messenger contract unauthenticated"
        );

Chỉ các thông điệp từ trình nhắn tin liên miền thích hợp (messenger, như bạn thấy bên dưới) mới có thể được tin cậy.


        require(
            getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,
            "OVM_XCHAIN: wrong sender of cross-domain message"
        );

Cách mà trình nhắn tin liên miền cung cấp địa chỉ đã gửi một thông điệp với lớp kia là hàm .xDomainMessageSender() (opens in a new tab). Miễn là nó được gọi trong giao dịch được khởi tạo bởi thông điệp, nó có thể cung cấp thông tin này.

Chúng ta cần đảm bảo rằng thông điệp chúng ta nhận được đến từ cầu nối kia.

Hàm này trả về trình nhắn tin liên miền. Chúng tôi sử dụng một hàm thay vì biến messenger để cho phép các hợp đồng kế thừa từ hợp đồng này sử dụng một thuật toán để chỉ định trình nhắn tin liên miền nào sẽ sử dụng.

Cuối cùng là hàm gửi thông điệp đến lớp kia.

    ) internal {
        // slither-disable-next-line reentrancy-events, reentrancy-benign

Slither (opens in a new tab) là một trình phân tích tĩnh mà Optimism chạy trên mọi hợp đồng để tìm kiếm các lỗ hổng và các vấn đề tiềm ẩn khác. Trong trường hợp này, dòng sau đây kích hoạt hai lỗ hổng:

  1. Sự kiện tái nhập (opens in a new tab)
  2. Tái nhập lành tính (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

Trong trường hợp này, chúng tôi không lo lắng về việc tái nhập, chúng tôi biết getCrossDomainMessenger() trả về một địa chỉ đáng tin cậy, ngay cả khi Slither không có cách nào để biết điều đó.

Hợp đồng cầu nối L1

Mã nguồn của hợp đồng này ở đây (opens in a new tab).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

Các giao diện có thể là một phần của các hợp đồng khác, vì vậy chúng phải hỗ trợ một loạt các phiên bản Solidity. Nhưng bản thân cầu nối là hợp đồng của chúng tôi, và chúng tôi có thể nghiêm ngặt về phiên bản Solidity mà nó sử dụng.

/* Interface Imports */
import { IL1StandardBridge } from "./IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";

IL1ERC20BridgeIL1StandardBridge được giải thích ở trên.

import { IL2ERC20Bridge } from "../../L2/messaging/IL2ERC20Bridge.sol";

Giao diện này (opens in a new tab) cho phép chúng ta tạo thông điệp để điều khiển cầu nối tiêu chuẩn trên L2.

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

Giao diện này (opens in a new tab) cho phép chúng ta điều khiển các hợp đồng ERC-20. Bạn có thể đọc thêm về nó ở đây.

/* Library Imports */
import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";

Như đã giải thích ở trên, hợp đồng này được sử dụng để nhắn tin giữa các lớp.

import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";

Lib_PredeployAddresses (opens in a new tab) có các địa chỉ cho các hợp đồng L2 luôn có cùng một địa chỉ. Điều này bao gồm cầu nối tiêu chuẩn trên L2.

import { Address } from "@openzeppelin/contracts/utils/Address.sol";

Các tiện ích Địa chỉ của OpenZeppelin (opens in a new tab). Nó được sử dụng để phân biệt giữa các địa chỉ hợp đồng và những địa chỉ thuộc về tài khoản sở hữu bên ngoài (EOA).

Lưu ý rằng đây không phải là một giải pháp hoàn hảo, vì không có cách nào để phân biệt giữa các lệnh gọi trực tiếp và các lệnh gọi được thực hiện từ hàm dựng của hợp đồng, nhưng ít nhất điều này cho phép chúng tôi xác định và ngăn chặn một số lỗi người dùng phổ biến.

import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

Tiêu chuẩn ERC-20 (opens in a new tab) hỗ trợ hai cách để một hợp đồng báo cáo lỗi:

  1. Hoàn nguyên
  2. Trả về false

Xử lý cả hai trường hợp sẽ làm cho mã của chúng ta phức tạp hơn, vì vậy thay vào đó chúng ta sử dụng SafeERC20 của OpenZeppelin (opens in a new tab), đảm bảo tất cả các lỗi đều dẫn đến việc hoàn nguyên (opens in a new tab).

Dòng này là cách chúng ta chỉ định sử dụng trình bao bọc SafeERC20 mỗi khi chúng ta sử dụng giao diện IERC20.


    /********************************
     * Tham chiếu Hợp đồng Bên ngoài *
     ********************************/

    address public l2TokenBridge;

Địa chỉ của L2StandardBridge.


    // Ánh xạ token L1 đến token L2 đến số dư của token L1 đã gửi
    mapping(address => mapping(address => uint256)) public deposits;

Một ánh xạ (opens in a new tab) kép như thế này là cách bạn định nghĩa một mảng thưa hai chiều (opens in a new tab). Các giá trị trong cấu trúc dữ liệu này được xác định là deposit[địa chỉ token L1][địa chỉ token L2]. Giá trị mặc định là không. Chỉ các ô được thiết lập thành một giá trị khác mới được ghi vào bộ nhớ lưu trữ.


    /***************
     * Hàm dựng *
     ***************/

    // Hợp đồng này hoạt động đằng sau một proxy, vì vậy các tham số của hàm dựng sẽ không được sử dụng.
    constructor() CrossDomainEnabled(address(0)) {}

Để có thể nâng cấp hợp đồng này mà không cần phải sao chép tất cả các biến trong bộ nhớ lưu trữ. Để làm điều đó, chúng tôi sử dụng một Proxy (opens in a new tab), một hợp đồng sử dụng delegatecall (opens in a new tab) để chuyển các lệnh gọi đến một hợp đồng riêng biệt có địa chỉ được lưu trữ bởi hợp đồng proxy (khi bạn nâng cấp, bạn yêu cầu proxy thay đổi địa chỉ đó). Khi bạn sử dụng delegatecall, bộ nhớ lưu trữ vẫn là bộ nhớ lưu trữ của hợp đồng gọi, vì vậy giá trị của tất cả các biến trạng thái hợp đồng không bị ảnh hưởng.

Một ảnh hưởng của mô hình này là bộ nhớ lưu trữ của hợp đồng được gọi của delegatecall không được sử dụng và do đó các giá trị hàm dựng được truyền cho nó không quan trọng. Đây là lý do chúng ta có thể cung cấp một giá trị vô nghĩa cho hàm dựng CrossDomainEnabled. Đó cũng là lý do tại sao việc khởi tạo bên dưới được tách biệt khỏi hàm dựng.

Thử nghiệm Slither này (opens in a new tab) xác định các hàm không được gọi từ mã hợp đồng và do đó có thể được khai báo external thay vì public. Chi phí gas của các hàm external có thể thấp hơn, vì chúng có thể được cung cấp các tham số trong calldata. Các hàm được khai báo là public phải có thể truy cập được từ bên trong hợp đồng. Các hợp đồng không thể sửa đổi calldata của chính chúng, vì vậy các tham số phải ở trong bộ nhớ. Khi một hàm như vậy được gọi từ bên ngoài, cần phải sao chép calldata vào bộ nhớ, điều này tốn gas. Trong trường hợp này, hàm chỉ được gọi một lần, vì vậy sự thiếu hiệu quả không quan trọng đối với chúng tôi.

    function initialize(address _l1messenger, address _l2TokenBridge) public {
        require(messenger == address(0), "Contract has already been initialized.");

Hàm initialize chỉ nên được gọi một lần. Nếu địa chỉ của trình nhắn tin liên miền L1 hoặc cầu nối token L2 thay đổi, chúng tôi tạo một proxy mới và một cầu nối mới gọi nó. Điều này không có khả năng xảy ra ngoại trừ khi toàn bộ hệ thống được nâng cấp, một sự kiện rất hiếm.

Lưu ý rằng hàm này không có bất kỳ cơ chế nào hạn chế ai có thể gọi nó. Điều này có nghĩa là về mặt lý thuyết, một kẻ tấn công có thể đợi cho đến khi chúng tôi triển khai proxy và phiên bản đầu tiên của cầu nối, sau đó chạy trước (opens in a new tab) để đến hàm initialize trước người dùng hợp pháp. Nhưng có hai phương pháp để ngăn chặn điều này:

  1. Nếu các hợp đồng không được triển khai trực tiếp bởi một EOA mà trong một giao dịch có một hợp đồng khác tạo ra chúng (opens in a new tab), toàn bộ quá trình có thể là nguyên tử, và kết thúc trước khi bất kỳ giao dịch nào khác được thực hiện.
  2. Nếu lệnh gọi hợp pháp đến initialize thất bại, luôn có thể bỏ qua proxy và cầu nối mới được tạo và tạo ra những cái mới.
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

Đây là hai tham số mà cầu nối cần biết.

Đây là lý do chúng ta cần các tiện ích Address của OpenZeppelin.

Hàm này tồn tại cho mục đích thử nghiệm. Lưu ý rằng nó không xuất hiện trong các định nghĩa giao diện - nó không dành cho việc sử dụng thông thường.

Hai hàm này là các trình bao bọc xung quanh _initiateETHDeposit, hàm xử lý việc gửi ETH thực tế.

Cách thức hoạt động của các thông điệp liên miền là hợp đồng đích được gọi với thông điệp làm calldata của nó. Các hợp đồng Solidity luôn diễn giải calldata của chúng theo các thông số kỹ thuật ABI (opens in a new tab). Hàm Solidity abi.encodeWithSelector (opens in a new tab) tạo ra calldata đó.

            IL2ERC20Bridge.finalizeDeposit.selector,
            address(0),
            Lib_PredeployAddresses.OVM_ETH,
            _from,
            _to,
            msg.value,
            _data
        );

Thông điệp ở đây là để gọi hàm finalizeDeposit (opens in a new tab) với các tham số này:

Thông sốGiá trịÝ nghĩa
_l1Tokenaddress(0)Giá trị đặc biệt để đại diện cho ETH (không phải là một token ERC-20) trên L1
_l2TokenLib_PredeployAddresses.OVM_ETHHợp đồng L2 quản lý ETH trên Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (hợp đồng này chỉ dành cho mục đích sử dụng nội bộ của Optimism)
_from_fromĐịa chỉ trên L1 gửi ETH
_to_toĐịa chỉ trên L2 nhận ETH
số lượngmsg.valueSố lượng wei đã gửi (đã được gửi đến cầu nối)
_data_dataDữ liệu bổ sung để đính kèm vào khoản tiền gửi
        // Gửi calldata vào L2
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

Gửi thông điệp thông qua trình nhắn tin liên miền.

        // slither-disable-next-line reentrancy-events
        emit ETHDepositInitiated(_from, _to, msg.value, _data);
    }

Phát ra một sự kiện để thông báo cho bất kỳ ứng dụng phi tập trung nào đang lắng nghe về giao dịch chuyển tiền này.

Hai hàm này là các trình bao bọc xung quanh _initiateERC20Deposit, hàm xử lý việc gửi ERC-20 thực tế.

Hàm này tương tự như _initiateETHDeposit ở trên, với một vài khác biệt quan trọng. Sự khác biệt đầu tiên là hàm này nhận các địa chỉ token và số lượng cần chuyển làm tham số. Trong trường hợp ETH, lệnh gọi đến cầu nối đã bao gồm việc chuyển tài sản đến tài khoản cầu nối (msg.value).

        // Khi một khoản tiền gửi được khởi tạo trên L1, Cầu nối L1 sẽ chuyển tiền vào chính nó cho các khoản
        // rút tiền trong tương lai. safeTransferFrom cũng kiểm tra xem hợp đồng có mã hay không, vì vậy điều này sẽ thất bại nếu
        // _from là một EOA hoặc address(0).
        // slither-disable-next-line reentrancy-events, reentrancy-benign
        IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);

Việc chuyển token ERC-20 theo một quy trình khác với ETH:

  1. Người dùng (_from) cấp cho cầu nối một khoản phụ cấp để chuyển các token thích hợp.
  2. Người dùng gọi cầu nối với địa chỉ của hợp đồng token, số lượng, v.v.
  3. Cầu nối chuyển các token (cho chính nó) như một phần của quá trình gửi tiền.

Bước đầu tiên có thể xảy ra trong một giao dịch riêng biệt so với hai bước cuối cùng. Tuy nhiên, chạy trước không phải là một vấn đề vì hai hàm gọi _initiateERC20Deposit (depositERC20depositERC20To) chỉ gọi hàm này với msg.sender làm tham số _from.

Thêm số lượng token đã gửi vào cấu trúc dữ liệu deposits. Có thể có nhiều địa chỉ trên L2 tương ứng với cùng một token L1 ERC-20, vì vậy không đủ để sử dụng số dư của token L1 ERC-20 của cầu nối để theo dõi các khoản tiền gửi.

Cầu nối L2 gửi một thông điệp đến trình nhắn tin liên miền L2, điều này khiến trình nhắn tin liên miền L1 gọi hàm này (tất nhiên, một khi giao dịch hoàn tất thông điệp (opens in a new tab) được gửi trên L1).

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

Đảm bảo rằng đây là một thông điệp hợp lệ, đến từ trình nhắn tin liên miền và bắt nguồn từ cầu nối token L2. Hàm này được sử dụng để rút ETH từ cầu nối, vì vậy chúng ta phải đảm bảo nó chỉ được gọi bởi người gọi được ủy quyền.

        // slither-disable-next-line reentrancy-events
        (bool success, ) = _to.call{ value: _amount }(new bytes(0));

Cách để chuyển ETH là gọi người nhận với số lượng wei trong msg.value.

        require(success, "TransferHelper::safeTransferETH: ETH transfer failed");

        // slither-disable-next-line reentrancy-events
        emit ETHWithdrawalFinalized(_from, _to, _amount, _data);

Phát ra một sự kiện về việc rút tiền.

Hàm này tương tự như finalizeETHWithdrawal ở trên, với những thay đổi cần thiết cho các token ERC-20.

        deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] - _amount;

Cập nhật cấu trúc dữ liệu deposits.

Có một triển khai trước đó của cầu nối. Khi chúng tôi chuyển từ triển khai đó sang triển khai này, chúng tôi đã phải di chuyển tất cả các tài sản. Các token ERC-20 chỉ cần được di chuyển. Tuy nhiên, để chuyển ETH đến một hợp đồng, bạn cần sự chấp thuận của hợp đồng đó, đó là điều mà donateETH cung cấp cho chúng tôi.

Các token ERC-20 trên L2

Để một token ERC-20 phù hợp với cầu nối tiêu chuẩn, nó cần cho phép cầu nối tiêu chuẩn, và chỉ cầu nối tiêu chuẩn, đúc token. Điều này là cần thiết vì các cầu nối cần đảm bảo rằng số lượng token lưu hành trên Optimism bằng với số lượng token bị khóa bên trong hợp đồng cầu nối L1. Nếu có quá nhiều token trên L2, một số người dùng sẽ không thể bắc cầu tài sản của họ trở lại L1. Thay vì một cầu nối đáng tin cậy, chúng ta về cơ bản sẽ tái tạo lại hệ thống ngân hàng dự trữ một phần (opens in a new tab). Nếu có quá nhiều token trên L1, một số token đó sẽ bị khóa bên trong hợp đồng cầu nối mãi mãi vì không có cách nào để giải phóng chúng mà không đốt các token L2.

IL2StandardERC20

Mọi token ERC-20 trên L2 sử dụng cầu nối tiêu chuẩn cần cung cấp giao diện này (opens in a new tab), có các hàm và sự kiện mà cầu nối tiêu chuẩn cần.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

Giao diện ERC-20 tiêu chuẩn (opens in a new tab) không bao gồm các hàm mintburn. Các phương thức đó không được yêu cầu bởi tiêu chuẩn ERC-20 (opens in a new tab), tiêu chuẩn này không chỉ định các cơ chế để tạo và hủy token.

import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";

Giao diện ERC-165 (opens in a new tab) được sử dụng để chỉ định các hàm mà một hợp đồng cung cấp. Bạn có thể đọc tiêu chuẩn ở đây (opens in a new tab).

interface IL2StandardERC20 is IERC20, IERC165 {
    function l1Token() external returns (address);

Hàm này cung cấp địa chỉ của token L1 được bắc cầu đến hợp đồng này. Lưu ý rằng chúng ta không có một hàm tương tự theo hướng ngược lại. Chúng ta cần có thể bắc cầu bất kỳ token L1 nào, bất kể hỗ trợ L2 có được lên kế hoạch khi nó được triển khai hay không.


    function mint(address _to, uint256 _amount) external;

    function burn(address _from, uint256 _amount) external;

    event Mint(address indexed _account, uint256 _amount);
    event Burn(address indexed _account, uint256 _amount);
}

Các hàm và sự kiện để đúc (tạo) và đốt (hủy) token. Cầu nối nên là thực thể duy nhất có thể chạy các hàm này để đảm bảo số lượng token là chính xác (bằng với số lượng token bị khóa trên L1).

L2StandardERC20

Đây là triển khai của chúng tôi về giao diện IL2StandardERC20 (opens in a new tab). Trừ khi bạn cần một loại logic tùy chỉnh nào đó, bạn nên sử dụng cái này.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

Hợp đồng ERC-20 của OpenZeppelin (opens in a new tab). Optimism không tin vào việc phát minh lại bánh xe, đặc biệt là khi bánh xe được kiểm toán kỹ lưỡng và cần phải đủ đáng tin cậy để giữ tài sản.

import "./IL2StandardERC20.sol";

contract L2StandardERC20 is IL2StandardERC20, ERC20 {
    address public l1Token;
    address public l2Bridge;

Đây là hai tham số cấu hình bổ sung mà chúng tôi yêu cầu và ERC-20 thường không có.

Đầu tiên gọi hàm dựng cho hợp đồng mà chúng ta kế thừa từ đó (ERC20(_name, _symbol)) và sau đó thiết lập các biến của riêng chúng ta.

Đây là cách ERC-165 (opens in a new tab) hoạt động. Mỗi giao diện là một tập hợp các hàm được hỗ trợ và được xác định là kết quả của phép toán OR độc quyền (opens in a new tab) của các bộ chọn hàm ABI (opens in a new tab) của các hàm đó.

Cầu nối L2 sử dụng ERC-165 như một kiểm tra hợp lý để đảm bảo rằng hợp đồng ERC-20 mà nó gửi tài sản đến là một IL2StandardERC20.

Lưu ý: Không có gì ngăn cản hợp đồng giả mạo cung cấp câu trả lời sai cho supportsInterface, vì vậy đây là một cơ chế kiểm tra hợp lý, không phải là một cơ chế bảo mật.

Chỉ có cầu nối L2 mới được phép đúc và đốt tài sản.

_mint_burn thực sự được định nghĩa trong hợp đồng OpenZeppelin ERC-20. Hợp đồng đó chỉ không hiển thị chúng ra bên ngoài, vì các điều kiện để đúc và đốt token rất đa dạng như số cách sử dụng ERC-20.

Mã cầu nối L2

Đây là mã chạy cầu nối trên Optimism. Nguồn của hợp đồng này ở đây (opens in a new tab).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

/* Interface Imports */
import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";
import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";

Giao diện IL2ERC20Bridge (opens in a new tab) rất tương tự với tương đương L1 mà chúng ta đã thấy ở trên. Có hai sự khác biệt đáng kể:

  1. Trên L1, bạn khởi tạo gửi tiền và hoàn tất rút tiền. Ở đây bạn khởi tạo rút tiền và hoàn tất gửi tiền.
  2. Trên L1, cần phải phân biệt giữa ETH và các token ERC-20. Trên L2, chúng ta có thể sử dụng cùng các hàm cho cả hai vì nội bộ số dư ETH trên Optimism được xử lý như một token ERC-20 với địa chỉ 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab).

Theo dõi địa chỉ của cầu nối L1. Lưu ý rằng ngược lại với tương đương L1, ở đây chúng ta cần biến này. Địa chỉ của cầu nối L1 không được biết trước.

Hai hàm này khởi tạo việc rút tiền. Lưu ý rằng không cần phải chỉ định địa chỉ token L1. Các token L2 được mong đợi sẽ cho chúng tôi biết địa chỉ tương đương của L1.

Lưu ý rằng chúng tôi không dựa vào tham số _from mà vào msg.sender khó giả mạo hơn nhiều (không thể, theo như tôi biết).


        // Xây dựng calldata cho l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)
        // slither-disable-next-line reentrancy-events
        address l1Token = IL2StandardERC20(_l2Token).l1Token();
        bytes memory message;

        if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {

Trên L1, cần phải phân biệt giữa ETH và ERC-20.

Hàm này được gọi bởi L1StandardBridge.

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

Đảm bảo nguồn của thông điệp là hợp lệ. Điều này quan trọng vì hàm này gọi _mint và có thể được sử dụng để cấp các token không được bảo đảm bởi các token mà cầu nối sở hữu trên L1.

        // Kiểm tra token mục tiêu có tuân thủ và
        // xác minh token đã gửi trên L1 khớp với biểu diễn token đã gửi L2 ở đây
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()

Các kiểm tra hợp lý:

  1. Giao diện chính xác được hỗ trợ
  2. Địa chỉ L1 của hợp đồng ERC-20 L2 khớp với nguồn L1 của các token
        ) {
            // Khi một khoản tiền gửi được hoàn tất, chúng tôi ghi có vào tài khoản trên L2 với cùng một số lượng
            // token.
            // slither-disable-next-line reentrancy-events
            IL2StandardERC20(_l2Token).mint(_to, _amount);
            // slither-disable-next-line reentrancy-events
            emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);

Nếu các kiểm tra hợp lý vượt qua, hoàn tất việc gửi tiền:

  1. Đúc các token
  2. Phát ra sự kiện thích hợp

Nếu người dùng mắc lỗi có thể phát hiện bằng cách sử dụng sai địa chỉ token L2, chúng tôi muốn hủy bỏ việc gửi tiền và trả lại các token trên L1. Cách duy nhất chúng ta có thể làm điều này từ L2 là gửi một thông điệp sẽ phải đợi hết thời gian thử thách lỗi, nhưng điều đó tốt hơn nhiều cho người dùng so với việc mất các token vĩnh viễn.

Kết luận

Cầu nối tiêu chuẩn là cơ chế linh hoạt nhất cho việc chuyển tài sản. Tuy nhiên, vì nó rất chung chung nên không phải lúc nào cũng là cơ chế dễ sử dụng nhất. Đặc biệt đối với việc rút tiền, hầu hết người dùng thích sử dụng các cầu nối của bên thứ ba (opens in a new tab) không phải chờ đợi hết thời gian thử thách và không yêu cầu bằng chứng Merkle để hoàn tất việc rút tiền.

Các cầu nối này thường hoạt động bằng cách có tài sản trên L1, mà họ cung cấp ngay lập tức với một khoản phí nhỏ (thường ít hơn chi phí gas cho một lần rút tiền qua cầu nối tiêu chuẩn). Khi cầu nối (hoặc những người điều hành nó) dự đoán sẽ thiếu tài sản L1, nó sẽ chuyển đủ tài sản từ L2. Vì đây là những khoản rút tiền rất lớn, chi phí rút tiền được phân bổ trên một số lượng lớn và là một tỷ lệ phần trăm nhỏ hơn nhiều.

Hy vọng bài viết này đã giúp bạn hiểu thêm về cách lớp 2 hoạt động, và cách viết mã Solidity rõ ràng và bảo mật.

Xem thêm công việc của tôi tại đây (opens in a new tab).

Cập nhật trang lần cuối: 3 tháng 4, 2026

Hướng dẫn này có hữu ích không?