Przejdź do głównej treści

Przegląd standardowego kontraktu mostu Optimism

solidity
most
warstwa 2
Średnio zaawansowany
Ori Pomerantz
30 marca 2022
30 minuta czytania

Optimism (opens in a new tab) to rollup optymistyczny. Rollupy optymistyczne mogą przetwarzać transakcje po znacznie niższej cenie niż sieć główna Ethereum (znana również jako warstwa 1 lub L1), ponieważ transakcje są przetwarzane tylko przez kilka węzłów, a nie przez każdy węzeł w sieci. Jednocześnie wszystkie dane są zapisywane w L1, dzięki czemu wszystko można udowodnić i zrekonstruować z zachowaniem wszystkich gwarancji integralności i dostępności sieci głównej.

Aby używać aktywów L1 na Optimism (lub dowolnej innej L2), aktywa te muszą być przeniesione przez most. Jednym ze sposobów na osiągnięcie tego jest zablokowanie przez użytkowników aktywów (najczęściej są to tokeny ETH i ERC-20) na L1 i otrzymanie równoważnych aktywów do wykorzystania na L2. Ostatecznie ten, kto je zdobędzie, może chcieć przenieść je z powrotem na L1 za pomocą mostu. W takim przypadku aktywa są spalane na L2, a następnie uwalniane z powrotem do użytkownika na L1.

W ten sposób działa standardowy most Optimism (opens in a new tab). W tym artykule przeanalizujemy kod źródłowy tego mostu, aby zobaczyć, jak on działa i zbadać go jako przykład dobrze napisanego kodu Solidity.

Przepływy sterowania

Most ma dwa główne przepływy:

  • Depozyt (z L1 do L2)
  • Wypłata (z L2 do L1)

Przepływ depozytu

Warstwa 1

  1. W przypadku deponowania ERC-20 deponent udziela mostowi zgody na wydanie zdeponowanej kwoty
  2. Deponent wywołuje most L1 (depositERC20, depositERC20To, depositETH lub depositETHTo)
  3. Most L1 przejmuje w posiadanie przeniesione aktywa
    • ETH: Aktywa są przekazywane przez deponenta w ramach wywołania
    • ERC-20: Aktywa są przenoszone przez most na siebie, korzystając ze zgody udzielonej przez deponenta
  4. Most L1 używa mechanizmu wiadomości między domenami do wywołania funkcji finalizeDeposit na moście L2

Warstwa 2

  1. Most L2 weryfikuje, czy wywołanie funkcji finalizeDeposit jest prawidłowe:
    • Pochodzi z kontraktu wiadomości między domenami
    • Pochodził pierwotnie z mostu na L1
  2. Most L2 sprawdza, czy kontrakt tokenu ERC-20 na L2 jest prawidłowy:
    • Kontrakt L2 informuje, że jego odpowiednik L1 jest taki sam jak ten, z którego pochodzą tokeny na L1
    • Kontrakt L2 informuje, że obsługuje prawidłowy interfejs (używając ERC-165 (opens in a new tab)).
  3. Jeśli kontrakt L2 jest prawidłowy, wywołaj go, aby wyemitować odpowiednią liczbę tokenów na odpowiedni adres. Jeśli nie, rozpocznij proces wypłaty, aby umożliwić użytkownikowi odebranie tokenów na L1.

Przepływ wypłat

Warstwa 2

  1. Wypłacający wywołuje most L2 (withdraw lub withdrawTo)
  2. Most L2 spala odpowiednią liczbę tokenów należących do msg.sender
  3. Most L2 używa mechanizmu wiadomości między domenami do wywołania funkcji finalizeETHWithdrawal lub finalizeERC20Withdrawal na moście L1

Warstwa 1

  1. Most L1 weryfikuje, czy wywołanie funkcji finalizeETHWithdrawal lub finalizeERC20Withdrawal jest prawidłowe:
    • Pochodzi z mechanizmu wiadomości między domenami
    • Pochodził pierwotnie z mostu na L2
  2. Most L1 przekazuje odpowiednie aktywa (ETH lub ERC-20) na odpowiedni adres

Kod warstwy 1

To jest kod, który działa na L1, w sieci głównej Ethereum.

IL1ERC20Bridge

Ten interfejs jest zdefiniowany tutaj (opens in a new tab). Zawiera on funkcje i definicje wymagane do przenoszenia tokenów ERC-20 za pomocą mostu.

// SPDX-License-Identifier: MIT

Większość kodu Optimism jest udostępniana na licencji MIT (opens in a new tab).

pragma solidity >0.5.0 <0.9.0;

W chwili pisania tego tekstu najnowsza wersja Solidity to 0.8.12. Dopóki wersja 0.9.0 nie zostanie wydana, nie wiemy, czy ten kod jest z nią kompatybilny, czy nie.

W terminologii mostów Optimism depozyt oznacza transfer z L1 do L2, a wypłata oznacza transfer z L2 do L1.

        address indexed _l1Token,
        address indexed _l2Token,

W większości przypadków adres ERC-20 na L1 nie jest taki sam jak adres równoważnego ERC-20 na L2. Listę adresów tokenów można zobaczyć tutaj (opens in a new tab). Adres z chainId 1 znajduje się na L1 (sieć główna), a adres z chainId 10 na L2 (Optimism). Pozostałe dwie wartości chainId dotyczą sieci testowej Kovan (42) i sieci testowej Optimistic Kovan (69).

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

Możliwe jest dodawanie notatek do transferów, w którym to przypadku są one dodawane do zdarzeń, które je raportują.

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

Ten sam kontrakt mostu obsługuje transfery w obu kierunkach. W przypadku mostu L1 oznacza to inicjalizację depozytów i finalizację wypłat.

Ta funkcja nie jest tak naprawdę potrzebna, ponieważ na L2 jest to kontrakt wdrożony wstępnie, więc zawsze znajduje się pod adresem 0x4200000000000000000000000000000000000010. Jest tu dla symetrii z mostem L2, ponieważ adres mostu L1 nie jest trywialny do poznania.

Parametr _l2Gas to ilość gazu L2, którą transakcja może zużyć. Do pewnego (wysokiego) limitu jest to darmowe (opens in a new tab), więc o ile kontrakt ERC-20 nie robi czegoś naprawdę dziwnego podczas emisji, nie powinno to stanowić problemu. Ta funkcja obsługuje typowy scenariusz, w którym użytkownik przenosi aktywa za pomocą mostu na ten sam adres na innym blockchainie.

Ta funkcja jest niemal identyczna jak depositERC20, ale pozwala na wysłanie ERC-20 na inny adres.

Wypłaty (i inne wiadomości z L2 do L1) w Optimism to proces dwuetapowy:

  1. Transakcja inicjująca na L2.
  2. Transakcja finalizująca lub odbierająca na L1. Ta transakcja musi nastąpić po zakończeniu okresu kwestionowania błędu (opens in a new tab) dla transakcji L2.

IL1StandardBridge

Ten interfejs jest zdefiniowany tutaj (opens in a new tab). Ten plik zawiera definicje zdarzeń i funkcji dla ETH. Definicje te są bardzo podobne do tych zdefiniowanych powyżej w IL1ERC20Bridge dla ERC-20.

Interfejs mostu jest podzielony na dwa pliki, ponieważ niektóre tokeny ERC-20 wymagają niestandardowego przetwarzania i nie mogą być obsługiwane przez standardowy most. W ten sposób niestandardowy most, który obsługuje taki token, może zaimplementować IL1ERC20Bridge i nie musi również przenosić ETH.

To zdarzenie jest prawie identyczne z wersją ERC-20 (ERC20DepositInitiated), z wyjątkiem braku adresów tokenów L1 i L2. To samo dotyczy innych zdarzeń i funkcji.

CrossDomainEnabled

Ten kontrakt (opens in a new tab) jest dziedziczony przez oba mosty (L1 i L2) do wysyłania wiadomości do drugiej warstwy.

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

/* Importy interfejsów */
import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";

Ten interfejs (opens in a new tab) informuje kontrakt, jak wysyłać wiadomości do drugiej warstwy, używając komunikatora między domenami. Ten komunikator między domenami to zupełnie inny system i zasługuje na osobny artykuł, który mam nadzieję napisać w przyszłości.

Jedyny parametr, który kontrakt musi znać, to adres komunikatora między domenami w tej warstwie. Ten parametr jest ustawiany raz, w konstruktorze i nigdy się nie zmienia.

Komunikacja między domenami jest dostępna dla każdego kontraktu na blockchainie, na którym jest uruchomiona (zarówno w sieci głównej Ethereum, jak i Optimism). Ale potrzebujemy, aby most po każdej stronie ufał tylko określonym wiadomościom, jeśli pochodzą one z mostu po drugiej stronie.

        require(
            msg.sender == address(getCrossDomainMessenger()),
            "OVM_XCHAIN: kontrakt komunikatora nieuwierzytelniony"
        );

Tylko wiadomości z odpowiedniego komunikatora między domenami (messenger, jak widać poniżej) mogą być zaufane.


        require(
            getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,
            "OVM_XCHAIN: zły nadawca wiadomości między domenami"
        );

Sposób, w jaki komunikator między domenami dostarcza adres, który wysłał wiadomość z drugiej warstwy, to funkcja .xDomainMessageSender() (opens in a new tab). Dopóki jest wywoływana w transakcji zainicjowanej przez wiadomość, może dostarczyć tych informacji.

Musimy upewnić się, że otrzymana wiadomość pochodzi z drugiego mostu.

Ta funkcja zwraca komunikator między domenami. Używamy funkcji, a nie zmiennej messenger, aby umożliwić kontraktom dziedziczącym po tym kontrakcie użycie algorytmu do określenia, którego komunikatora między domenami użyć.

Na koniec funkcja, która wysyła wiadomość do drugiej warstwy.

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

Slither (opens in a new tab) to statyczny analizator, który Optimism uruchamia na każdym kontrakcie w poszukiwaniu luk w zabezpieczeniach i innych potencjalnych problemów. W tym przypadku poniższa linia uruchamia dwie luki w zabezpieczeniach:

  1. Zdarzenia ponownego wejścia (opens in a new tab)
  2. Łagodne ponowne wejście (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

W tym przypadku nie martwimy się o ponowne wejście, ponieważ wiemy, że getCrossDomainMessenger() zwraca zaufany adres, nawet jeśli Slither nie ma sposobu, aby to wiedzieć.

Kontrakt mostu L1

Kod źródłowy tego kontraktu znajduje się tutaj (opens in a new tab).

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

Interfejsy mogą być częścią innych kontraktów, więc muszą obsługiwać szeroki zakres wersji Solidity. Ale sam most jest naszym kontraktem i możemy być rygorystyczni co do wersji Solidity, której używa.

/* Importy interfejsów */
import { IL1StandardBridge } from "./IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";

IL1ERC20Bridge i IL1StandardBridge zostały wyjaśnione powyżej.

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

Ten interfejs (opens in a new tab) pozwala nam tworzyć wiadomości do kontrolowania standardowego mostu na L2.

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

Ten interfejs (opens in a new tab) pozwala nam kontrolować kontrakty ERC-20. Więcej na ten temat możesz przeczytać tutaj.

/* Importy bibliotek */
import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";

Jak wyjaśniono powyżej, ten kontrakt jest używany do komunikacji międzywarstwowej.

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

Lib_PredeployAddresses (opens in a new tab) zawiera adresy kontraktów L2, które zawsze mają ten sam adres. Obejmuje to standardowy most na L2.

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

Narzędzia adresowe OpenZeppelin (opens in a new tab). Jest używany do rozróżniania adresów kontraktów od adresów należących do kont zewnętrznych (EOA).

Należy pamiętać, że nie jest to idealne rozwiązanie, ponieważ nie ma sposobu, aby odróżnić wywołania bezpośrednie od wywołań z konstruktora kontraktu, ale przynajmniej pozwala nam to zidentyfikować i zapobiec niektórym typowym błędom użytkowników.

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

Standard ERC-20 (opens in a new tab) obsługuje dwa sposoby raportowania niepowodzenia przez kontrakt:

  1. Przywróć
  2. Zwróć false

Obsługa obu przypadków skomplikowałaby nasz kod, dlatego zamiast tego używamy SafeERC20 OpenZeppelin (opens in a new tab), co zapewnia, że wszystkie niepowodzenia skutkują przywróceniem (opens in a new tab).

Ta linia określa, że za każdym razem, gdy używamy interfejsu IERC20, używamy opakowania SafeERC20.


    /********************************
     * Odniesienia do kontraktów zewnętrznych *
     ********************************/

    address public l2TokenBridge;

Adres L2StandardBridge.


    // Mapuje token L1 do tokenu L2 do salda zdeponowanego tokenu L1
    mapping(address => mapping(address => uint256)) public deposits;

Podwójne mapowanie (opens in a new tab) takie jak to jest sposobem na zdefiniowanie dwuwymiarowej tablicy rzadkiej (opens in a new tab). Wartości w tej strukturze danych są identyfikowane jako deposit[L1 token addr][L2 token addr]. Wartość domyślna to zero. Tylko komórki, które są ustawione na inną wartość, są zapisywane w pamięci.


    /***************
     * Konstruktor *
     ***************/

    // Ten kontrakt znajduje się za proxy, więc parametry konstruktora nie będą używane.
    constructor() CrossDomainEnabled(address(0)) {}

Aby móc uaktualnić ten kontrakt bez konieczności kopiowania wszystkich zmiennych w pamięci. Aby to zrobić, używamy Proxy (opens in a new tab), kontraktu, który używa delegatecall (opens in a new tab) do przekazywania wywołań do osobnego kontraktu, którego adres jest przechowywany przez kontrakt proxy (podczas aktualizacji informujesz proxy o zmianie tego adresu). Gdy używasz delegatecall, pamięć pozostaje pamięcią kontraktu wywołującego, więc wartości wszystkich zmiennych stanu kontraktu pozostają nienaruszone.

Jednym z efektów tego wzorca jest to, że pamięć kontraktu, który jest wywoływany przez delegatecall, nie jest używana, a zatem wartości konstruktora przekazane do niego nie mają znaczenia. To jest powód, dla którego możemy podać bezsensowną wartość do konstruktora CrossDomainEnabled. Jest to również powód, dla którego poniższa inicjalizacja jest oddzielona od konstruktora.

Ten test Slither (opens in a new tab) identyfikuje funkcje, które nie są wywoływane z kodu kontraktu i dlatego mogą być zadeklarowane jako external zamiast public. Koszt gazu funkcji external może być niższy, ponieważ mogą one być dostarczane z parametrami w calldata. Funkcje zadeklarowane jako public muszą być dostępne z wnętrza kontraktu. Kontrakty nie mogą modyfikować własnych calldata, więc parametry muszą znajdować się w pamięci. Gdy taka funkcja jest wywoływana zewnętrznie, konieczne jest skopiowanie calldata do pamięci, co kosztuje gaz. W tym przypadku funkcja jest wywoływana tylko raz, więc nieefektywność nie ma dla nas znaczenia.

    function initialize(address _l1messenger, address _l2TokenBridge) public {
        require(messenger == address(0), "Kontrakt został już zainicjowany.");

Funkcja initialize powinna być wywoływana tylko raz. Jeśli zmieni się adres komunikatora między domenami L1 lub mostu tokenów L2, tworzymy nowe proxy i nowy most, który je wywołuje. Jest to mało prawdopodobne, chyba że cały system zostanie uaktualniony, co jest bardzo rzadkim zjawiskiem.

Należy pamiętać, że ta funkcja nie ma żadnego mechanizmu ograniczającego, kto może ją wywołać. Oznacza to, że teoretycznie napastnik może poczekać, aż wdrożymy proxy i pierwszą wersję mostu, a następnie wykonać front-running (opens in a new tab), aby dostać się do funkcji initialize przed legalnym użytkownikiem. Ale istnieją dwie metody, aby temu zapobiec:

  1. Jeśli kontrakty są wdrażane nie bezpośrednio przez EOA, ale w transakcji, w której inny kontrakt je tworzy (opens in a new tab), cały proces może być atomowy i zakończyć się przed wykonaniem jakiejkolwiek innej transakcji.
  2. Jeśli legalne wywołanie initialize nie powiedzie się, zawsze można zignorować nowo utworzone proxy i most i utworzyć nowe.
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

To są dwa parametry, które most musi znać.

To jest powód, dla którego potrzebowaliśmy narzędzi Address od OpenZeppelin.

Ta funkcja istnieje w celach testowych. Zauważ, że nie pojawia się w definicjach interfejsu - nie jest przeznaczona do normalnego użytku.

Te dwie funkcje są opakowaniami wokół _initiateETHDeposit, funkcji obsługującej faktyczny depozyt ETH.

Sposób działania wiadomości między domenami polega na tym, że kontrakt docelowy jest wywoływany z wiadomością jako jego calldata. Kontrakty Solidity zawsze interpretują swoje calldata zgodnie z specyfikacjami ABI (opens in a new tab). Funkcja Solidity abi.encodeWithSelector (opens in a new tab) tworzy te calldata.

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

Wiadomość tutaj polega na wywołaniu funkcji finalizeDeposit (opens in a new tab) z tymi parametrami:

ParametrWartośćZnaczenie
_l1Tokenaddress(0)Specjalna wartość oznaczająca ETH (który nie jest tokenem ERC-20) na L1
_l2TokenLib_PredeployAddresses.OVM_ETHKontrakt L2, który zarządza ETH na Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (ten kontrakt jest przeznaczony wyłącznie do użytku wewnętrznego Optimism)
_from_fromAdres na L1, który wysyła ETH
_to_toAdres na L2, który odbiera ETH
kwotamsg.valueIlość wysłanych wei (które zostały już wysłane na most)
_data_dataDodatkowe dane do dołączenia do depozytu
        // Wyślij calldata do L2
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

Wyślij wiadomość za pośrednictwem komunikatora między domenami.

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

Wyemituj zdarzenie, aby poinformować o tym transferze każdą aplikację zdecentralizowaną, która go nasłuchuje.

Te dwie funkcje są opakowaniami wokół _initiateERC20Deposit, funkcji obsługującej faktyczny depozyt ERC-20.

Ta funkcja jest podobna do _initiateETHDeposit powyżej, z kilkoma ważnymi różnicami. Pierwsza różnica polega na tym, że ta funkcja otrzymuje adresy tokenów i kwotę do przeniesienia jako parametry. W przypadku ETH wywołanie mostu już obejmuje transfer aktywów na konto mostu (msg.value).

        // Gdy depozyt jest inicjowany na L1, most L1 przekazuje środki do siebie na przyszłe
        // wypłaty. safeTransferFrom sprawdza również, czy kontrakt ma kod, więc to się nie powiedzie, jeśli
        // _from jest EOA lub address(0).
        // slither-disable-next-line reentrancy-events, reentrancy-benign
        IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);

Transfery tokenów ERC-20 przebiegają inaczej niż transfery ETH:

  1. Użytkownik (_from) daje mostowi upoważnienie do transferu odpowiednich tokenów.
  2. Użytkownik wywołuje most z adresem kontraktu tokenu, kwotą itp.
  3. Most transferuje tokeny (do siebie) w ramach procesu depozytowego.

Pierwszy krok może nastąpić w osobnej transakcji niż dwa ostatnie. Jednak front-running nie stanowi problemu, ponieważ dwie funkcje, które wywołują _initiateERC20Deposit (depositERC20 i depositERC20To) wywołują tę funkcję tylko z msg.sender jako parametrem _from.

Dodaj zdeponowaną kwotę tokenów do struktury danych deposits. Na L2 może istnieć wiele adresów odpowiadających temu samemu tokenowi ERC-20 L1, więc nie wystarczy użyć salda mostu tokenu ERC-20 L1, aby śledzić depozyty.

Most L2 wysyła wiadomość do komunikatora między domenami L2, co powoduje, że komunikator między domenami L1 wywołuje tę funkcję (oczywiście, gdy transakcja finalizująca wiadomość (opens in a new tab) zostanie przesłana na L1).

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

Upewnij się, że jest to legalna wiadomość, pochodząca z komunikatora między domenami i pochodząca z mostu tokenów L2. Ta funkcja służy do wypłacania ETH z mostu, więc musimy upewnić się, że jest wywoływana tylko przez upoważnionego wywołującego.

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

Sposobem na transfer ETH jest wywołanie odbiorcy z kwotą wei w msg.value.

        require(success, "TransferHelper::safeTransferETH: Transfer ETH nie powiódł się");

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

Wyemituj zdarzenie dotyczące wypłaty.

Ta funkcja jest podobna do finalizeETHWithdrawal powyżej, z niezbędnymi zmianami dla tokenów ERC-20.

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

Zaktualizuj strukturę danych deposits.

Istniała wcześniejsza implementacja mostu. Kiedy przeszliśmy z tamtej implementacji na tę, musieliśmy przenieść wszystkie aktywa. Tokeny ERC-20 można po prostu przenieść. Jednak aby przetransferować ETH do kontraktu, potrzebna jest zgoda tego kontraktu, którą zapewnia nam donateETH.

Tokeny ERC-20 na L2

Aby token ERC-20 pasował do standardowego mostu, musi on zezwalać standardowemu mostowi, i tylko standardowemu mostowi, na emisję tokenów. Jest to konieczne, ponieważ mosty muszą zapewnić, że liczba tokenów w obiegu na Optimism jest równa liczbie tokenów zablokowanych w kontrakcie mostu L1. Jeśli na L2 będzie zbyt wiele tokenów, niektórzy użytkownicy nie będą mogli przenieść swoich aktywów z powrotem na L1. Zamiast zaufanego mostu, w zasadzie odtworzylibyśmy bankowość rezerw cząstkowych (opens in a new tab). Jeśli na L1 jest zbyt wiele tokenów, niektóre z nich pozostaną na zawsze zablokowane w kontrakcie mostu, ponieważ nie ma sposobu, aby je uwolnić bez spalenia tokenów L2.

IL2StandardERC20

Każdy token ERC-20 na L2, który używa standardowego mostu, musi dostarczać ten interfejs (opens in a new tab), który zawiera funkcje i zdarzenia potrzebne standardowemu mostowi.

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

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

Standardowy interfejs ERC-20 (opens in a new tab) nie zawiera funkcji mint i burn. Metody te nie są wymagane przez standard ERC-20 (opens in a new tab), który nie określa mechanizmów tworzenia i niszczenia tokenów.

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

Interfejs ERC-165 (opens in a new tab) służy do określania, jakie funkcje dostarcza kontrakt. Standard można przeczytać tutaj (opens in a new tab).

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

Ta funkcja dostarcza adres tokenu L1, który jest przenoszony na ten kontrakt za pomocą mostu. Zauważ, że nie mamy podobnej funkcji w przeciwnym kierunku. Musimy być w stanie przenosić dowolny token L1 za pomocą mostu, niezależnie od tego, czy wsparcie L2 było planowane podczas jego implementacji, czy nie.


    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);
}

Funkcje i zdarzenia do emisji (tworzenia) i spalania (niszczenia) tokenów. Most powinien być jedynym podmiotem, który może uruchamiać te funkcje, aby zapewnić prawidłową liczbę tokenów (równą liczbie tokenów zablokowanych na L1).

L2StandardERC20

To jest nasza implementacja interfejsu IL2StandardERC20 (opens in a new tab). O ile nie potrzebujesz jakiejś niestandardowej logiki, powinieneś użyć tej.

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

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

Kontrakt ERC-20 OpenZeppelin (opens in a new tab). Optimism nie wierzy w wynajdywanie koła na nowo, zwłaszcza gdy koło jest dobrze audytowane i musi być na tyle godne zaufania, aby przechowywać aktywa.

import "./IL2StandardERC20.sol";

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

Są to dwa dodatkowe parametry konfiguracyjne, których wymagamy, a których ERC-20 normalnie nie wymaga.

Najpierw wywołaj konstruktor dla kontraktu, z którego dziedziczymy (ERC20(_name, _symbol)), a następnie ustaw nasze własne zmienne.

Tak działa ERC-165 (opens in a new tab). Każdy interfejs to liczba obsługiwanych funkcji i jest identyfikowany jako alternatywa wykluczająca (opens in a new tab) selektorów funkcji ABI (opens in a new tab) tych funkcji.

Most L2 używa ERC-165 jako testu poprawności, aby upewnić się, że kontrakt ERC-20, do którego wysyła aktywa, jest IL2StandardERC20.

Uwaga: Nic nie stoi na przeszkodzie, aby nieuczciwy kontrakt dostarczał fałszywych odpowiedzi do supportsInterface, więc jest to mechanizm sprawdzania poprawności, nie mechanizm bezpieczeństwa.

Tylko most L2 może emitować i spalać aktywa.

_mint i _burn są w rzeczywistości zdefiniowane w kontrakcie ERC-20 OpenZeppelin. Ten kontrakt po prostu nie udostępnia ich na zewnątrz, ponieważ warunki emisji i spalania tokenów są tak zróżnicowane, jak liczba sposobów wykorzystania ERC-20.

Kod mostu L2

To jest kod, który uruchamia most na Optimism. Źródło tego kontraktu znajduje się tutaj (opens in a new tab).

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

/* Importy interfejsów */
import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";
import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";

Interfejs IL2ERC20Bridge (opens in a new tab) jest bardzo podobny do odpowiednika L1, który widzieliśmy powyżej. Istnieją dwie znaczące różnice:

  1. Na L1 inicjujesz depozyty i finalizujesz wypłaty. Tutaj inicjujesz wypłaty i finalizujesz depozyty.
  2. Na L1 konieczne jest rozróżnienie między tokenami ETH i ERC-20. Na L2 możemy używać tych samych funkcji dla obu, ponieważ wewnętrznie salda ETH na Optimism są obsługiwane jako token ERC-20 z adresem 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab).

Śledź adres mostu L1. Zauważ, że w przeciwieństwie do odpowiednika L1, tutaj potrzebujemy tej zmiennej. Adres mostu L1 nie jest znany z góry.

Te dwie funkcje inicjują wypłaty. Zauważ, że nie ma potrzeby określania adresu tokenu L1. Oczekuje się, że tokeny L2 podadzą nam adres swojego odpowiednika L1.

Zauważ, że nie polegamy na parametrze _from, ale na msg.sender, który jest znacznie trudniejszy do sfałszowania (o ile mi wiadomo, niemożliwy).


        // Skonstruuj calldata dla l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)
        // slither-disable-next-line reentrancy-events
        address l1Token = IL2StandardERC20(_l2Token).l1Token();
        bytes memory message;

        if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {

Na L1 konieczne jest rozróżnienie między ETH i ERC-20.

Ta funkcja jest wywoływana przez L1StandardBridge.

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

Upewnij się, że źródło wiadomości jest legalne. Jest to ważne, ponieważ ta funkcja wywołuje _mint i może być użyta do wydawania tokenów, które nie są pokryte tokenami, które most posiada na L1.

        // Sprawdź, czy token docelowy jest zgodny i
        // zweryfikuj, czy zdeponowany token na L1 odpowiada reprezentacji zdeponowanego tokenu L2 tutaj
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()

Testy poprawności:

  1. Obsługiwany jest prawidłowy interfejs
  2. Adres L1 kontraktu ERC-20 na L2 odpowiada źródłu tokenów na L1
        ) {
            // Po sfinalizowaniu depozytu zasilamy konto na L2 taką samą kwotą
            // tokenów.
            // 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);

Jeśli testy poprawności przejdą pomyślnie, sfinalizuj depozyt:

  1. Wyemituj tokeny
  2. Wyemituj odpowiednie zdarzenie

Jeśli użytkownik popełnił wykrywalny błąd, używając niewłaściwego adresu tokenu L2, chcemy anulować depozyt i zwrócić tokeny na L1. Jedynym sposobem, w jaki możemy to zrobić z L2, jest wysłanie wiadomości, która będzie musiała poczekać na okres kwestionowania błędu, ale jest to znacznie lepsze dla użytkownika niż trwała utrata tokenów.

Wnioski

Standardowy most jest najbardziej elastycznym mechanizmem transferu aktywów. Jednakże, ponieważ jest tak ogólny, nie zawsze jest to najłatwiejszy mechanizm do użycia. Szczególnie w przypadku wypłat, większość użytkowników woli korzystać z mostów stron trzecich (opens in a new tab), które nie czekają na okres kwestionowania i nie wymagają dowodu Merkle, aby sfinalizować wypłatę.

Te mosty zazwyczaj działają poprzez posiadanie aktywów na L1, które dostarczają natychmiast za niewielką opłatą (często niższą niż koszt gazu za standardową wypłatę z mostu). Gdy most (lub osoby go prowadzące) przewiduje brak aktywów L1, przekazuje wystarczającą ilość aktywów z L2. Ponieważ są to bardzo duże wypłaty, koszt wypłaty jest amortyzowany na dużą kwotę i stanowi znacznie mniejszy procent.

Mam nadzieję, że ten artykuł pomógł ci lepiej zrozumieć, jak działa warstwa 2 i jak pisać kod Solidity, który jest jasny i bezpieczny.

Zobacz więcej mojej pracy tutaj (opens in a new tab).

Ostatnia aktualizacja strony: 3 kwietnia 2026

Czy ten samouczek był pomocny?