Przejdź do głównej treści

Przewodnik po kontrakcie standardowego mostu Optimism

Solidity
most
warstwa 2
Średniozaawansowany
Ori Pomerantz
30 marca 2022
31 minut czytania

Optimism (opens in a new tab) to optymistyczny rollup. Optymistyczne rollupy mogą przetwarzać transakcje za znacznie niższą cenę 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ć ze wszystkimi gwarancjami integralności i dostępności sieci głównej.

Aby używać aktywów z L1 na Optimism (lub dowolnej innej L2), aktywa te muszą zostać przeniesione przez most. Jednym ze sposobów na osiągnięcie tego jest zablokowanie przez użytkowników aktywów (najczęściej są to ETH i tokeny ERC-20) na L1 i otrzymanie równoważnych aktywów do wykorzystania na L2. Ostatecznie ten, kto wejdzie w ich posiadanie, może chcieć przenieść je z powrotem przez most na L1. 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 przyjrzymy się kodowi źródłowemu tego mostu, aby zobaczyć, jak działa, i przeanalizujemy go jako przykład dobrze napisanego kodu w języku 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 przyznaje mostowi limit wydatków na kwotę, która ma zostać zdeponowana
  2. Deponent wywołuje most L1 (depositERC20, depositERC20To, depositETH lub depositETHTo)
  3. Most L1 przejmuje w posiadanie przenoszone aktywo
    • ETH: Aktywo jest transferowane przez deponenta w ramach wywołania
    • ERC-20: Aktywo jest transferowane przez most do samego siebie przy użyciu limitu wydatków zapewnionego przez deponenta
  4. Most L1 używa mechanizmu wiadomości międzydomenowych (cross-domain message), aby wywołać finalizeDeposit w moście L2

Warstwa 2

  1. Most L2 weryfikuje, czy wywołanie finalizeDeposit jest prawidłowe:
    • Pochodzi z kontraktu wiadomości międzydomenowych
    • Pierwotnie pochodziło z mostu na L1
  2. Most L2 sprawdza, czy kontrakt tokena ERC-20 na L2 jest właściwy:
    • Kontrakt L2 zgłasza, że jego odpowiednik na L1 jest taki sam jak ten, z którego pochodzą tokeny na L1
    • Kontrakt L2 zgłasza, że obsługuje poprawny interfejs (używając ERC-165 (opens in a new tab)).
  3. Jeśli kontrakt L2 jest właściwy, wywołuje go, aby wybić odpowiednią liczbę tokenów na odpowiedni adres. Jeśli nie, rozpoczyna proces wypłaty, aby umożliwić użytkownikowi odebranie tokenów na L1.

Przepływ wypłaty

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ędzydomenowych, aby wywołać finalizeETHWithdrawal lub finalizeERC20Withdrawal w moście L1

Warstwa 1

  1. Most L1 weryfikuje, czy wywołanie finalizeETHWithdrawal lub finalizeERC20Withdrawal jest prawidłowe:
    • Pochodzi z mechanizmu wiadomości międzydomenowych
    • Pierwotnie pochodziło z mostu na L2
  2. Most L1 transferuje odpowiednie aktywo (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 przez most.

// SPDX-License-Identifier: MIT

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

pragma solidity >0.5.0 <0.9.0;

W momencie pisania tego tekstu najnowszą wersją Solidity jest 0.8.12. Dopóki nie zostanie wydana wersja 0.9.0, nie wiemy, czy ten kod będzie z nią kompatybilny, czy nie.

W terminologii mostu 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 znajduje się 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
    );

Do transferów można dodawać notatki, 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 wstępnie wdrożony kontrakt, więc zawsze znajduje się pod adresem 0x4200000000000000000000000000000000000010. Znajduje się tutaj dla zachowania 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 wybijania, nie powinno to stanowić problemu. Ta funkcja zajmuje się typowym scenariuszem, w którym użytkownik przenosi aktywa przez most na ten sam adres na innym blockchainie.

Ta funkcja jest prawie identyczna z 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 (roszczenie) na L1. Ta transakcja musi nastąpić po zakończeniu okresu kwestionowania błędów (fault challenge period) (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 w IL1ERC20Bridge powyżej 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 tą różnicą, że nie zawiera 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) w celu 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) mówi kontraktowi, jak wysyłać wiadomości do drugiej warstwy, używając komunikatora międzydomenowego (cross domain messenger). Ten komunikator międzydomenowy to zupełnie inny system i zasługuje na osobny artykuł, który mam nadzieję napisać w przyszłości.

Jedynym parametrem, który kontrakt musi znać, jest adres komunikatora międzydomenowego w tej warstwie. Parametr ten jest ustawiany raz, w konstruktorze, i nigdy się nie zmienia.

Wiadomości międzydomenowe są dostępne dla każdego kontraktu na blockchainie, na którym jest on uruchomiony (zarówno w sieci głównej Ethereum, jak i w Optimism). Musimy jednak sprawić, 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: messenger contract unauthenticated"
        );

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


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

Sposobem, w jaki komunikator międzydomenowy udostępnia adres, który wysłał wiadomość z innej warstwy, jest funkcja .xDomainMessageSender() (opens in a new tab). Dopóki jest ona wywoływana w transakcji zainicjowanej przez wiadomość, może dostarczyć te informacje.

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

Ta funkcja zwraca komunikator międzydomenowy. Używamy funkcji zamiast zmiennej messenger, aby umożliwić kontraktom dziedziczącym po tym kontrakcie użycie algorytmu do określenia, którego komunikatora międzydomenowego 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 analizator statyczny, który Optimism uruchamia na każdym kontrakcie w poszukiwaniu luk w zabezpieczeniach i innych potencjalnych problemów. W tym przypadku poniższy wiersz wyzwala dwie luki:

  1. Zdarzenia reentrancji (opens in a new tab)
  2. Łagodna reentrancja (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

W tym przypadku nie martwimy się o reentrancję, ponieważ wiemy, że getCrossDomainMessenger() zwraca zaufany adres, nawet jeśli Slither nie ma możliwości, aby o tym 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 tego, jakiej wersji Solidity 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 sterowania standardowym mostem 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żna przeczytać tutaj.

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

Jak wyjaśniono powyżej, ten kontrakt jest używany do przesyłania wiadomości między warstwami.

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 Address z OpenZeppelin (opens in a new tab). Służą one do rozróżniania adresów kontraktów od tych należących do kont zewnętrznych (EOA).

Należy pamiętać, że nie jest to idealne rozwiązanie, ponieważ nie ma sposobu na rozróżnienie bezpośrednich wywołań 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 zgłaszania niepowodzenia przez kontrakt:

  1. Wycofanie (revert)
  2. Zwrócenie false

Obsługa obu przypadków skomplikowałaby nasz kod, więc zamiast tego używamy SafeERC20 z OpenZeppelin (opens in a new tab), co gwarantuje, że wszystkie niepowodzenia skutkują wycofaniem (opens in a new tab).

W tym wierszu określamy, aby używać wrappera SafeERC20 za każdym razem, gdy używamy interfejsu IERC20.


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

    address public l2TokenBridge;

Adres L2StandardBridge.


    // Mapuje token warstwy 1 (L1) na token warstwy 2 (L2) na saldo zdeponowanego tokena warstwy 1 (L1)
    mapping(address => mapping(address => uint256)) public deposits;

Podwójne mapowanie (opens in a new tab) tego typu to sposób 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 ustawione na inną wartość są zapisywane w pamięci (storage).


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

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

Chcemy mieć możliwość aktualizacji tego kontraktu 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 oddzielnego kontraktu, którego adres jest przechowywany przez kontrakt proxy (podczas aktualizacji mówisz proxy, aby zmienił ten adres). Kiedy używasz delegatecall, pamięć pozostaje pamięcią kontraktu wywołującego, więc wartości wszystkich zmiennych stanu kontraktu pozostają nienaruszone.

Jednym ze skutkó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. Z tego powodu 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łyby zostać zadeklarowane jako external zamiast public. Koszt gazu dla funkcji external może być niższy, ponieważ mogą one otrzymywać parametry w danych wywołania (calldata). Funkcje zadeklarowane jako public muszą być dostępne z wnętrza kontraktu. Kontrakty nie mogą modyfikować własnych danych wywołania, więc parametry muszą znajdować się w pamięci (memory). Gdy taka funkcja jest wywoływana zewnętrznie, konieczne jest skopiowanie danych wywołania do pamięci, co kosztuje gaz. W tym przypadku funkcja jest wywoływana tylko raz, więc ta nieefektywność nie ma dla nas znaczenia.

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

Funkcja initialize powinna być wywołana tylko raz. Jeśli zmieni się adres komunikatora międzydomenowego L1 lub mostu tokenów L2, tworzymy nowe proxy i nowy most, który je wywołuje. Jest mało prawdopodobne, aby to nastąpiło, z wyjątkiem sytuacji, gdy cały system jest aktualizowany, co zdarza się bardzo rzadko.

Należy zauważyć, że ta funkcja nie ma żadnego mechanizmu ograniczającego to, kto może ją wywołać. Oznacza to, że w teorii atakujący mógłby poczekać, aż wdrożymy proxy i pierwszą wersję mostu, a następnie zastosować wyprzedzanie transakcji (opens in a new tab), aby dostać się do funkcji initialize przed prawowitym użytkownikiem. Istnieją jednak dwie metody, aby temu zapobiec:

  1. Jeśli kontrakty nie są wdrażane 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 prawowite wywołanie initialize nie powiedzie się, zawsze można zignorować nowo utworzone proxy i most, a następnie utworzyć nowe.
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

Są to dwa parametry, które most musi znać.

Z tego powodu potrzebowaliśmy narzędzi Address z OpenZeppelin.

Ta funkcja istnieje w celach testowych. Zauważ, że nie pojawia się ona w definicjach interfejsów – nie jest przeznaczona do normalnego użytku.

Te dwie funkcje to wrappery wokół _initiateETHDeposit, funkcji, która obsługuje właściwy depozyt ETH.

Wiadomości międzydomenowe działają w ten sposób, że kontrakt docelowy jest wywoływany z wiadomością jako jego danymi wywołania (calldata). Kontrakty Solidity zawsze interpretują swoje dane wywołania zgodnie ze specyfikacjami ABI (opens in a new tab). Funkcja Solidity abi.encodeWithSelector (opens in a new tab) tworzy te dane wywołania.

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

Wiadomość polega tutaj na wywołaniu funkcji finalizeDeposit (opens in a new tab) z następującymi parametrami:

ParametrWartośćZnaczenie
_l1Tokenaddress(0)Specjalna wartość oznaczająca ETH (które 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
amountmsg.valueIlość wysłanych wei (które zostały już wysłane do mostu)
_data_dataDodatkowe dane do dołączenia do depozytu
        // Wysyła dane wywołania do warstwy 2 (L2)
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

Wysyła wiadomość przez komunikator międzydomenowy.

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

Emituje zdarzenie, aby poinformować każdą zdecentralizowaną aplikację (dapp), która nasłuchuje tego transferu.

Te dwie funkcje to wrappery wokół _initiateERC20Deposit, funkcji, która obsługuje właściwy 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 transferu jako parametry. W przypadku ETH wywołanie mostu obejmuje już transfer aktywa na konto mostu (msg.value).

        // Kiedy depozyt jest inicjowany w warstwie 1 (L1), most warstwy 1 (L1) transferuje środki do siebie na poczet przyszłych
        // wypłat. safeTransferFrom sprawdza również, czy kontrakt posiada 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ż w przypadku ETH:

  1. Użytkownik (_from) przyznaje mostowi limit wydatków na transfer odpowiednich tokenów.
  2. Użytkownik wywołuje most z adresem kontraktu tokena, kwotą itp.
  3. Most transferuje tokeny (do samego siebie) w ramach procesu depozytu.

Pierwszy krok może nastąpić w oddzielnej transakcji od dwóch ostatnich. Jednak wyprzedzanie transakcji 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.

Dodaje zdeponowaną kwotę tokenów do struktury danych deposits. Na L2 może istnieć wiele adresów odpowiadających temu samemu tokenowi ERC-20 na L1, więc użycie salda mostu dla tokena ERC-20 na L1 nie wystarczy do śledzenia depozytów.

Most L2 wysyła wiadomość do komunikatora międzydomenowego L2, co powoduje, że komunikator międzydomenowy L1 wywołuje tę funkcję (oczywiście po przesłaniu transakcji finalizującej wiadomość (opens in a new tab) na L1).

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

Upewnia się, że jest to prawidłowa wiadomość, pochodząca z komunikatora międzydomenowego i mająca swoje źródło w moście tokenów L2. Ta funkcja służy do wypłaty ETH z mostu, więc musimy upewnić się, że jest wywoływana tylko przez autoryzowanego 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 ilością wei w msg.value.

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

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

Emituje 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;

Aktualizuje 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, potrzebujesz zgody tego kontraktu, co właśnie zapewnia nam donateETH.

Tokeny ERC-20 na L2

Aby token ERC-20 pasował do standardowego mostu, musi pozwalać standardowemu mostowi, i tylko standardowemu mostowi, na wybijanie tokenów. Jest to konieczne, ponieważ mosty muszą zapewnić, że liczba tokenów w obiegu na Optimism jest równa liczbie tokenów zablokowanych wewnątrz kontraktu mostu L1. Gdyby na L2 było zbyt wiele tokenów, niektórzy użytkownicy nie byliby w stanie przenieść swoich aktywów z powrotem przez most na L1. Zamiast zaufanego mostu, w zasadzie odtworzylibyśmy system rezerwy cząstkowej (opens in a new tab). Gdyby na L1 było zbyt wiele tokenów, niektóre z nich pozostałyby zablokowane wewnątrz kontraktu mostu na zawsze, ponieważ nie ma sposobu na ich uwolnienie bez spalenia tokenów L2.

IL2StandardERC20

Każdy token ERC-20 na L2, który używa standardowego mostu, musi udostępniać 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 pozostawia nieokreślone mechanizmy 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 udostępnia kontrakt. Standard można przeczytać tutaj (opens in a new tab).

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

Ta funkcja udostępnia adres tokena L1, który jest przenoszony przez most do tego kontraktu. Należy zauważyć, że nie mamy podobnej funkcji w przeciwnym kierunku. Musimy mieć możliwość przeniesienia przez most dowolnego tokena L1, niezależnie od tego, czy obsługa L2 była planowana 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 wybijania (tworzenia) i spalania (niszczenia) tokenów. Most powinien być jedynym podmiotem, który może uruchamiać te funkcje, aby zapewnić, że liczba tokenów jest prawidłowa (równa 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 z OpenZeppelin (opens in a new tab). Optimism nie wierzy w wymyślanie koła na nowo, zwłaszcza gdy koło jest dobrze zbadane i musi być wystarczająco 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łuje konstruktor dla kontraktu, po którym dziedziczymy (ERC20(_name, _symbol)), a następnie ustawia nasze własne zmienne.

W ten sposób działa ERC-165 (opens in a new tab). Każdy interfejs to zbiór obsługiwanych funkcji i jest identyfikowany jako alternatywa wykluczająca (XOR) (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 (sanity check), aby upewnić się, że kontrakt ERC-20, do którego wysyła aktywa, to IL2StandardERC20.

Uwaga: Nie ma nic, co powstrzymałoby złośliwy kontrakt przed udzielaniem fałszywych odpowiedzi na supportsInterface, więc jest to mechanizm testu poprawności, a nie mechanizm bezpieczeństwa.

Tylko most L2 ma prawo wybijania i spalania aktywów.

_mint i _burn są w rzeczywistości zdefiniowane w kontrakcie ERC-20 z OpenZeppelin. Ten kontrakt po prostu nie udostępnia ich na zewnątrz, ponieważ warunki wybijania 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. Kod źródłowy 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 istotne 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 ETH a tokenami 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).

Śledzi adres mostu L1. Należy zauważyć, ż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. Należy zauważyć, że nie ma potrzeby określania adresu tokena L1. Oczekuje się, że tokeny L2 podadzą nam adres odpowiednika L1.

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


        // Konstruuje dane wywołania 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 a ERC-20.

Ta funkcja jest wywoływana przez L1StandardBridge.

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

Upewnia się, że źródło wiadomości jest prawidłowe. Jest to ważne, ponieważ ta funkcja wywołuje _mint i mogłaby zostać użyta do przyznania tokenów, które nie mają pokrycia w tokenach posiadanych przez most na L1.

        // Sprawdza, czy docelowy token jest zgodny i
        // weryfikuje, czy zdeponowany token w warstwie 1 (L1) pasuje do reprezentacji zdeponowanego tokena w warstwie 2 (L2) tutaj
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()

Testy poprawności (sanity checks):

  1. Obsługiwany jest poprawny interfejs
  2. Adres L1 kontraktu ERC-20 na L2 pasuje do źródła tokenów na L1
        ) {
            // Kiedy depozyt jest finalizowany, uznajemy konto w warstwie 2 (L2) tą 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 zakończą się pomyślnie, finalizuje depozyt:

  1. Wybija tokeny
  2. Emituje odpowiednie zdarzenie

Jeśli użytkownik popełnił wykrywalny błąd, używając niewłaściwego adresu tokena 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 odczekać okres kwestionowania błędów, ale jest to znacznie lepsze dla użytkownika niż trwała utrata tokenów.

Podsumowanie

Standardowy most to najbardziej elastyczny mechanizm transferu aktywów. Jednak ze względu na to, że jest tak ogólny, nie zawsze jest najłatwiejszym mechanizmem w użyciu. 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'a do sfinalizowania wypłaty.

Mosty te zazwyczaj działają w ten sposób, że posiadają aktywa na L1, które udostępniają natychmiast za niewielką opłatą (często mniejszą niż koszt gazu dla standardowej wypłaty z mostu). Kiedy most (lub osoby nim zarządzające) przewiduje, że zabraknie mu aktywów na L1, transferuje 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.

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

Zobacz tutaj więcej moich prac (opens in a new tab).