Nhảy đế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

Optimismopens 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 Optimismopens 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-165opens 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 ở đâyopens 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.

1// SPDX-License-Identifier: MIT

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

1pragma 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.

1/**
2 * @title IL1ERC20Bridge
3 */
4interface IL1ERC20Bridge {
5 /**********
6 * Các sự kiện *
7 **********/
8
9 event ERC20DepositInitiated(
Hiện tất cả

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.

1 address indexed _l1Token,
2 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 ở đâyopens 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).

1 address indexed _from,
2 address _to,
3 uint256 _amount,
4 bytes _data
5 );

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.

1 event ERC20WithdrawalFinalized(
2 address indexed _l1Token,
3 address indexed _l2Token,
4 address indexed _from,
5 address _to,
6 uint256 _amount,
7 bytes _data
8 );

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.

1
2 /********************
3 * Các hàm công khai *
4 ********************/
5
6 /**
7 * @dev lấy địa chỉ của hợp đồng cầu nối L2 tương ứng.
8 * @return Địa chỉ của hợp đồng cầu nối L2 tương ứng.
9 */
10 function l2TokenBridge() external returns (address);
Hiện tất cả

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.

1 /**
2 * @dev gửi một lượng ERC20 vào số dư của người gọi trên L2.
3 * @param _l1Token Địa chỉ của L1 ERC20 mà chúng ta đang gửi
4 * @param _l2Token Địa chỉ của ERC20 L2 tương ứng của L1
5 * @param _amount Lượng ERC20 cần gửi
6 * @param _l2Gas Giới hạn gas cần thiết để hoàn tất việc gửi tiền trên L2.
7 * @param _data Dữ liệu tùy chọn để chuyển tiếp đến L2. Dữ liệu này được cung cấp
8 * chỉ để thuận tiện cho các hợp đồng bên ngoài. Ngoài việc thực thi một
9 * độ dài tối đa, các hợp đồng này không cung cấp bất kỳ đảm bảo nào về nội dung của nó.
10 */
11 function depositERC20(
12 address _l1Token,
13 address _l2Token,
14 uint256 _amount,
15 uint32 _l2Gas,
16 bytes calldata _data
17 ) external;
Hiện tất cả

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.

1 /**
2 * @dev gửi một lượng ERC20 vào số dư của người nhận trên L2.
3 * @param _l1Token Địa chỉ của L1 ERC20 mà chúng ta đang gửi
4 * @param _l2Token Địa chỉ của ERC20 L2 tương ứng của L1
5 * @param _to Địa chỉ L2 để ghi có khoản rút tiền.
6 * @param _amount Số lượng ERC20 cần gửi.
7 * @param _l2Gas Giới hạn gas cần thiết để hoàn tất việc gửi tiền trên L2.
8 * @param _data Dữ liệu tùy chọn để chuyển tiếp đến L2. Dữ liệu này được cung cấp
9 * chỉ để thuận tiện cho các hợp đồng bên ngoài. Ngoài việc thực thi một
10 * độ dài tối đa, các hợp đồng này không cung cấp bất kỳ đảm bảo nào về nội dung của nó.
11 */
12 function depositERC20To(
13 address _l1Token,
14 address _l2Token,
15 address _to,
16 uint256 _amount,
17 uint32 _l2Gas,
18 bytes calldata _data
19 ) external;
Hiện tất 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.

1 /*************************
2 * Các hàm liên chuỗi *
3 *************************/
4
5 /**
6 * @dev Hoàn tất việc rút tiền từ L2 sang L1 và ghi có tiền vào số dư của người nhận
7 * token ERC20 L1.
8 * Lệnh gọi này sẽ thất bại nếu việc rút tiền đã được khởi tạo từ L2 chưa được hoàn tất.
9 *
10 * @param _l1Token Địa chỉ của token L1 để finalizeWithdrawal.
11 * @param _l2Token Địa chỉ của token L2 nơi việc rút tiền được khởi tạo.
12 * @param _from Địa chỉ L2 khởi tạo giao dịch chuyển tiền.
13 * @param _to Địa chỉ L1 để ghi có khoản rút tiền.
14 * @param _amount Số lượng ERC20 cần gửi.
15 * @param _data Dữ liệu do người gửi cung cấp trên L2. Dữ liệu này được cung cấp
16 * chỉ để thuận tiện cho các hợp đồng bên ngoài. Ngoài việc thực thi một
17 * độ dài tối đa, các hợp đồng này không cung cấp bất kỳ đảm bảo nào về nội dung của nó.
18 */
19 function finalizeERC20Withdrawal(
20 address _l1Token,
21 address _l2Token,
22 address _from,
23 address _to,
24 uint256 _amount,
25 bytes calldata _data
26 ) external;
27}
Hiện tất 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ỗiopens in a new tab cho giao dịch L2 kết thúc.

IL1StandardBridge

Giao diện này được định nghĩa ở đâyopens 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.

1// SPDX-License-Identifier: MIT
2pragma solidity >0.5.0 <0.9.0;
3
4import "./IL1ERC20Bridge.sol";
5
6/**
7 * @title IL1StandardBridge
8 */
9interface IL1StandardBridge is IL1ERC20Bridge {
10 /**********
11 * Các sự kiện *
12 **********/
13 event ETHDepositInitiated(
14 address indexed _from,
15 address indexed _to,
16 uint256 _amount,
17 bytes _data
18 );
Hiện tất cả

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.

1 event ETHWithdrawalFinalized(
2 .
3 .
4 .
5 );
6
7 /********************
8 * Các hàm công khai *
9 ********************/
10
11 /**
12 * @dev Gửi một lượng ETH vào số dư của người gọi trên L2.
13 .
14 .
15 .
16 */
17 function depositETH(uint32 _l2Gas, bytes calldata _data) external payable;
18
19 /**
20 * @dev Gửi một lượng ETH vào số dư của người nhận trên L2.
21 .
22 .
23 .
24 */
25 function depositETHTo(
26 address _to,
27 uint32 _l2Gas,
28 bytes calldata _data
29 ) external payable;
30
31 /*************************
32 * Các hàm liên chuỗi *
33 *************************/
34
35 /**
36 * @dev Hoàn tất việc rút tiền từ L2 sang L1 và ghi có tiền vào số dư của người nhận
37 * token L1 ETH. Vì chỉ có xDomainMessenger mới có thể gọi hàm này, nên nó sẽ không bao giờ được gọi
38 * trước khi việc rút tiền được hoàn tất.
39 .
40 .
41 .
42 */
43 function finalizeETHWithdrawal(
44 address _from,
45 address _to,
46 uint256 _amount,
47 bytes calldata _data
48 ) external;
49}
Hiện tất cả

CrossDomainEnabled

Hợp đồng nàyopens 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.

1// SPDX-License-Identifier: MIT
2pragma solidity >0.5.0 <0.9.0;
3
4/* Interface Imports */
5import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";

Giao diện nàyopens 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.

1/**
2 * @title CrossDomainEnabled
3 * @dev Hợp đồng trợ giúp cho các hợp đồng thực hiện giao tiếp liên miền
4 *
5 * Trình biên dịch được sử dụng: được định nghĩa bởi hợp đồng kế thừa
6 */
7contract CrossDomainEnabled {
8 /*************
9 * Biến *
10 *************/
11
12 // Hợp đồng Messenger được sử dụng để gửi và nhận thông điệp từ miền khác.
13 address public messenger;
14
15 /***************
16 * Hàm dựng *
17 ***************/
18
19 /**
20 * @param _messenger Địa chỉ của CrossDomainMessenger trên lớp hiện tại.
21 */
22 constructor(address _messenger) {
23 messenger = _messenger;
24 }
Hiện tất cả

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.

1
2 /**********************
3 * Bổ ngữ hàm *
4 **********************/
5
6 /**
7 * @dev Chỉ cho phép hàm được sửa đổi được gọi bởi một tài khoản liên miền cụ thể.
8 * @param _sourceDomainAccount Tài khoản duy nhất trên miền gốc được
9 * xác thực để gọi hàm này.
10 */
11 modifier onlyFromCrossDomainAccount(address _sourceDomainAccount) {
Hiện tất cả

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.

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

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.

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

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.

1
2 _;
3 }
4
5 /**********************
6 * Các hàm nội bộ *
7 **********************/
8
9 /**
10 * @dev Lấy trình nhắn tin, thường từ bộ nhớ lưu trữ. Hàm này được hiển thị trong trường hợp một hợp đồng con
11 * cần ghi đè.
12 * @return Địa chỉ của hợp đồng trình nhắn tin liên miền nên được sử dụng.
13 */
14 function getCrossDomainMessenger() internal virtual returns (ICrossDomainMessenger) {
15 return ICrossDomainMessenger(messenger);
16 }
Hiện tất cả

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.

1
2 /**
3 * Gửi một thông điệp đến một tài khoản trên một miền khác
4 * @param _crossDomainTarget Người nhận dự định trên miền đích
5 * @param _message Dữ liệu để gửi đến mục tiêu (thường là calldata cho một hàm với
6 * `onlyFromCrossDomainAccount()`)
7 * @param _gasLimit gasLimit cho việc nhận thông điệp trên miền đích.
8 */
9 function sendCrossDomainMessage(
10 address _crossDomainTarget,
11 uint32 _gasLimit,
12 bytes memory _message
Hiện tất cả

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

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

Slitheropens 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ậpopens in a new tab
  2. Tái nhập lành tínhopens in a new tab
1 getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
2 }
3}

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 ở đâyopens in a new tab.

1// SPDX-License-Identifier: MIT
2pragma 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.

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

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

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

Giao diện nàyopens 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.

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

Giao diện nàyopens 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.

1/* Library Imports */
2import { 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.

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

Lib_PredeployAddressesopens 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.

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

Các tiện ích Địa chỉ của OpenZeppelinopens 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.

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

Tiêu chuẩn ERC-20opens 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 OpenZeppelinopens in a new tab, đảm bảo tất cả các lỗi đều dẫn đến việc hoàn nguyênopens in a new tab.

1/**
2 * @title L1StandardBridge
3 * @dev Cầu nối L1 ETH và ERC20 là một hợp đồng lưu trữ các khoản tiền L1 đã gửi và các token tiêu chuẩn
4 * đang được sử dụng trên L2. Nó đồng bộ hóa một Cầu nối L2 tương ứng, thông báo cho nó về các khoản tiền gửi
5 * và lắng nghe nó về các khoản rút tiền mới được hoàn tất.
6 *
7 */
8contract L1StandardBridge is IL1StandardBridge, CrossDomainEnabled {
9 using SafeERC20 for IERC20;
Hiện tất cả

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.

1
2 /********************************
3 * Tham chiếu Hợp đồng Bên ngoài *
4 ********************************/
5
6 address public l2TokenBridge;

Địa chỉ của L2StandardBridge.

1
2 // Ánh xạ token L1 đến token L2 đến số dư của token L1 đã gửi
3 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ềuopens 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ữ.

1
2 /***************
3 * Hàm dựng *
4 ***************/
5
6 // 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.
7 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 Proxyopens in a new tab, một hợp đồng sử dụng delegatecallopens 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.

1 /******************
2 * Khởi tạo *
3 ******************/
4
5 /**
6 * @param _l1messenger Địa chỉ L1 Messenger đang được sử dụng để giao tiếp liên chuỗi.
7 * @param _l2TokenBridge Địa chỉ cầu nối tiêu chuẩn L2.
8 */
9 // slither-disable-next-line external-function
Hiện tất cả

Thử nghiệm Slither nàyopens 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.

1 function initialize(address _l1messenger, address _l2TokenBridge) public {
2 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ướcopens 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úngopens 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.
1 messenger = _l1messenger;
2 l2TokenBridge = _l2TokenBridge;
3 }

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

1
2 /**************
3 * Gửi tiền *
4 **************/
5
6 /** @dev Bổ ngữ yêu cầu người gửi phải là EOA. Kiểm tra này có thể bị bỏ qua bởi một hợp đồng
7 * độc hại thông qua initcode, nhưng nó xử lý lỗi người dùng mà chúng tôi muốn tránh.
8 */
9 modifier onlyEOA() {
10 // Được sử dụng để ngăn chặn việc gửi tiền từ các hợp đồng (tránh mất token do nhầm lẫn)
11 require(!Address.isContract(msg.sender), "Account not EOA");
12 _;
13 }
Hiện tất cả

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

1 /**
2 * @dev Hàm này có thể được gọi mà không có dữ liệu
3 * để gửi một lượng ETH vào số dư của người gọi trên L2.
4 * Vì hàm nhận không lấy dữ liệu, một lượng
5 * mặc định thận trọng được chuyển tiếp đến L2.
6 */
7 receive() external payable onlyEOA {
8 _initiateETHDeposit(msg.sender, msg.sender, 200_000, bytes(""));
9 }
Hiện tất cả

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.

1 /**
2 * @inheritdoc IL1StandardBridge
3 */
4 function depositETH(uint32 _l2Gas, bytes calldata _data) external payable onlyEOA {
5 _initiateETHDeposit(msg.sender, msg.sender, _l2Gas, _data);
6 }
7
8 /**
9 * @inheritdoc IL1StandardBridge
10 */
11 function depositETHTo(
12 address _to,
13 uint32 _l2Gas,
14 bytes calldata _data
15 ) external payable {
16 _initiateETHDeposit(msg.sender, _to, _l2Gas, _data);
17 }
Hiện tất cả

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ế.

1 /**
2 * @dev Thực hiện logic gửi tiền bằng cách lưu trữ ETH và thông báo cho Cổng ETH L2
3 * về việc gửi tiền.
4 * @param _from Tài khoản để lấy tiền gửi từ L1.
5 * @param _to Tài khoản để cấp tiền gửi trên L2.
6 * @param _l2Gas Giới hạn gas cần thiết để hoàn tất việc gửi tiền trên L2.
7 * @param _data Dữ liệu tùy chọn để chuyển tiếp đến L2. Dữ liệu này được cung cấp
8 * chỉ để thuận tiện cho các hợp đồng bên ngoài. Ngoài việc thực thi một
9 * độ dài tối đa, các hợp đồng này không cung cấp bất kỳ đảm bảo nào về nội dung của nó.
10 */
11 function _initiateETHDeposit(
12 address _from,
13 address _to,
14 uint32 _l2Gas,
15 bytes memory _data
16 ) internal {
17 // Xây dựng calldata cho lệnh gọi finalizeDeposit
18 bytes memory message = abi.encodeWithSelector(
Hiện tất cả

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 ABIopens in a new tab. Hàm Solidity abi.encodeWithSelectoropens in a new tab tạo ra calldata đó.

1 IL2ERC20Bridge.finalizeDeposit.selector,
2 address(0),
3 Lib_PredeployAddresses.OVM_ETH,
4 _from,
5 _to,
6 msg.value,
7 _data
8 );

Thông điệp ở đây là để gọi hàm finalizeDepositopens 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
1 // Gửi calldata vào L2
2 // slither-disable-next-line reentrancy-events
3 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

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

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

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.

1 /**
2 * @inheritdoc IL1ERC20Bridge
3 */
4 function depositERC20(
5 .
6 .
7 .
8 ) external virtual onlyEOA {
9 _initiateERC20Deposit(_l1Token, _l2Token, msg.sender, msg.sender, _amount, _l2Gas, _data);
10 }
11
12 /**
13 * @inheritdoc IL1ERC20Bridge
14 */
15 function depositERC20To(
16 .
17 .
18 .
19 ) external virtual {
20 _initiateERC20Deposit(_l1Token, _l2Token, msg.sender, _to, _amount, _l2Gas, _data);
21 }
Hiện tất cả

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ế.

1 /**
2 * @dev Thực hiện logic gửi tiền bằng cách thông báo cho Token đã gửi L2
3 * hợp đồng về việc gửi tiền và gọi một trình xử lý để khóa các khoản tiền L1. (ví dụ, transferFrom)
4 *
5 * @param _l1Token Địa chỉ của L1 ERC20 chúng ta đang gửi
6 * @param _l2Token Địa chỉ của ERC20 L2 tương ứng của L1
7 * @param _from Tài khoản để lấy tiền gửi từ L1
8 * @param _to Tài khoản để cấp tiền gửi trên L2
9 * @param _amount Số lượng ERC20 cần gửi.
10 * @param _l2Gas Giới hạn gas cần thiết để hoàn tất việc gửi tiền trên L2.
11 * @param _data Dữ liệu tùy chọn để chuyển tiếp đến L2. Dữ liệu này được cung cấp
12 * chỉ để thuận tiện cho các hợp đồng bên ngoài. Ngoài việc thực thi một
13 * độ dài tối đa, các hợp đồng này không cung cấp bất kỳ đảm bảo nào về nội dung của nó.
14 */
15 function _initiateERC20Deposit(
16 address _l1Token,
17 address _l2Token,
18 address _from,
19 address _to,
20 uint256 _amount,
21 uint32 _l2Gas,
22 bytes calldata _data
23 ) internal {
Hiện tất cả

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).

1 // 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
2 // 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
3 // _from là một EOA hoặc address(0).
4 // slither-disable-next-line reentrancy-events, reentrancy-benign
5 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.

1 // Xây dựng calldata cho _l2Token.finalizeDeposit(_to, _amount)
2 bytes memory message = abi.encodeWithSelector(
3 IL2ERC20Bridge.finalizeDeposit.selector,
4 _l1Token,
5 _l2Token,
6 _from,
7 _to,
8 _amount,
9 _data
10 );
11
12 // Gửi calldata vào L2
13 // slither-disable-next-line reentrancy-events, reentrancy-benign
14 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);
15
16 // slither-disable-next-line reentrancy-benign
17 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] + _amount;
Hiện tất cả

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.

1
2 // slither-disable-next-line reentrancy-events
3 emit ERC20DepositInitiated(_l1Token, _l2Token, _from, _to, _amount, _data);
4 }
5
6 /*************************
7 * Các hàm liên chuỗi *
8 *************************/
9
10 /**
11 * @inheritdoc IL1StandardBridge
12 */
13 function finalizeETHWithdrawal(
14 address _from,
15 address _to,
16 uint256 _amount,
17 bytes calldata _data
Hiện tất cả

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ệpopens in a new tab được gửi trên L1).

1 ) 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.

1 // slither-disable-next-line reentrancy-events
2 (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.

1 require(success, "TransferHelper::safeTransferETH: ETH transfer failed");
2
3 // slither-disable-next-line reentrancy-events
4 emit ETHWithdrawalFinalized(_from, _to, _amount, _data);

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

1 }
2
3 /**
4 * @inheritdoc IL1ERC20Bridge
5 */
6 function finalizeERC20Withdrawal(
7 address _l1Token,
8 address _l2Token,
9 address _from,
10 address _to,
11 uint256 _amount,
12 bytes calldata _data
13 ) external onlyFromCrossDomainAccount(l2TokenBridge) {
Hiện tất cả

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.

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

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

1
2 // Khi một khoản rút tiền được hoàn tất trên L1, Cầu nối L1 sẽ chuyển tiền cho người rút
3 // slither-disable-next-line reentrancy-events
4 IERC20(_l1Token).safeTransfer(_to, _amount);
5
6 // slither-disable-next-line reentrancy-events
7 emit ERC20WithdrawalFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);
8 }
9
10
11 /*****************************
12 * Tạm thời - Di chuyển ETH *
13 *****************************/
14
15 /**
16 * @dev Thêm số dư ETH vào tài khoản. Điều này nhằm cho phép ETH
17 * được di chuyển từ một cổng cũ sang một cổng mới.
18 * LƯU Ý: Điều này được để lại cho một lần nâng cấp duy nhất để chúng tôi có thể nhận được ETH đã di chuyển từ
19 * hợp đồng cũ
20 */
21 function donateETH() external payable {}
22}
Hiện tất cả

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ầnopens 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àyopens in a new tab, có các hàm và sự kiện mà cầu nối tiêu chuẩn cần.

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.9;
3
4import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

Giao diện ERC-20 tiêu chuẩnopens 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-20opens in a new tab, tiêu chuẩn này không chỉ định các cơ chế để tạo và hủy token.

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

Giao diện ERC-165opens 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 ở đâyopens in a new tab.

1interface IL2StandardERC20 is IERC20, IERC165 {
2 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.

1
2 function mint(address _to, uint256 _amount) external;
3
4 function burn(address _from, uint256 _amount) external;
5
6 event Mint(address indexed _account, uint256 _amount);
7 event Burn(address indexed _account, uint256 _amount);
8}

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 IL2StandardERC20opens 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.

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.9;
3
4import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

Hợp đồng ERC-20 của OpenZeppelinopens 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.

1import "./IL2StandardERC20.sol";
2
3contract L2StandardERC20 is IL2StandardERC20, ERC20 {
4 address public l1Token;
5 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ó.

1
2 /**
3 * @param _l2Bridge Địa chỉ của cầu nối tiêu chuẩn L2.
4 * @param _l1Token Địa chỉ của token L1 tương ứng.
5 * @param _name Tên ERC20.
6 * @param _symbol Ký hiệu ERC20.
7 */
8 constructor(
9 address _l2Bridge,
10 address _l1Token,
11 string memory _name,
12 string memory _symbol
13 ) ERC20(_name, _symbol) {
14 l1Token = _l1Token;
15 l2Bridge = _l2Bridge;
16 }
Hiện tất 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.

1
2 modifier onlyL2Bridge() {
3 require(msg.sender == l2Bridge, "Only L2 Bridge can mint and burn");
4 _;
5 }
6
7
8 // slither-disable-next-line external-function
9 function supportsInterface(bytes4 _interfaceId) public pure returns (bool) {
10 bytes4 firstSupportedInterface = bytes4(keccak256("supportsInterface(bytes4)")); // ERC165
11 bytes4 secondSupportedInterface = IL2StandardERC20.l1Token.selector ^
12 IL2StandardERC20.mint.selector ^
13 IL2StandardERC20.burn.selector;
14 return _interfaceId == firstSupportedInterface || _interfaceId == secondSupportedInterface;
15 }
Hiện tất cả

Đây là cách ERC-165opens 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ềnopens in a new tab của các bộ chọn hàm ABIopens 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.

1 // slither-disable-next-line external-function
2 function mint(address _to, uint256 _amount) public virtual onlyL2Bridge {
3 _mint(_to, _amount);
4
5 emit Mint(_to, _amount);
6 }
7
8 // slither-disable-next-line external-function
9 function burn(address _from, uint256 _amount) public virtual onlyL2Bridge {
10 _burn(_from, _amount);
11
12 emit Burn(_from, _amount);
13 }
14}
Hiện tất cả

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 ở đâyopens in a new tab.

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.9;
3
4/* Interface Imports */
5import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";
6import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";
7import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";

Giao diện IL2ERC20Bridgeopens 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ỉ 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000opens in a new tab.
1/* Library Imports */
2import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";
3import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";
4import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";
5
6/* Contract Imports */
7import { IL2StandardERC20 } from "../../standards/IL2StandardERC20.sol";
8
9/**
10 * @title L2StandardBridge
11 * @dev Cầu nối Tiêu chuẩn L2 là một hợp đồng hoạt động cùng với cầu nối Tiêu chuẩn L1 để
12 * cho phép chuyển đổi ETH và ERC20 giữa L1 và L2.
13 * Hợp đồng này hoạt động như một trình đúc các token mới khi nó nghe về các khoản tiền gửi vào cầu nối Tiêu chuẩn
14 * L1.
15 * Hợp đồng này cũng hoạt động như một trình đốt các token dự định rút, thông báo cho cầu nối L1
16 * để giải phóng các khoản tiền L1.
17 */
18contract L2StandardBridge is IL2ERC20Bridge, CrossDomainEnabled {
19 /********************************
20 * Tham chiếu Hợp đồng Bên ngoài *
21 ********************************/
22
23 address public l1TokenBridge;
Hiện tất cả

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.

1
2 /***************
3 * Hàm dựng *
4 ***************/
5
6 /**
7 * @param _l2CrossDomainMessenger Trình nhắn tin liên miền được sử dụng bởi hợp đồng này.
8 * @param _l1TokenBridge Địa chỉ của cầu nối L1 được triển khai trên chuỗi chính.
9 */
10 constructor(address _l2CrossDomainMessenger, address _l1TokenBridge)
11 CrossDomainEnabled(_l2CrossDomainMessenger)
12 {
13 l1TokenBridge = _l1TokenBridge;
14 }
15
16 /***************
17 * Rút tiền *
18 ***************/
19
20 /**
21 * @inheritdoc IL2ERC20Bridge
22 */
23 function withdraw(
24 address _l2Token,
25 uint256 _amount,
26 uint32 _l1Gas,
27 bytes calldata _data
28 ) external virtual {
29 _initiateWithdrawal(_l2Token, msg.sender, msg.sender, _amount, _l1Gas, _data);
30 }
31
32 /**
33 * @inheritdoc IL2ERC20Bridge
34 */
35 function withdrawTo(
36 address _l2Token,
37 address _to,
38 uint256 _amount,
39 uint32 _l1Gas,
40 bytes calldata _data
41 ) external virtual {
42 _initiateWithdrawal(_l2Token, msg.sender, _to, _amount, _l1Gas, _data);
43 }
Hiện tất 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.

1
2 /**
3 * @dev Thực hiện logic rút tiền bằng cách đốt token và thông báo
4 * cho Cổng token L1 về việc rút tiền.
5 * @param _l2Token Địa chỉ của token L2 nơi việc rút tiền được khởi tạo.
6 * @param _from Tài khoản để lấy tiền rút từ L2.
7 * @param _to Tài khoản để cấp tiền rút trên L1.
8 * @param _amount Số lượng token cần rút.
9 * @param _l1Gas Không sử dụng, nhưng được bao gồm cho các cân nhắc tương thích về phía trước tiềm năng.
10 * @param _data Dữ liệu tùy chọn để chuyển tiếp đến L1. Dữ liệu này được cung cấp
11 * chỉ để thuận tiện cho các hợp đồng bên ngoài. Ngoài việc thực thi một
12 * độ dài tối đa, các hợp đồng này không cung cấp bất kỳ đảm bảo nào về nội dung của nó.
13 */
14 function _initiateWithdrawal(
15 address _l2Token,
16 address _from,
17 address _to,
18 uint256 _amount,
19 uint32 _l1Gas,
20 bytes calldata _data
21 ) internal {
22 // Khi một khoản rút tiền được khởi tạo, chúng tôi đốt tiền của người rút để ngăn chặn việc sử dụng L2
23 // sau đó
24 // slither-disable-next-line reentrancy-events
25 IL2StandardERC20(_l2Token).burn(msg.sender, _amount);
Hiện tất cả

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).

1
2 // Xây dựng calldata cho l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)
3 // slither-disable-next-line reentrancy-events
4 address l1Token = IL2StandardERC20(_l2Token).l1Token();
5 bytes memory message;
6
7 if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {

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

1 message = abi.encodeWithSelector(
2 IL1StandardBridge.finalizeETHWithdrawal.selector,
3 _from,
4 _to,
5 _amount,
6 _data
7 );
8 } else {
9 message = abi.encodeWithSelector(
10 IL1ERC20Bridge.finalizeERC20Withdrawal.selector,
11 l1Token,
12 _l2Token,
13 _from,
14 _to,
15 _amount,
16 _data
17 );
18 }
19
20 // Gửi thông điệp lên cầu nối L1
21 // slither-disable-next-line reentrancy-events
22 sendCrossDomainMessage(l1TokenBridge, _l1Gas, message);
23
24 // slither-disable-next-line reentrancy-events
25 emit WithdrawalInitiated(l1Token, _l2Token, msg.sender, _to, _amount, _data);
26 }
27
28 /************************************
29 * Hàm liên chuỗi: Gửi tiền *
30 ************************************/
31
32 /**
33 * @inheritdoc IL2ERC20Bridge
34 */
35 function finalizeDeposit(
36 address _l1Token,
37 address _l2Token,
38 address _from,
39 address _to,
40 uint256 _amount,
41 bytes calldata _data
Hiện tất cả

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

1 ) 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.

1 // Kiểm tra token mục tiêu có tuân thủ và
2 // xác minh token đã gửi trên L1 khớp với biểu diễn token đã gửi L2 ở đây
3 if (
4 // slither-disable-next-line reentrancy-events
5 ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
6 _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
1 ) {
2 // 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
3 // token.
4 // slither-disable-next-line reentrancy-events
5 IL2StandardERC20(_l2Token).mint(_to, _amount);
6 // slither-disable-next-line reentrancy-events
7 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
1 } else {
2 // Hoặc token L2 đang được gửi vào không đồng ý về địa chỉ chính xác
3 // của token L1 của nó, hoặc không hỗ trợ giao diện chính xác.
4 // Điều này chỉ nên xảy ra nếu có một token L2 độc hại, hoặc nếu người dùng bằng cách nào đó
5 // chỉ định sai địa chỉ token L2 để gửi vào.
6 // Trong cả hai trường hợp, chúng tôi dừng quá trình ở đây và xây dựng một thông điệp
7 // rút tiền để người dùng có thể lấy lại tiền của họ trong một số trường hợp.
8 // Không có cách nào để ngăn chặn hoàn toàn các hợp đồng token độc hại, nhưng điều này giới hạn
9 // lỗi người dùng và giảm thiểu một số hình thức hành vi hợp đồng độc hại.
Hiện tất cả

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.

1 bytes memory message = abi.encodeWithSelector(
2 IL1ERC20Bridge.finalizeERC20Withdrawal.selector,
3 _l1Token,
4 _l2Token,
5 _to, // đã hoán đổi _to và _from ở đây để trả lại khoản tiền gửi cho người gửi
6 _from,
7 _amount,
8 _data
9 );
10
11 // Gửi thông điệp lên cầu nối L1
12 // slither-disable-next-line reentrancy-events
13 sendCrossDomainMessage(l1TokenBridge, 0, message);
14 // slither-disable-next-line reentrancy-events
15 emit DepositFailed(_l1Token, _l2Token, _from, _to, _amount, _data);
16 }
17 }
18}
Hiện tất cả

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ứ baopens 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 đâyopens in a new tab.

Lần cập nhật trang lần cuối: 22 tháng 10, 2025

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