Перейти до основного вмісту

Покрокове керівництво для стандартного мостового контракту Optimism

мова програмування
міст
рівень 2
Середнячок
Ori Pomerantz
30 березня 2022 р.
29 читається за хвилину

Optimism (opens in a new tab) — це оптимістичний ролап. Оптимістичні ролапи можуть обробляти транзакції за набагато нижчою ціною, ніж мережа Ethereum Mainnet (також відома як рівень 1 або L1), оскільки транзакції обробляються лише кількома вузлами, а не кожним вузлом у мережі. Водночас усі дані записуються на L1, тому все можна перевірити та реконструювати з усіма гарантіями цілісності та доступності Mainnet.

Щоб використовувати активи L1 на Optimism (або будь-якому іншому L2), їх потрібно переказати через міст. Один зі способів досягти цього — заблокувати активи (найпоширенішими є ETH і токени ERC-20) на L1 та отримати еквівалентні активи для використання на L2. Зрештою, той, у кого вони опиняться, може захотіти повернути їх на L1 через міст. При цьому активи спалюються на L2, а потім вивільняються користувачеві на L1.

Саме так працює стандартний міст Optimism (opens in a new tab). У цій статті ми розглянемо вихідний код цього мосту, щоб побачити, як він працює, і вивчимо його як приклад добре написаного коду на Solidity.

Потоки керування

Міст має два основні потоки:

  • Внесення (з L1 до L2)
  • Виведення (з L2 до L1)

Процес внесення

Рівень 1

  1. При внесенні ERC-20, вкладник надає мосту дозвіл (allowance) на витрату суми, що вноситься
  2. Вкладник викликає міст L1 (depositERC20, depositERC20To, depositETH, або depositETHTo)
  3. Міст L1 отримує у володіння актив, що переказується через міст
    • ETH: актив передається вкладником у межах виклику
    • ERC-20: актив переказується мостом самому собі з використанням дозволу (allowance), наданого вкладником
  4. Міст L1 використовує механізм міждоменних повідомлень для виклику finalizeDeposit на мосту L2

Рівень 2

  1. Міст L2 перевіряє, чи є виклик finalizeDeposit легітимним:
    • Надійшло з контракту міждоменних повідомлень
    • Початково надійшло з мосту на L1
  2. Міст L2 перевіряє, чи є контракт токена ERC-20 на L2 правильним:
    • Контракт L2 повідомляє, що його аналог L1 збігається з тим, звідки надійшли токени на L1
    • Контракт L2 повідомляє, що підтримує правильний інтерфейс (з використанням ERC-165 (opens in a new tab)).
  3. Якщо контракт L2 правильний, викликати його, щоб викарбувати відповідну кількість токенів на відповідну адресу. Якщо ні, розпочати процес виведення, щоб дозволити користувачеві отримати токени на L1.

Процес виведення

Рівень 2

  1. Той, хто виводить кошти, викликає міст L2 (withdraw або withdrawTo)
  2. Міст L2 спалює відповідну кількість токенів, що належать msg.sender
  3. Міст L2 використовує механізм міждоменних повідомлень для виклику finalizeETHWithdrawal або finalizeERC20Withdrawal на мосту L1

Рівень 1

  1. Міст L1 перевіряє, чи є виклик finalizeETHWithdrawal або finalizeERC20Withdrawal легітимним:
    • Надійшло з механізму міждоменних повідомлень
    • Початково надійшло з мосту на L2
  2. Міст L1 переказує відповідний актив (ETH або ERC-20) на відповідну адресу

Код рівня 1

Це код, який виконується на L1, у мережі Ethereum Mainnet.

IL1ERC20Bridge

Цей інтерфейс визначено тут (opens in a new tab). Він містить функції та визначення, необхідні для переказу токенів ERC-20 через міст.

// SPDX-License-Identifier: MIT

Більша частина коду Optimism випущена під ліцензією MIT (opens in a new tab).

pragma solidity >0.5.0 <0.9.0;

На момент написання статті остання версія Solidity — 0.8.12. Поки не випущено версію 0.9.0, ми не знаємо, чи сумісний цей код із нею.

У термінології мосту Optimism deposit означає переказ з L1 на L2, а withdrawal — переказ з L2 на L1.

        address indexed _l1Token,
        address indexed _l2Token,

У більшості випадків адреса ERC-20 на L1 не збігається з адресою еквівалентного ERC-20 на L2. Список адрес токенів можна переглянути тут (opens in a new tab). Адреса з chainId 1 знаходиться на L1 (Mainnet), а адреса з chainId 10 — на L2 (Optimism). Інші два значення chainId призначені для тестової мережі Kovan (42) та оптимістичної тестової мережі Kovan (69).

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

Можна додавати примітки до переказів, і в такому разі вони додаються до подій, що їх фіксують.

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

Той самий контракт мосту обробляє перекази в обох напрямках. У випадку мосту L1 це означає ініціалізацію внесення та фіналізацію виведення коштів.

Ця функція насправді не потрібна, оскільки на L2 це попередньо розгорнутий контракт, тому вона завжди знаходиться за адресою 0x4200000000000000000000000000000000000010. Вона тут для симетрії з мостом L2, оскільки адресу мосту L1 дізнатися не так просто.

Параметр _l2Gas — це кількість газу L2, яку транзакція може витратити. До певного (високого) ліміту це безкоштовно (opens in a new tab), тому, якщо контракт ERC-20 не робить нічого дивного під час карбування, це не повинно бути проблемою. Ця функція обробляє поширений сценарій, коли користувач переказує активи через міст на ту саму адресу в іншому блокчейні.

Ця функція майже ідентична depositERC20, але вона дозволяє надсилати ERC-20 на іншу адресу.

Виведення коштів (та інші повідомлення з L2 на L1) в Optimism — це двоетапний процес:

  1. Ініціююча транзакція на L2.
  2. Фіналізуюча або підтверджуюча транзакція на L1. Ця транзакція має відбутися після завершення періоду оскарження помилок (opens in a new tab) для транзакції L2.

IL1StandardBridge

Цей інтерфейс визначено тут (opens in a new tab). Цей файл містить визначення подій та функцій для ETH. Ці визначення дуже схожі на визначені вище в IL1ERC20Bridge для ERC-20.

Інтерфейс мосту розділено на два файли, оскільки деякі токени ERC-20 вимагають спеціальної обробки і не можуть бути оброблені стандартним мостом. Таким чином, спеціальний міст, який обробляє такий токен, може реалізувати IL1ERC20Bridge і не мусить також переказувати ETH через міст.

Ця подія майже ідентична версії ERC-20 (ERC20DepositInitiated), за винятком відсутності адрес токенів L1 та L2. Те саме стосується інших подій і функцій.

CrossDomainEnabled

Цей контракт (opens in a new tab) успадковується обома мостами (L1 і L2) для надсилання повідомлень на інший рівень.

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

/* Імпорт інтерфейсів */
import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";

Цей інтерфейс (opens in a new tab) повідомляє контракту, як надсилати повідомлення на інший рівень за допомогою міждоменного месенджера. Цей міждоменний месенджер — це ціла окрема система, яка заслуговує на власну статтю, яку я сподіваюся написати в майбутньому.

Єдиний параметр, який має знати контракт, — це адреса міждоменного месенджера на цьому рівні. Цей параметр встановлюється один раз у конструкторі й ніколи не змінюється.

Міждоменний обмін повідомленнями доступний будь-якому контракту в блокчейні, де він виконується (або в Ethereum Mainnet, або в Optimism). Але нам потрібно, щоб міст на кожній стороні довіряв лише тим повідомленням, які надходять від мосту з іншого боку.

        require(
            msg.sender == address(getCrossDomainMessenger()),
            "OVM_XCHAIN: контракт месенджера не автентифікований"
        );

Можна довіряти лише повідомленням із відповідного міждоменного месенджера (messenger, як ви побачите нижче).


        require(
            getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,
            "OVM_XCHAIN: неправильний відправник міждоменного повідомлення"
        );

Спосіб, у який міждоменний месенджер надає адресу, що надіслала повідомлення з іншого рівня, — це функція .xDomainMessageSender() (opens in a new tab). Доки вона викликається в транзакції, ініційованій повідомленням, вона може надавати цю інформацію.

Нам потрібно переконатися, що отримане повідомлення надійшло з іншого мосту.

Ця функція повертає міждоменний месенджер. Ми використовуємо функцію, а не змінну messenger, щоб дозволити контрактам, які успадковують від цього, використовувати алгоритм для визначення, який міждоменний месенджер використовувати.

Нарешті, функція, яка надсилає повідомлення на інший рівень.

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

Slither (opens in a new tab) — це статичний аналізатор, який Optimism запускає для кожного контракту, щоб знайти вразливості та інші потенційні проблеми. У цьому випадку наступний рядок викликає дві вразливості:

  1. Події повторного входу (opens in a new tab)
  2. Безпечний повторний вхід (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

У цьому випадку ми не турбуємося про повторний вхід, оскільки знаємо, що getCrossDomainMessenger() повертає надійну адресу, навіть якщо Slither не може цього знати.

Контракт мосту L1

Вихідний код цього контракту тут (opens in a new tab).

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

Інтерфейси можуть бути частиною інших контрактів, тому вони мають підтримувати широкий діапазон версій Solidity. Але сам міст — це наш контракт, і ми можемо бути суворими щодо версії Solidity, яку він використовує.

/* Імпорт інтерфейсів */
import { IL1StandardBridge } from "./IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";

IL1ERC20Bridge та IL1StandardBridge пояснено вище.

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

Цей інтерфейс (opens in a new tab) дозволяє нам створювати повідомлення для керування стандартним мостом на L2.

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

Цей інтерфейс (opens in a new tab) дозволяє нам керувати контрактами ERC-20. Детальніше про це можна прочитати тут.

/* Імпорт бібліотек */
import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";

Як пояснювалося вище, цей контракт використовується для міжрівневого обміну повідомленнями.

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

Lib_PredeployAddresses (opens in a new tab) містить адреси контрактів L2, які завжди мають однакову адресу. Це включає стандартний міст на L2.

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

Утиліти Address від OpenZeppelin (opens in a new tab). Він використовується для розрізнення адрес контрактів і тих, що належать зовнішнім обліковим записам (EOA).

Зауважте, що це не ідеальне рішення, оскільки немає способу розрізнити прямі виклики та виклики, зроблені з конструктора контракту, але принаймні це дозволяє нам виявляти та запобігати деяким поширеним помилкам користувачів.

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

Стандарт ERC-20 (opens in a new tab) підтримує два способи повідомлення контрактом про помилку:

  1. Revert
  2. Повернення false

Обробка обох випадків ускладнила б наш код, тому замість цього ми використовуємо SafeERC20 від OpenZeppelin (opens in a new tab), який гарантує, що всі помилки призводять до revert (opens in a new tab).

Цей рядок визначає використання обгортки SafeERC20 щоразу, коли ми використовуємо інтерфейс IERC20.


    /********************************
     * Посилання на зовнішні контракти *
     ********************************/

    address public l2TokenBridge;

Адреса L2StandardBridge.


    // Відображає токен L1 на токен L2 до балансу внесеного токена L1
    mapping(address => mapping(address => uint256)) public deposits;

Подвійне відображення (mapping) (opens in a new tab) — це спосіб визначення двовимірного розрідженого масиву (opens in a new tab). Значення в цій структурі даних ідентифікуються як deposit[адреса токена L1][адреса токена L2]. Значення за замовчуванням — нуль. До сховища записуються лише ті комірки, яким встановлено інше значення.


    /***************
     * Конструктор *
     ***************/

    // Цей контракт працює за проксі, тому параметри конструктора не будуть використовуватися.
    constructor() CrossDomainEnabled(address(0)) {}

Щоб мати можливість оновити цей контракт без необхідності копіювати всі змінні в сховищі. Для цього ми використовуємо Proxy (opens in a new tab), контракт, який використовує delegatecall (opens in a new tab) для переадресації викликів до окремого контракту, чия адреса зберігається в контракті проксі (під час оновлення ви повідомляєте проксі змінити цю адресу). Коли ви використовуєте delegatecall, сховище залишається сховищем викликаючого контракту, тому значення всіх змінних стану контракту не змінюються.

Одним з наслідків цього шаблону є те, що сховище викликаного контракту delegatecall не використовується, і тому значення конструктора, передані йому, не мають значення. Саме тому ми можемо надати безглузде значення конструктору CrossDomainEnabled. Це також причина, чому наведена нижче ініціалізація відокремлена від конструктора.

Цей тест Slither (opens in a new tab) виявляє функції, які не викликаються з коду контракту і тому можуть бути оголошені як external замість public. Вартість газу для функцій external може бути нижчою, оскільки їм можна передавати параметри в calldata. Функції, оголошені як public, мають бути доступні зсередини контракту. Контракти не можуть змінювати власні calldata, тому параметри мають бути в пам’яті. Коли така функція викликається ззовні, необхідно скопіювати calldata в пам’ять, що коштує газу. У цьому випадку функція викликається лише один раз, тому неефективність для нас не має значення.

    function initialize(address _l1messenger, address _l2TokenBridge) public {
        require(messenger == address(0), "Контракт уже ініціалізовано.");

Функція initialize має викликатися лише один раз. Якщо адреса міждоменного месенджера L1 або мосту токенів L2 змінюється, ми створюємо новий проксі та новий міст, який його викликає. Це навряд чи станеться, за винятком випадків оновлення всієї системи, що буває дуже рідко.

Зауважте, що ця функція не має механізму, який обмежує, хто може її викликати. Це означає, що теоретично зловмисник може почекати, доки ми розгорнемо проксі та першу версію мосту, а потім випередити (front-run) (opens in a new tab), щоб дістатися до функції initialize раніше, ніж це зробить легітимний користувач. Але є два способи запобігти цьому:

  1. Якщо контракти розгортаються не безпосередньо EOA, а у транзакції, яка змушує інший контракт створювати їх (opens in a new tab), весь процес може бути атомарним і завершитися до виконання будь-якої іншої транзакції.
  2. Якщо легітимний виклик initialize не вдається, завжди можна проігнорувати щойно створений проксі та міст і створити нові.
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

Це два параметри, які міст повинен знати.

Ось чому нам знадобилися утиліти Address від OpenZeppelin.

Ця функція існує для тестування. Зверніть увагу, що вона не з’являється у визначеннях інтерфейсу — вона не для звичайного використання.

Ці дві функції є обгортками навколо _initiateETHDeposit, функції, яка обробляє фактичне внесення ETH.

Спосіб роботи міждоменних повідомлень полягає в тому, що контракт призначення викликається з повідомленням як його calldata. Контракти Solidity завжди інтерпретують свої calldata відповідно до специфікацій ABI (opens in a new tab). Функція Solidity abi.encodeWithSelector (opens in a new tab) створює ці calldata.

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

Повідомлення тут — це виклик функції finalizeDeposit (opens in a new tab) з такими параметрами:

ПараметрЗначенняЗначення
_l1Tokenaddress(0)Спеціальне значення для ETH (який не є токеном ERC-20) на L1
_l2TokenLib_PredeployAddresses.OVM_ETHКонтракт L2, який керує ETH на Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (цей контракт призначений лише для внутрішнього використання Optimism)
_from_fromАдреса на L1, яка надсилає ETH
_to_toАдреса на L2, яка отримує ETH
сумаmsg.valueКількість надісланих wei (які вже надіслано на міст)
_data_dataДодаткові дані, які додаються до депозиту
        // Надіслати calldata на L2
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

Надішліть повідомлення через міждоменний месенджер.

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

Надішліть подію, щоб повідомити будь-який децентралізований застосунок, який відстежує цей переказ.

Ці дві функції є обгортками навколо _initiateERC20Deposit, функції, яка обробляє фактичне внесення ERC-20.

Ця функція подібна до _initiateETHDeposit вище, з кількома важливими відмінностями. Перша відмінність полягає в тому, що ця функція отримує адреси токенів і суму для переказу як параметри. У випадку з ETH виклик мосту вже включає переказ активу на рахунок мосту (msg.value).

        // Коли депозит ініціюється на L1, міст L1 переказує кошти на свій рахунок для майбутніх
        // виведень. safeTransferFrom також перевіряє, чи є у контракту код, тому це не спрацює, якщо
        // _from є EOA або address(0).
        // slither-disable-next-line reentrancy-events, reentrancy-benign
        IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);

Перекази токенів ERC-20 відбуваються за іншим процесом, ніж ETH:

  1. Користувач (_from) надає дозвіл мосту на переказ відповідних токенів.
  2. Користувач викликає міст з адресою контракту токена, сумою тощо.
  3. Міст переказує токени (собі) у рамках процесу внесення.

Перший крок може відбуватися в окремій транзакції від останніх двох. Однак випередження (front-running) не є проблемою, оскільки дві функції, які викликають _initiateERC20Deposit (depositERC20 і depositERC20To), викликають цю функцію лише з msg.sender як параметром _from.

Додайте внесену кількість токенів до структури даних deposits. На L2 може бути кілька адрес, які відповідають одному токену L1 ERC-20, тому для відстеження депозитів недостатньо використовувати баланс токена L1 ERC-20 на мосту.

Міст L2 надсилає повідомлення до міждоменного месенджера L2, який змушує міждоменний месенджер L1 викликати цю функцію (звичайно, коли транзакція, яка завершує повідомлення (opens in a new tab), надсилається на L1).

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

Переконайтеся, що це легітимне повідомлення, яке надходить від міждоменного месенджера та походить від мосту токенів L2. Ця функція використовується для виведення ETH з мосту, тому ми повинні переконатися, що її викликає лише авторизований викликаючий.

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

Спосіб переказу ETH полягає у виклику одержувача із сумою wei в msg.value.

        require(success, "TransferHelper::safeTransferETH: не вдалося переказати ETH");

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

Випромінюйте подію про виведення коштів.

Ця функція подібна до finalizeETHWithdrawal вище, з необхідними змінами для токенів ERC-20.

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

Оновіть структуру даних deposits.

Існувала рання реалізація мосту. Коли ми переходили від тієї реалізації до цієї, нам довелося перемістити всі активи. Токени 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), який має функції та події, необхідні стандартному мосту.

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

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

Стандартний інтерфейс ERC-20 (opens in a new tab) не включає функції mint та burn. Ці методи не вимагаються стандартом ERC-20 (opens in a new tab), який не визначає механізми створення та знищення токенів.

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

Інтерфейс ERC-165 (opens in a new tab) використовується для визначення того, які функції надає контракт. Стандарт можна прочитати тут (opens in a new tab).

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

Ця функція надає адресу токена L1, який переказується на цей контракт через міст. Зауважте, що у нас немає подібної функції у зворотному напрямку. Нам потрібно мати можливість переказувати будь-який токен L1 через міст, незалежно від того, чи планувалася підтримка L2 під час його впровадження.


    function mint(address _to, uint256 _amount) external;

    function burn(address _from, uint256 _amount) external;

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

Функції та події для карбування (створення) і спалювання (знищення) токенів. Міст має бути єдиною сутністю, яка може запускати ці функції, щоб переконатися, що кількість токенів правильна (дорівнює кількості токенів, заблокованих на L1).

L2StandardERC20

Це наша реалізація інтерфейсу IL2StandardERC20 (opens in a new tab). Якщо вам не потрібна якась спеціальна логіка, ви повинні використовувати цю.

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

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

Контракт ERC-20 від OpenZeppelin (opens in a new tab). Optimism не вірить у винахід колеса, особливо коли колесо добре перевірено та має бути достатньо надійним, щоб зберігати активи.

import "./IL2StandardERC20.sol";

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

Це два додаткові параметри конфігурації, які потрібні нам, а ERC-20 зазвичай не вимагає.

Спочатку викликаємо конструктор для контракту, від якого ми успадковуємо (ERC20(_name, _symbol)), а потім встановлюємо наші власні змінні.

Саме так працює 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, тому це механізм перевірки на адекватність, а не механізм безпеки.

Лише на мосту L2 дозволено карбувати та спалювати активи.

_mint та _burn насправді визначені в контракті ERC-20 від OpenZeppelin. Цей контракт просто не робить їх доступними ззовні, оскільки умови для карбування та спалювання токенів настільки ж різноманітні, як і кількість способів використання ERC-20.

Код мосту L2

Це код, який запускає міст у Optimism. Вихідний код цього контракту тут (opens in a new tab).

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

/* Імпорт інтерфейсів */
import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";
import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";

Інтерфейс IL2ERC20Bridge (opens in a new tab) дуже схожий на еквівалент L1, який ми бачили вище. Є дві істотні відмінності:

  1. На L1 ви ініціюєте внесення та фіналізуєте виведення коштів. Тут ви ініціюєте виведення та фіналізуєте внесення коштів.
  2. На L1 необхідно розрізняти токени ETH і ERC-20. На L2 ми можемо використовувати однакові функції для обох, оскільки внутрішні баланси ETH на Optimism обробляються як токен ERC-20 з адресою 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab).

Відстежуйте адресу мосту L1. Зверніть увагу, що на відміну від еквівалента L1, тут нам потрібна ця змінна. Адреса мосту L1 заздалегідь невідома.

Ці дві функції ініціюють виведення коштів. Зверніть увагу, що немає необхідності вказувати адресу токена L1. Очікується, що токени L2 повідомлять нам адресу еквівалента L1.

Зауважте, що ми покладаємося не на параметр _from, а на msg.sender, який набагато важче підробити (наскільки мені відомо, неможливо).


        // Сконструювати calldata для l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)
        // slither-disable-next-line reentrancy-events
        address l1Token = IL2StandardERC20(_l2Token).l1Token();
        bytes memory message;

        if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {

На L1 необхідно розрізняти ETH і ERC-20.

Ця функція викликається L1StandardBridge.

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

Переконайтеся, що джерело повідомлення є легітимним. Це важливо, оскільки ця функція викликає _mint і може бути використана для надання токенів, які не забезпечені токенами, якими володіє міст на L1.

        // Перевірити, чи є цільовий токен сумісним, і
        // перевірити, чи відповідає внесений токен на L1 представленню токена на L2 тут
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()

Перевірки на адекватність:

  1. Підтримується правильний інтерфейс
  2. Адреса L1 контракту L2 ERC-20 відповідає джерелу L1 токенів
        ) {
            // Коли депозит фіналізується, ми зараховуємо на рахунок на L2 таку ж кількість
            // токенів.
            // slither-disable-next-line reentrancy-events
            IL2StandardERC20(_l2Token).mint(_to, _amount);
            // slither-disable-next-line reentrancy-events
            emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);

Якщо перевірки на адекватність проходять, фіналізуйте депозит:

  1. Викарбуйте токени
  2. Випромінюйте відповідну подію

Якщо користувач зробив помилку, яку можна виявити, використавши неправильну адресу токена L2, ми хочемо скасувати депозит і повернути токени на L1. Єдиний спосіб зробити це з L2 — надіслати повідомлення, яке має чекати періоду оскарження помилки, але це набагато краще для користувача, ніж остаточна втрата токенів.

Висновок

Стандартний міст є найбільш гнучким механізмом для переказу активів. Однак, оскільки він настільки загальний, це не завжди найпростіший механізм для використання. Особливо для виведення коштів, більшість користувачів вважають за краще використовувати сторонні мости (opens in a new tab), які не чекають періоду оскарження і не вимагають доказу Merkle для завершення виведення.

Ці мости зазвичай працюють, маючи активи на L1, які вони надають негайно за невелику плату (часто меншу, ніж вартість газу для стандартного виведення через міст). Коли міст (або люди, які ним керують) очікує брак активів L1, він переказує достатню кількість активів з L2. Оскільки це дуже великі виведення, вартість виведення амортизується на велику суму та становить набагато менший відсоток.

Сподіваюся, ця стаття допомогла вам більше зрозуміти, як працює рівень 2 і як написати чіткий і безпечний код Solidity.

Більше моїх робіт дивіться тут (opens in a new tab).

Останнє оновлення сторінки: 3 квітня 2026 р.

Цей посібник був корисним?