Пошаговый обзор контракта стандартного Моста Optimism
Optimism (opens in a new tab) — это оптимистический ролл-ап. Оптимистические ролл-апы могут обрабатывать транзакции по гораздо более низкой цене, чем основная сеть Ethereum (также известная как уровень 1, или L1), поскольку транзакции обрабатываются только несколькими узлами, а не каждым узлом в сети. В то же время все данные записываются в L1, поэтому все можно доказать и реконструировать со всеми гарантиями целостности и доступности основной сети.
Чтобы использовать активы L1 в Optimism (или в любом другом L2), их необходимо перевести через Мост. Один из способов добиться этого — предоставить пользователям возможность заблокировать активы (наиболее распространенными являются ETH и токены ERC-20) на L1 и получить эквивалентные активы для использования на L2. В конечном итоге тот, кто их получит, может захотеть вернуть их на L1 через Мост. При этом активы сжигаются на L2, а затем возвращаются пользователю на L1.
Именно так работает стандартный Мост Optimism (opens in a new tab). В этой статье мы рассмотрим исходный код этого Моста, чтобы увидеть, как он работает, и изучим его как пример хорошо написанного кода на Solidity.
Потоки управления
Мост имеет два основных потока:
- Депозит (с L1 на L2)
- Вывод (с L2 на L1)
Поток депозита
Уровень 1
- При внесении ERC-20 вкладчик дает Мосту разрешение на расходование вносимой суммы
- Вкладчик вызывает Мост L1 (
depositERC20,depositERC20To,depositETHилиdepositETHTo) - Мост L1 получает во владение переведенный через Мост актив
- ETH: актив передается вкладчиком в рамках вызова
- ERC-20: актив переводится Мостом самому себе, используя разрешение, предоставленное вкладчиком
- Мост L1 использует механизм междоменных сообщений для вызова
finalizeDepositна Мосте L2
Уровень 2
- Мост L2 проверяет, что вызов
finalizeDepositявляется легитимным:- Вызов поступил из контракта междоменных сообщений
- Изначально был отправлен с Моста на L1
- Мост L2 проверяет, является ли контракт токена ERC-20 на L2 верным:
- Контракт L2 сообщает, что его аналог на L1 совпадает с тем, от которого поступили токены на L1
- Контракт L2 сообщает, что он поддерживает правильный интерфейс (с использованием ERC-165 (opens in a new tab)).
- Если контракт L2 является верным, он вызывается для выпуска соответствующего количества токенов на соответствующий адрес. В противном случае запускается процесс вывода, чтобы пользователь мог получить токены на L1.
Поток вывода
Уровень 2
- Инициатор вывода вызывает Мост L2 (
withdrawилиwithdrawTo) - Мост L2 сжигает соответствующее количество токенов, принадлежащих
msg.sender - Мост L2 использует механизм междоменных сообщений для вызова
finalizeETHWithdrawalилиfinalizeERC20Withdrawalна Мосте L1
Уровень 1
- Мост L1 проверяет, что вызов
finalizeETHWithdrawalилиfinalizeERC20Withdrawalявляется легитимным:- Вызов поступил через механизм междоменных сообщений
- Изначально был отправлен с Моста на L2
- Мост L1 переводит соответствующий актив (ETH или ERC-20) на соответствующий адрес
Код уровня 1
Это код, который выполняется на L1, в основной сети Ethereum.
IL1ERC20Bridge
Этот интерфейс определен здесь (opens in a new tab). Он включает функции и определения, необходимые для перевода токенов ERC-20 через Мост.
1// SPDX-License-Identifier: MITБольшая часть кода Optimism выпущена под лицензией MIT (opens in a new tab).
1pragma solidity >0.5.0 <0.9.0;На момент написания статьи последняя версия Solidity — 0.8.12. Пока не выйдет версия 0.9.0, неизвестно, будет ли этот код совместим с ней.
1/**2 * @title IL1ERC20Bridge3 */4interface IL1ERC20Bridge {5 /**********6 * События *7 **********/89 event ERC20DepositInitiated(Показать всеВ терминологии Моста Optimism депозит означает перевод с L1 на L2, а вывод — перевод с L2 на L1.
1 address indexed _l1Token,2 address indexed _l2Token,В большинстве случаев адрес ERC-20 на L1 не совпадает с адресом эквивалентного ERC-20 на L2.
Список адресов токенов можно посмотреть здесь (opens in a new tab).
Адрес с chainId 1 находится на L1 (в основной сети), а адрес с chainId 10 — на L2 (в сети Optimism).
Два других значения chainId предназначены для тестовой сети Kovan (42) и тестовой сети Optimistic Kovan (69).
1 address indexed _from,2 address _to,3 uint256 _amount,4 bytes _data5 );К переводам можно добавлять примечания, и в этом случае они добавляются в сообщающие о них события.
1 event ERC20WithdrawalFinalized(2 address indexed _l1Token,3 address indexed _l2Token,4 address indexed _from,5 address _to,6 uint256 _amount,7 bytes _data8 );Один и тот же контракт Моста обрабатывает переводы в обоих направлениях. В случае Моста L1 это означает инициализацию депозитов и финализацию выводов.
12 /********************3 * Публичные функции *4 ********************/56 /**7 * @dev Получить адрес соответствующего контракта Моста L2.8 * @return Адрес соответствующего контракта Моста L2.9 */10 function l2TokenBridge() external returns (address);Показать всеЭта функция на самом деле не нужна, так как на L2 это предварительно развернутый контракт, поэтому он всегда находится по адресу 0x4200000000000000000000000000000000000010.
Она здесь для симметрии с Мостом L2, потому что адрес Моста L1 узнать не тривиально.
1 /**2 * @dev внести сумму ERC20 на баланс вызывающего на L2.3 * @param _l1Token Адрес ERC20 на L1, который мы вносим4 * @param _l2Token Адрес соответствующего ERC20 на L25 * @param _amount Сумма ERC20 для внесения6 * @param _l2Gas Лимит газа, необходимый для завершения депозита на L2.7 * @param _data Необязательные данные для пересылки на L2. Эти данные предоставляются8 * исключительно для удобства внешних контрактов. Кроме ограничения максимальной9 * длины, эти контракты не дают никаких гарантий относительно их содержимого.10 */11 function depositERC20(12 address _l1Token,13 address _l2Token,14 uint256 _amount,15 uint32 _l2Gas,16 bytes calldata _data17 ) external;Показать всеПараметр _l2Gas — это количество газа на L2, которое может потратить транзакция.
До определенного (высокого) лимита это бесплатно (opens in a new tab), поэтому, если только контракт ERC-20 не делает что-то действительно странное при выпуске, это не должно быть проблемой.
Эта функция предназначена для распространенного сценария, когда пользователь переводит активы через Мост на тот же адрес в другом блокчейне.
1 /**2 * @dev внести сумму ERC20 на баланс получателя на L2.3 * @param _l1Token Адрес ERC20 на L1, который мы вносим4 * @param _l2Token Адрес соответствующего ERC20 на L25 * @param _to Адрес L2, на который будет зачислен вывод средств.6 * @param _amount Сумма ERC20 для внесения.7 * @param _l2Gas Лимит газа, необходимый для завершения депозита на L2.8 * @param _data Необязательные данные для пересылки на L2. Эти данные предоставляются9 * исключительно для удобства внешних контрактов. Кроме ограничения максимальной10 * длины, эти контракты не дают никаких гарантий относительно их содержимого.11 */12 function depositERC20To(13 address _l1Token,14 address _l2Token,15 address _to,16 uint256 _amount,17 uint32 _l2Gas,18 bytes calldata _data19 ) external;Показать всеЭта функция почти идентична depositERC20, но позволяет отправить ERC-20 на другой адрес.
1 /*************************2 * Межсетевые функции *3 *************************/45 /**6 * @dev Завершить вывод с L2 на L1 и зачислить средства на баланс получателя7 * токена ERC20 на L1.8 * Этот вызов не удастся, если инициированный вывод с L2 не был завершен.9 *10 * @param _l1Token Адрес токена L1 для finalizeWithdrawal.11 * @param _l2Token Адрес токена L2, на котором был инициирован вывод.12 * @param _from Адрес L2, инициирующий перевод.13 * @param _to Адрес L1, на который будет зачислен вывод.14 * @param _amount Сумма ERC20 для внесения.15 * @param _data Данные, предоставленные отправителем на L2. Эти данные предоставляются16 * исключительно для удобства внешних контрактов. Кроме ограничения максимальной17 * длины, эти контракты не дают никаких гарантий относительно их содержимого.18 */19 function finalizeERC20Withdrawal(20 address _l1Token,21 address _l2Token,22 address _from,23 address _to,24 uint256 _amount,25 bytes calldata _data26 ) external;27}Показать всеВывод средств (и другие сообщения с L2 на L1) в Optimism — это двухэтапный процесс:
- Инициирующая транзакция на L2.
- Завершающая или запрашивающая транзакция на L1. Эта транзакция должна произойти после окончания периода оспаривания сбоев (opens in a new tab) для транзакции L2.
IL1StandardBridge
Этот интерфейс определен здесь (opens in a new tab).
Этот файл содержит определения событий и функций для ETH.
Эти определения очень похожи на определения в IL1ERC20Bridge, приведенные выше для ERC-20.
Интерфейс Моста разделен на два файла, поскольку некоторые токены ERC-20 требуют специальной обработки и не могут обрабатываться стандартным Мостом.
Таким образом, пользовательский Мост, обрабатывающий такой токен, может реализовать IL1ERC20Bridge и не должен также переводить ETH через Мост.
1// SPDX-License-Identifier: MIT2pragma solidity >0.5.0 <0.9.0;34import "./IL1ERC20Bridge.sol";56/**7 * @title IL1StandardBridge8 */9interface IL1StandardBridge is IL1ERC20Bridge {10 /**********11 * События *12 **********/13 event ETHDepositInitiated(14 address indexed _from,15 address indexed _to,16 uint256 _amount,17 bytes _data18 );Показать всеЭто событие почти идентично версии для ERC-20 (ERC20DepositInitiated), за исключением отсутствия адресов токенов L1 и L2.
То же самое относится и к другим событиям и функциям.
1 event ETHWithdrawalFinalized(2 .3 .4 .5 );67 /********************8 * Публичные функции *9 ********************/1011 /**12 * @dev Депозит суммы ETH на баланс вызывающего на L2.13 .14 .15 .16 */17 function depositETH(uint32 _l2Gas, bytes calldata _data) external payable;1819 /**20 * @dev Депозит суммы ETH на баланс получателя на L2.21 .22 .23 .24 */25 function depositETHTo(26 address _to,27 uint32 _l2Gas,28 bytes calldata _data29 ) external payable;3031 /*************************32 * Межсетевые функции *33 *************************/3435 /**36 * @dev Завершить вывод с L2 на L1 и зачислить средства на баланс получателя37 * токена ETH на L1. Поскольку только xDomainMessenger может вызывать эту функцию, она никогда не будет вызвана38 * до завершения вывода.39 .40 .41 .42 */43 function finalizeETHWithdrawal(44 address _from,45 address _to,46 uint256 _amount,47 bytes calldata _data48 ) external;49}Показать всеCrossDomainEnabled
Этот контракт (opens in a new tab) наследуется обоими Мостами (L1 и L2) для отправки сообщений на другой уровень.
1// SPDX-License-Identifier: MIT2pragma solidity >0.5.0 <0.9.0;34/* Interface Imports */5import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";Этот интерфейс (opens in a new tab) сообщает контракту, как отправлять сообщения на другой уровень с помощью междоменного мессенджера. Этот междоменный мессенджер — это целая отдельная система, которая заслуживает отдельной статьи, и я надеюсь написать ее в будущем.
1/**2 * @title CrossDomainEnabled3 * @dev Вспомогательный контракт для контрактов, выполняющих междоменные коммуникации4 *5 * Используемый компилятор: определяется наследующим контрактом6 */7contract CrossDomainEnabled {8 /*************9 * Переменные *10 *************/1112 // Контракт-мессенджер, используемый для отправки и получения сообщений из другого домена.13 address public messenger;1415 /***************16 * Конструктор *17 ***************/1819 /**20 * @param _messenger Адрес CrossDomainMessenger на текущем уровне.21 */22 constructor(address _messenger) {23 messenger = _messenger;24 }Показать всеЕдинственный параметр, который должен знать контракт, — это адрес междоменного мессенджера на этом уровне. Этот параметр устанавливается один раз в конструкторе и никогда не изменяется.
12 /**********************3 * Модификаторы функций *4 **********************/56 /**7 * Гарантирует, что измененную функцию может вызывать только определенная междоменная учетная запись.8 * @param _sourceDomainAccount Единственный аккаунт в исходном домене, который аутентифицирован для вызова этой функции.9 */10 modifier onlyFromCrossDomainAccount(address _sourceDomainAccount) {Показать всеМеждоменный обмен сообщениями доступен любому контракту в блокчейне, в котором он запущен (либо в основной сети Ethereum, либо в Optimism). Но нам нужно, чтобы Мост на каждой стороне доверял только определенным сообщениям, если они исходят от Моста на другой стороне.
1 require(2 msg.sender == address(getCrossDomainMessenger()),3 "OVM_XCHAIN: messenger contract unauthenticated"4 );Доверять можно только сообщениям от соответствующего междоменного мессенджера (messenger, как вы увидите ниже).
12 require(3 getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,4 "OVM_XCHAIN: wrong sender of cross-domain message"5 );Междоменный мессенджер предоставляет адрес, отправивший сообщение на другой уровень, с помощью функции .xDomainMessageSender() (opens in a new tab).
Пока он вызывается в транзакции, инициированной сообщением, он может предоставить эту информацию.
Нам нужно убедиться, что полученное сообщение пришло от другого Моста.
12 _;3 }45 /**********************6 * Внутренние функции *7 **********************/89 /**10 * Получает мессенджер, обычно из хранилища. Эта функция доступна на случай, если дочернему контракту11 * потребуется переопределение.12 * @return Адрес контракта междоменного мессенджера, который следует использовать.13 */14 function getCrossDomainMessenger() internal virtual returns (ICrossDomainMessenger) {15 return ICrossDomainMessenger(messenger);16 }Показать всеЭта функция возвращает междоменный мессенджер.
Мы используем функцию, а не переменную messenger, чтобы позволить контрактам, которые наследуются от этого, использовать алгоритм для указания, какой междоменный мессенджер использовать.
12 /**3 * Отправляет сообщение на аккаунт в другом домене4 * @param _crossDomainTarget Предполагаемый получатель в домене назначения5 * @param _message Данные для отправки цели (обычно calldata для функции с6 * `onlyFromCrossDomainAccount()`)7 * @param _gasLimit gasLimit для получения сообщения в целевом домене.8 */9 function sendCrossDomainMessage(10 address _crossDomainTarget,11 uint32 _gasLimit,12 bytes memory _messageПоказать всеНаконец, функция, которая отправляет сообщение на другой уровень.
1 ) internal {2 // slither-disable-next-line reentrancy-events, reentrancy-benignSlither (opens in a new tab) — это статический анализатор, который Optimism запускает для каждого контракта для поиска уязвимостей и других потенциальных проблем. В этом случае следующая строка вызывает две уязвимости:
1 getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);2 }3}В данном случае мы не беспокоимся о повторном входе, поскольку знаем, что getCrossDomainMessenger() возвращает надежный адрес, даже если Slither не может этого знать.
Контракт Моста L1
Исходный код этого контракта находится здесь (opens in a new tab).
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;Интерфейсы могут быть частью других контрактов, поэтому они должны поддерживать широкий диапазон версий Solidity. Но сам Мост — это наш контракт, и мы можем быть строги в отношении того, какую версию Solidity он использует.
1/* Interface Imports */2import { IL1StandardBridge } from "./IL1StandardBridge.sol";3import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";IL1ERC20Bridge и IL1StandardBridge описаны выше.
1import { IL2ERC20Bridge } from "../../L2/messaging/IL2ERC20Bridge.sol";Этот интерфейс (opens in a new tab) позволяет нам создавать сообщения для управления стандартным Мостом на L2.
1import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";Этот интерфейс (opens in a new tab) позволяет нам управлять контрактами ERC-20. Вы можете прочитать больше об этом здесь.
1/* Library Imports */2import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";Как объяснялось выше, этот контракт используется для межуровневого обмена сообщениями.
1import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";Lib_PredeployAddresses (opens in a new tab) содержит адреса для контрактов L2, которые всегда имеют один и тот же адрес. Сюда входит стандартный Мост на L2.
1import { Address } from "@openzeppelin/contracts/utils/Address.sol";Утилиты Address от OpenZeppelin (opens in a new tab). Они используются для различения адресов контрактов и адресов, принадлежащих внешним аккаунтам (EOA).
Обратите внимание, что это не идеальное решение, поскольку невозможно отличить прямые вызовы от вызовов, сделанных из конструктора контракта, но, по крайней мере, это позволяет нам выявлять и предотвращать некоторые распространенные ошибки пользователей.
1import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";Стандарт ERC-20 (opens in a new tab) поддерживает два способа, которыми контракт может сообщить о сбое:
- Revert (откат)
- Вернуть
false
Обработка обоих случаев усложнила бы наш код, поэтому вместо этого мы используем SafeERC20 от OpenZeppelin (opens in a new tab), который гарантирует, что все сбои приводят к откату (opens in a new tab).
1/**2 * @title L1StandardBridge3 * @dev Мост L1 для ETH и ERC20 — это контракт, который хранит депонированные средства L1 и стандартные4 * токены, используемые на L2. Он синхронизирует соответствующий Мост L2, информируя его о депозитах5 * и прослушивая его на предмет новых завершенных выводов.6 *7 */8contract L1StandardBridge is IL1StandardBridge, CrossDomainEnabled {9 using SafeERC20 for IERC20;Показать всеЭта строка указывает на использование оболочки SafeERC20 каждый раз, когда мы используем интерфейс IERC20.
12 /********************************3 * Ссылки на внешние контракты *4 ********************************/56 address public l2TokenBridge;Адрес L2StandardBridge.
12 // Сопоставляет токен L1 с токеном L2 для баланса депонированного токена L13 mapping(address => mapping(address => uint256)) public deposits;Двойное сопоставление (mapping) (opens in a new tab) — это способ определения двумерного разреженного массива (opens in a new tab).
Значения в этой структуре данных идентифицируются как deposit[адрес токена L1][адрес токена L2].
Значение по умолчанию равно нулю.
В хранилище записываются только ячейки, которым присвоено другое значение.
12 /***************3 * Конструктор *4 ***************/56 // Этот контракт находится за прокси, поэтому параметры конструктора не будут использоваться.7 constructor() CrossDomainEnabled(address(0)) {}Чтобы иметь возможность обновлять этот контракт без необходимости копировать все переменные в хранилище.
Для этого мы используем Прокси (opens in a new tab), контракт, который использует delegatecall (opens in a new tab) для перенаправления вызовов отдельному контракту, адрес которого хранится в прокси-контракте (при обновлении вы указываете прокси-контракту изменить этот адрес).
Когда вы используете delegatecall, хранилище остается хранилищем вызывающего контракта, поэтому значения всех переменных состояния контракта не затрагиваются.
Одним из следствий этого шаблона является то, что хранилище контракта, который является вызываемым для delegatecall, не используется, и поэтому значения конструктора, переданные ему, не имеют значения.
Именно по этой причине мы можем предоставить бессмысленное значение конструктору CrossDomainEnabled.
Это также причина того, что приведенная ниже инициализация отделена от конструктора.
1 /******************2 * Инициализация *3 ******************/45 /**6 * @param _l1messenger Адрес мессенджера L1, используемый для межсетевых коммуникаций.7 * @param _l2TokenBridge Адрес стандартного Моста L2.8 */9 // slither-disable-next-line external-functionПоказать всеЭтот тест Slither (opens in a new tab) идентифицирует функции, которые не вызываются из кода контракта и поэтому могут быть объявлены как external вместо public.
Стоимость газа для external-функций может быть ниже, поскольку им можно предоставить параметры в calldata.
Функции, объявленные как public, должны быть доступны изнутри контракта.
Контракты не могут изменять свои собственные calldata, поэтому параметры должны находиться в памяти.
Когда такая функция вызывается извне, необходимо скопировать calldata в память, что стоит газ.
В этом случае функция вызывается только один раз, поэтому неэффективность для нас не имеет значения.
1 function initialize(address _l1messenger, address _l2TokenBridge) public {2 require(messenger == address(0), "Contract has already been initialized.");Функцию initialize следует вызывать только один раз.
Если адрес междоменного мессенджера L1 или Моста токенов L2 изменится, мы создадим новый прокси и новый Мост, который его вызывает.
Такое вряд ли случится, за исключением случаев обновления всей системы, что является очень редким явлением.
Обратите внимание, что у этой функции нет никакого механизма, который ограничивает, кто может ее вызывать.
Это означает, что теоретически злоумышленник может подождать, пока мы развернем прокси и первую версию Моста, а затем опередить (opens in a new tab) транзакцию, чтобы добраться до функции initialize раньше, чем это сделает легитимный пользователь. Но есть два способа предотвратить это:
- Если контракты развертываются не напрямую EOA, а в транзакции, в которой их создает другой контракт (opens in a new tab), весь процесс может быть атомарным и завершиться до выполнения любой другой транзакции.
- Если легитимный вызов
initializeне удался, всегда можно проигнорировать вновь созданный прокси и Мост и создать новые.
1 messenger = _l1messenger;2 l2TokenBridge = _l2TokenBridge;3 }Это два параметра, которые должен знать Мост.
12 /**************3 * Внесение депозита *4 **************/56 /** @dev Модификатор, требующий, чтобы отправитель был EOA. Эту проверку можно обойти с помощью вредоносного7 * контракта через initcode, но она помогает избежать ошибки пользователя, которой мы хотим избежать.8 */9 modifier onlyEOA() {10 // Используется для прекращения депозитов от контрактов (во избежание случайной потери токенов)11 require(!Address.isContract(msg.sender), "Account not EOA");12 _;13 }Показать всеИменно по этой причине нам понадобились утилиты Address от OpenZeppelin.
1 /**2 * @dev Эту функцию можно вызвать без данных3 * для внесения суммы ETH на баланс вызывающего на L2.4 * Поскольку функция receive не принимает данные, консервативная5 * сумма по умолчанию пересылается на L2.6 */7 receive() external payable onlyEOA {8 _initiateETHDeposit(msg.sender, msg.sender, 200_000, bytes(""));9 }Показать всеЭта функция существует для целей тестирования. Обратите внимание, что она не появляется в определениях интерфейса — она не предназначена для обычного использования.
1 /**2 * @inheritdoc IL1StandardBridge3 */4 function depositETH(uint32 _l2Gas, bytes calldata _data) external payable onlyEOA {5 _initiateETHDeposit(msg.sender, msg.sender, _l2Gas, _data);6 }78 /**9 * @inheritdoc IL1StandardBridge10 */11 function depositETHTo(12 address _to,13 uint32 _l2Gas,14 bytes calldata _data15 ) external payable {16 _initiateETHDeposit(msg.sender, _to, _l2Gas, _data);17 }Показать всеЭти две функции являются обертками _initiateETHDeposit, функции, которая обрабатывает фактический депозит ETH.
1 /**2 * @dev Выполняет логику для депозитов, сохраняя ETH и информируя шлюз L2 ETH о3 * депозите.4 * @param _from Аккаунт для снятия депозита на L1.5 * @param _to Аккаунт для зачисления депозита на L2.6 * @param _l2Gas Лимит газа, необходимый для завершения депозита на L2.7 * @param _data Необязательные данные для пересылки на L2. Эти данные предоставляются8 * исключительно для удобства внешних контрактов. Кроме ограничения максимальной9 * длины, эти контракты не дают никаких гарантий относительно их содержимого.10 */11 function _initiateETHDeposit(12 address _from,13 address _to,14 uint32 _l2Gas,15 bytes memory _data16 ) internal {17 // Конструируем calldata для вызова finalizeDeposit18 bytes memory message = abi.encodeWithSelector(Показать всеМеждоменные сообщения работают следующим образом: контракт назначения вызывается с сообщением в качестве его calldata.
Контракты Solidity всегда интерпретируют свои calldata в соответствии с
спецификациями ABI (opens in a new tab).
Функция Solidity abi.encodeWithSelector (opens in a new tab) создает эти calldata.
1 IL2ERC20Bridge.finalizeDeposit.selector,2 address(0),3 Lib_PredeployAddresses.OVM_ETH,4 _from,5 _to,6 msg.value,7 _data8 );Здесь сообщение вызывает функцию finalizeDeposit (opens in a new tab) со следующими параметрами:
| Параметр | Значение | Значение |
|---|---|---|
| _l1Token | address(0) | Специальное значение для обозначения ETH (который не является токеном ERC-20) на L1 |
| _l2Token | Lib_PredeployAddresses.OVM_ETH | Контракт L2, который управляет ETH в Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (этот контракт предназначен только для внутреннего использования в Optimism) |
| _from | _from | Адрес на L1, который отправляет ETH |
| _to | _to | Адрес на L2, который получает ETH |
| сумма | msg.value | Количество отправленных wei (которые уже отправлены на Мост) |
| _data | _data | Дополнительные данные для присоединения к депозиту |
1 // Отправить calldata на L22 // slither-disable-next-line reentrancy-events3 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);Отправьте сообщение через междоменный мессенджер.
1 // slither-disable-next-line reentrancy-events2 emit ETHDepositInitiated(_from, _to, msg.value, _data);3 }Выпустить событие, чтобы уведомить любое децентрализованное приложение, которое отслеживает этот перевод.
1 /**2 * @inheritdoc IL1ERC20Bridge3 */4 function depositERC20(5 .6 .7 .8 ) external virtual onlyEOA {9 _initiateERC20Deposit(_l1Token, _l2Token, msg.sender, msg.sender, _amount, _l2Gas, _data);10 }1112 /**13 * @inheritdoc IL1ERC20Bridge14 */15 function depositERC20To(16 .17 .18 .19 ) external virtual {20 _initiateERC20Deposit(_l1Token, _l2Token, msg.sender, _to, _amount, _l2Gas, _data);21 }Показать всеЭти две функции являются обертками _initiateERC20Deposit, функции, которая обрабатывает фактический депозит ERC-20.
1 /**2 * @dev Выполняет логику для депозитов, информируя контракт депонированного токена L23 * о депозите и вызывая обработчик для блокировки средств на L1 (например, transferFrom).4 *5 * @param _l1Token Адрес ERC20 на L1, который мы вносим6 * @param _l2Token Адрес соответствующего ERC20 на L27 * @param _from Аккаунт для снятия депозита на L18 * @param _to Аккаунт для зачисления депозита на L29 * @param _amount Сумма ERC20 для внесения.10 * @param _l2Gas Лимит газа, необходимый для завершения депозита на L2.11 * @param _data Необязательные данные для пересылки на L2. Эти данные предоставляются12 * исключительно для удобства внешних контрактов. Кроме ограничения максимальной13 * длины, эти контракты не дают никаких гарантий относительно их содержимого.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 _data23 ) internal {Показать всеЭта функция аналогична _initiateETHDeposit выше, но с несколькими важными отличиями.
Первое отличие состоит в том, что эта функция получает адреса токенов и сумму для перевода в качестве параметров.
В случае с ETH вызов Моста уже включает перевод актива на счет Моста (msg.value).
1 // Когда депозит инициируется на L1, Мост L1 переводит средства себе для будущих2 // выводов. safeTransferFrom также проверяет, есть ли у контракта код, поэтому это не удастся, если3 // _from является EOA или address(0).4 // slither-disable-next-line reentrancy-events, reentrancy-benign5 IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);Переводы токенов ERC-20 следуют иному процессу, чем ETH:
- Пользователь (
_from) дает Мосту разрешение на перевод соответствующих токенов. - Пользователь вызывает Мост, указывая адрес контракта токена, сумму и т. д.
- Мост переводит токены (самому себе) в рамках процесса депозита.
Первый шаг может выполняться в отдельной транзакции от двух последних.
Однако упреждающее выполнение (front-running) не является проблемой, поскольку две функции, которые вызывают _initiateERC20Deposit (depositERC20 и depositERC20To), вызывают эту функцию только с msg.sender в качестве параметра _from.
1 // Конструируем calldata для _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 _data10 );1112 // Отправить calldata на L213 // slither-disable-next-line reentrancy-events, reentrancy-benign14 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);1516 // slither-disable-next-line reentrancy-benign17 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] + _amount;Показать всеДобавьте внесенную сумму токенов в структуру данных deposits.
На L2 может быть несколько адресов, соответствующих одному и тому же токену ERC-20 на L1, поэтому недостаточно использовать баланс Моста токена ERC-20 на L1 для отслеживания депозитов.
12 // slither-disable-next-line reentrancy-events3 emit ERC20DepositInitiated(_l1Token, _l2Token, _from, _to, _amount, _data);4 }56 /*************************7 * Межсетевые функции *8 *************************/910 /**11 * @inheritdoc IL1StandardBridge12 */13 function finalizeETHWithdrawal(14 address _from,15 address _to,16 uint256 _amount,17 bytes calldata _dataПоказать всеМост L2 отправляет сообщение междоменному мессенджеру L2, который заставляет междоменный мессенджер L1 вызывать эту функцию (конечно, как только транзакция, завершающая сообщение (opens in a new tab), будет отправлена на L1).
1 ) external onlyFromCrossDomainAccount(l2TokenBridge) {Убедитесь, что это легитимное сообщение, исходящее от междоменного мессенджера и отправленное с Моста токенов L2. Эта функция используется для вывода ETH с Моста, поэтому мы должны убедиться, что она вызывается только авторизованным вызывающим.
1 // slither-disable-next-line reentrancy-events2 (bool success, ) = _to.call{ value: _amount }(new bytes(0));Способ перевода ETH заключается в вызове получателя с суммой wei в msg.value.
1 require(success, "TransferHelper::safeTransferETH: ETH transfer failed");23 // slither-disable-next-line reentrancy-events4 emit ETHWithdrawalFinalized(_from, _to, _amount, _data);Выпустить событие о выводе средств.
1 }23 /**4 * @inheritdoc IL1ERC20Bridge5 */6 function finalizeERC20Withdrawal(7 address _l1Token,8 address _l2Token,9 address _from,10 address _to,11 uint256 _amount,12 bytes calldata _data13 ) external onlyFromCrossDomainAccount(l2TokenBridge) {Показать всеЭта функция аналогична finalizeETHWithdrawal выше, с необходимыми изменениями для токенов ERC-20.
1 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] - _amount;Обновите структуру данных deposits.
12 // Когда вывод завершается на L1, Мост L1 переводит средства тому, кто их выводит3 // slither-disable-next-line reentrancy-events4 IERC20(_l1Token).safeTransfer(_to, _amount);56 // slither-disable-next-line reentrancy-events7 emit ERC20WithdrawalFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);8 }91011 /*****************************12 * Временно - миграция ETH *13 *****************************/1415 /**16 * @dev Добавляет баланс ETH на аккаунт. Это предназначено для того, чтобы17 * ETH можно было перенести со старого шлюза на новый.18 * ПРИМЕЧАНИЕ: Это оставлено только для одного обновления, чтобы мы могли получить перенесенный ETH со19 * старого контракта20 */21 function donateETH() external payable {}22}Показать всеСуществовала более ранняя реализация Моста.
Когда мы перешли от той реализации к этой, нам пришлось переместить все активы.
Токены ERC-20 можно просто переместить.
Однако для перевода ETH на контракт необходимо одобрение этого контракта, что нам и предоставляет donateETH.
Токены ERC-20 на L2
Чтобы токен ERC-20 подходил для стандартного Моста, он должен позволять стандартному Мосту и только стандартному Мосту выпускать токены. Это необходимо, потому что Мосты должны гарантировать, что количество токенов, находящихся в обращении в сети Optimism, равно количеству токенов, заблокированных в контракте Моста L1. Если на L2 будет слишком много токенов, некоторые пользователи не смогут вернуть свои активы на L1 через Мост. Вместо доверенного Моста мы, по сути, воссоздали бы частичное банковское резервирование (opens in a new tab). Если на L1 слишком много токенов, некоторые из этих токенов навсегда останутся заблокированными в контракте Моста, потому что их невозможно освободить без сжигания токенов L2.
IL2StandardERC20
Каждый токен ERC-20 на L2, который использует стандартный Мост, должен предоставлять этот интерфейс (opens in a new tab), который содержит функции и события, необходимые стандартному Мосту.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";Стандартный интерфейс ERC-20 (opens in a new tab) не включает функции mint и burn.
Эти методы не требуются стандартом ERC-20 (opens in a new tab), который оставляет неуказанными механизмы создания и уничтожения токенов.
1import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";Интерфейс ERC-165 (opens in a new tab) используется для указания, какие функции предоставляет контракт. Вы можете прочитать стандарт здесь (opens in a new tab).
1interface IL2StandardERC20 is IERC20, IERC165 {2 function l1Token() external returns (address);Эта функция предоставляет адрес токена L1, который переведен на этот контракт через Мост. Обратите внимание, что аналогичной функции в обратном направлении у нас нет. Нам нужно иметь возможность переводить через Мост любой токен L1, независимо от того, планировалась ли поддержка L2 при его реализации или нет.
12 function mint(address _to, uint256 _amount) external;34 function burn(address _from, uint256 _amount) external;56 event Mint(address indexed _account, uint256 _amount);7 event Burn(address indexed _account, uint256 _amount);8}Функции и события для выпуска (создания) и сжигания (уничтожения) токенов. Мост должен быть единственной сущностью, которая может выполнять эти функции, чтобы гарантировать правильное количество токенов (равное количеству токенов, заблокированных на L1).
L2StandardERC20
Это наша реализация интерфейса IL2StandardERC20 (opens in a new tab).
Если вам не нужна какая-то специальная логика, вам следует использовать эту реализацию.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";Контракт ERC-20 от OpenZeppelin (opens in a new tab). Optimism не верит в изобретение велосипеда, особенно когда велосипед хорошо проверен и должен быть достаточно надежным для хранения активов.
1import "./IL2StandardERC20.sol";23contract L2StandardERC20 is IL2StandardERC20, ERC20 {4 address public l1Token;5 address public l2Bridge;Это два дополнительных параметра конфигурации, которые нам требуются и которых обычно нет у ERC-20.
12 /**3 * @param _l2Bridge Адрес стандартного Моста L2.4 * @param _l1Token Адрес соответствующего токена L1.5 * @param _name Название ERC20.6 * @param _symbol Символ ERC20.7 */8 constructor(9 address _l2Bridge,10 address _l1Token,11 string memory _name,12 string memory _symbol13 ) ERC20(_name, _symbol) {14 l1Token = _l1Token;15 l2Bridge = _l2Bridge;16 }Показать всеСначала вызовите конструктор контракта, от которого мы наследуем (ERC20(_name, _symbol)), а затем установите наши собственные переменные.
12 modifier onlyL2Bridge() {3 require(msg.sender == l2Bridge, "Only L2 Bridge can mint and burn");4 _;5 }678 // slither-disable-next-line external-function9 function supportsInterface(bytes4 _interfaceId) public pure returns (bool) {10 bytes4 firstSupportedInterface = bytes4(keccak256("supportsInterface(bytes4)")); // ERC16511 bytes4 secondSupportedInterface = IL2StandardERC20.l1Token.selector ^12 IL2StandardERC20.mint.selector ^13 IL2StandardERC20.burn.selector;14 return _interfaceId == firstSupportedInterface || _interfaceId == secondSupportedInterface;15 }Показать всеИменно так работает ERC-165 (opens in a new tab). Каждый интерфейс — это набор поддерживаемых функций, и он идентифицируется как исключающее ИЛИ (opens in a new tab) селекторов функций ABI (opens in a new tab) этих функций.
Мост L2 использует ERC-165 в качестве проверки на вменяемость, чтобы убедиться, что контракт ERC-20, на который он отправляет активы, является IL2StandardERC20.
Примечание: Ничто не мешает мошенническому контракту предоставлять ложные ответы на supportsInterface, поэтому это механизм проверки на вменяемость, а не механизм безопасности.
1 // slither-disable-next-line external-function2 function mint(address _to, uint256 _amount) public virtual onlyL2Bridge {3 _mint(_to, _amount);45 emit Mint(_to, _amount);6 }78 // slither-disable-next-line external-function9 function burn(address _from, uint256 _amount) public virtual onlyL2Bridge {10 _burn(_from, _amount);1112 emit Burn(_from, _amount);13 }14}Показать всеТолько Мосту L2 разрешено выпускать и сжигать активы.
_mint и _burn на самом деле определены в контракте ERC-20 от OpenZeppelin.
Этот контракт просто не предоставляет их внешне, потому что условия для выпуска и сжигания токенов столь же разнообразны, как и количество способов использования ERC-20.
Код Моста L2
Это код, который запускает Мост на Optimism. Исходный код этого контракта находится здесь (opens in a new tab).
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34/* Interface Imports */5import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";6import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";7import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";Интерфейс IL2ERC20Bridge (opens in a new tab) очень похож на эквивалент L1, который мы видели выше. Есть два существенных различия:
- На L1 вы инициируете депозиты и завершаете вывод средств. Здесь вы инициируете выводы и завершаете депозиты.
- На L1 необходимо различать токены ETH и ERC-20. На L2 мы можем использовать одни и те же функции для обоих, поскольку внутренние балансы ETH в Optimism обрабатываются как токен ERC-20 с адресом 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens 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";56/* Contract Imports */7import { IL2StandardERC20 } from "../../standards/IL2StandardERC20.sol";89/**10 * @title L2StandardBridge11 * @dev Стандартный Мост L2 — это контракт, который работает совместно со стандартным Мостом L112 * для обеспечения переходов ETH и ERC20 между L1 и L2.13 * Этот контракт действует как эмитент для новых токенов, когда он получает информацию о депозитах в стандартный14 * Мост L1.15 * Этот контракт также действует как сжигатель токенов, предназначенных для вывода, информируя16 * Мост L1 о необходимости высвобождения средств L1.17 */18contract L2StandardBridge is IL2ERC20Bridge, CrossDomainEnabled {19 /********************************20 * Ссылки на внешние контракты *21 ********************************/2223 address public l1TokenBridge;Показать всеОтслеживаем адрес Моста L1. Обратите внимание, что в отличие от эквивалента L1, здесь нам необходима эта переменная. Адрес Моста L1 заранее не известен.
12 /***************3 * Конструктор *4 ***************/56 /**7 * @param _l2CrossDomainMessenger Междоменный мессенджер, используемый этим контрактом.8 * @param _l1TokenBridge Адрес Моста L1, развернутого в основной сети.9 */10 constructor(address _l2CrossDomainMessenger, address _l1TokenBridge)11 CrossDomainEnabled(_l2CrossDomainMessenger)12 {13 l1TokenBridge = _l1TokenBridge;14 }1516 /***************17 * Вывод средств *18 ***************/1920 /**21 * @inheritdoc IL2ERC20Bridge22 */23 function withdraw(24 address _l2Token,25 uint256 _amount,26 uint32 _l1Gas,27 bytes calldata _data28 ) external virtual {29 _initiateWithdrawal(_l2Token, msg.sender, msg.sender, _amount, _l1Gas, _data);30 }3132 /**33 * @inheritdoc IL2ERC20Bridge34 */35 function withdrawTo(36 address _l2Token,37 address _to,38 uint256 _amount,39 uint32 _l1Gas,40 bytes calldata _data41 ) external virtual {42 _initiateWithdrawal(_l2Token, msg.sender, _to, _amount, _l1Gas, _data);43 }Показать всеЭти две функции инициируют вывод средств. Обратите внимание, что нет необходимости указывать адрес токена L1. Ожидается, что токены L2 сообщат нам адрес эквивалента на L1.
12 /**3 * @dev Выполняет логику вывода средств, сжигая токен и информируя4 * шлюз токенов L1 о выводе.5 * @param _l2Token Адрес токена L2, на котором инициирован вывод.6 * @param _from Аккаунт для списания выводимых средств на L2.7 * @param _to Аккаунт для зачисления выводимых средств на L1.8 * @param _amount Сумма токена для вывода.9 * @param _l1Gas Не используется, но включен для потенциальной прямой совместимости.10 * @param _data Необязательные данные для пересылки на L1. Эти данные предоставляются11 * исключительно для удобства внешних контрактов. Кроме ограничения максимальной12 * длины, эти контракты не дают никаких гарантий относительно их содержимого.13 */14 function _initiateWithdrawal(15 address _l2Token,16 address _from,17 address _to,18 uint256 _amount,19 uint32 _l1Gas,20 bytes calldata _data21 ) internal {22 // Когда инициируется вывод, мы сжигаем средства выводившего для предотвращения последующего использования на L223 // slither-disable-next-line reentrancy-events24 IL2StandardERC20(_l2Token).burn(msg.sender, _amount);Показать всеОбратите внимание, что мы не полагаемся на параметр _from, а на msg.sender, который намного сложнее подделать (насколько я знаю, невозможно).
12 // Конструируем calldata для l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)3 // slither-disable-next-line reentrancy-events4 address l1Token = IL2StandardERC20(_l2Token).l1Token();5 bytes memory message;67 if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {На L1 необходимо различать ETH и ERC-20.
1 message = abi.encodeWithSelector(2 IL1StandardBridge.finalizeETHWithdrawal.selector,3 _from,4 _to,5 _amount,6 _data7 );8 } else {9 message = abi.encodeWithSelector(10 IL1ERC20Bridge.finalizeERC20Withdrawal.selector,11 l1Token,12 _l2Token,13 _from,14 _to,15 _amount,16 _data17 );18 }1920 // Отправить сообщение на Мост L121 // slither-disable-next-line reentrancy-events22 sendCrossDomainMessage(l1TokenBridge, _l1Gas, message);2324 // slither-disable-next-line reentrancy-events25 emit WithdrawalInitiated(l1Token, _l2Token, msg.sender, _to, _amount, _data);26 }2728 /************************************29 * Межсетевая функция: Депозит *30 ************************************/3132 /**33 * @inheritdoc IL2ERC20Bridge34 */35 function finalizeDeposit(36 address _l1Token,37 address _l2Token,38 address _from,39 address _to,40 uint256 _amount,41 bytes calldata _dataПоказать всеЭта функция вызывается L1StandardBridge.
1 ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {Убедитесь, что источник сообщения легитимен.
Это важно, потому что эта функция вызывает _mint и может быть использована для выдачи токенов, которые не покрыты токенами, принадлежащими Мосту на L1.
1 // Проверяем, что целевой токен соответствует требованиям, и2 // убеждаемся, что депонированный токен на L1 соответствует представлению токена на L23 if (4 // slither-disable-next-line reentrancy-events5 ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&6 _l1Token == IL2StandardERC20(_l2Token).l1Token()Проверки на вменяемость:
- Поддерживается правильный интерфейс
- Адрес L1 контракта L2 ERC-20 соответствует источнику токенов на L1
1 ) {2 // Когда депозит завершается, мы зачисляем на аккаунт на L2 такую же сумму3 // токенов.4 // slither-disable-next-line reentrancy-events5 IL2StandardERC20(_l2Token).mint(_to, _amount);6 // slither-disable-next-line reentrancy-events7 emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);Если проверки на вменяемость пройдены, завершите депозит:
- Выпустить токены
- Выпустить соответствующее событие
1 } else {2 // Либо токен L2, на который делается депозит, не согласен с правильным адресом3 // своего токена L1, либо не поддерживает правильный интерфейс.4 // Это должно происходить только в случае вредоносного токена L2 или если пользователь как-то5 // указал неправильный адрес токена L2 для депозита.6 // В любом случае мы останавливаем процесс здесь и конструируем сообщение о выводе,7 // чтобы пользователи в некоторых случаях могли вернуть свои средства.8 // Невозможно полностью предотвратить вредоносные контракты токенов, но это ограничивает9 // ошибки пользователя и смягчает некоторые формы вредоносного поведения контрактов.Показать всеЕсли пользователь допустил обнаруживаемую ошибку, используя неправильный адрес токена L2, мы хотим отменить депозит и вернуть токены на L1. Единственный способ сделать это с L2 — отправить сообщение, которому придется ждать периода оспаривания, но для пользователя это намного лучше, чем безвозвратная потеря токенов.
1 bytes memory message = abi.encodeWithSelector(2 IL1ERC20Bridge.finalizeERC20Withdrawal.selector,3 _l1Token,4 _l2Token,5 _to, // поменяли _to и _from местами, чтобы вернуть депозит отправителю6 _from,7 _amount,8 _data9 );1011 // Отправить сообщение на Мост L112 // slither-disable-next-line reentrancy-events13 sendCrossDomainMessage(l1TokenBridge, 0, message);14 // slither-disable-next-line reentrancy-events15 emit DepositFailed(_l1Token, _l2Token, _from, _to, _amount, _data);16 }17 }18}Показать всеЗаключение
Стандартный Мост является наиболее гибким механизмом для перевода активов. Однако из-за своей универсальности он не всегда является самым простым в использовании механизмом. Особенно для вывода средств, большинство пользователей предпочитают использовать сторонние Мосты (opens in a new tab), которые не ждут периода оспаривания и не требуют доказательства Меркла для завершения вывода.
Эти Мосты обычно работают за счет наличия активов на L1, которые они предоставляют немедленно за небольшую плату (часто меньшую, чем стоимость газа при стандартном выводе через Мост). Когда Мост (или люди, управляющие им) предвидит нехватку активов L1, он переводит достаточное количество активов с L2. Поскольку это очень крупные выводы, стоимость вывода амортизируется на большую сумму и составляет гораздо меньший процент.
Надеемся, эта статья помогла вам лучше понять, как работает уровень 2 и как писать понятный и безопасный код на Solidity.
Больше моих работ смотрите здесь (opens in a new tab).
Последнее обновление страницы: 22 октября 2025 г.