Zum Hauptinhalt springen

Walkthrough durch den Standard-Brücken-Vertrag von Optimism

Solidity
Brücke
Layer 2
Fortgeschritten
Ori Pomerantz
30. März 2022
33 Minuten Lesezeit

Optimism (opens in a new tab) ist ein Optimistic Rollup. Optimistic Rollups können Transaktionen zu einem viel niedrigeren Preis als das Ethereum Mainnet (auch bekannt als Layer 1 oder L1) verarbeiten, da Transaktionen nur von wenigen Knoten anstatt von jedem Knoten im Netzwerk verarbeitet werden. Gleichzeitig werden alle Daten auf L1 geschrieben, sodass alles mit allen Integritäts- und Verfügbarkeitsgarantien des Mainnets bewiesen und rekonstruiert werden kann.

Um L1-Vermögenswerte auf Optimism (oder einem anderen L2) zu verwenden, müssen die Vermögenswerte über eine Brücke transferiert werden. Eine Möglichkeit, dies zu erreichen, besteht darin, dass Benutzer Vermögenswerte (ETH und ERC-20-Token sind die häufigsten) auf L1 sperren und gleichwertige Vermögenswerte zur Verwendung auf L2 erhalten. Letztendlich möchte derjenige, der sie am Ende besitzt, sie vielleicht wieder über die Brücke zurück zu L1 transferieren. Dabei werden die Vermögenswerte auf L2 verbrannt und dann auf L1 wieder an den Benutzer freigegeben.

Auf diese Weise funktioniert die Standard-Brücke von Optimism (opens in a new tab). In diesem Artikel gehen wir den Quellcode für diese Brücke durch, um zu sehen, wie sie funktioniert, und studieren ihn als Beispiel für gut geschriebenen Solidity-Code.

Kontrollflüsse

Die Brücke hat zwei Hauptflüsse:

  • Einzahlung (von L1 zu L2)
  • Abhebung (von L2 zu L1)

Einzahlungsfluss

Layer 1

  1. Bei der Einzahlung eines ERC-20 gibt der Einzahler der Brücke einen Freigabebetrag, um den eingezahlten Betrag auszugeben.
  2. Der Einzahler ruft die L1-Brücke auf (depositERC20, depositERC20To, depositETH oder depositETHTo).
  3. Die L1-Brücke übernimmt den Besitz des über die Brücke transferierten Vermögenswerts.
    • ETH: Der Vermögenswert wird vom Einzahler als Teil des Aufrufs transferiert.
    • ERC-20: Der Vermögenswert wird von der Brücke unter Verwendung des vom Einzahler bereitgestellten Freigabebetrags an sich selbst transferiert.
  4. Die L1-Brücke verwendet den domänenübergreifenden Nachrichtenmechanismus (Cross-Domain Message Mechanism), um finalizeDeposit auf der L2-Brücke aufzurufen.

Layer 2

  1. Die L2-Brücke verifiziert, dass der Aufruf von finalizeDeposit legitim ist:
    • Er kam vom domänenübergreifenden Nachrichten-Vertrag.
    • Er stammte ursprünglich von der Brücke auf L1.
  2. Die L2-Brücke prüft, ob der ERC-20-Token-Vertrag auf L2 der richtige ist:
  3. Wenn der L2-Vertrag der richtige ist, wird er aufgerufen, um die entsprechende Anzahl von Token für die entsprechende Adresse zu prägen. Wenn nicht, wird ein Abhebungsprozess gestartet, damit der Benutzer die Token auf L1 beanspruchen kann.

Abhebungsfluss

Layer 2

  1. Der Abhebende ruft die L2-Brücke auf (withdraw oder withdrawTo).
  2. Die L2-Brücke verbrennt die entsprechende Anzahl von Token, die msg.sender gehören.
  3. Die L2-Brücke verwendet den domänenübergreifenden Nachrichtenmechanismus, um finalizeETHWithdrawal oder finalizeERC20Withdrawal auf der L1-Brücke aufzurufen.

Layer 1

  1. Die L1-Brücke verifiziert, dass der Aufruf von finalizeETHWithdrawal oder finalizeERC20Withdrawal legitim ist:
    • Er kam vom domänenübergreifenden Nachrichtenmechanismus.
    • Er stammte ursprünglich von der Brücke auf L2.
  2. Die L1-Brücke transferiert den entsprechenden Vermögenswert (ETH oder ERC-20) an die entsprechende Adresse.

Layer-1-Code

Dies ist der Code, der auf L1, dem Ethereum Mainnet, ausgeführt wird.

IL1ERC20Bridge

Diese Schnittstelle ist hier definiert (opens in a new tab). Sie enthält Funktionen und Definitionen, die für den Transfer von ERC-20-Token über die Brücke erforderlich sind.

// SPDX-License-Identifier: MIT

Der Großteil des Codes von Optimism wird unter der MIT-Lizenz veröffentlicht (opens in a new tab).

pragma solidity >0.5.0 <0.9.0;

Zum Zeitpunkt des Schreibens ist die neueste Version von Solidity 0.8.12. Bis Version 0.9.0 veröffentlicht wird, wissen wir nicht, ob dieser Code damit kompatibel ist oder nicht.

In der Terminologie der Optimism-Brücke bedeutet Einzahlung (Deposit) einen Transfer von L1 zu L2 und Abhebung (Withdrawal) einen Transfer von L2 zu L1.

        address indexed _l1Token,
        address indexed _l2Token,

In den meisten Fällen ist die Adresse eines ERC-20 auf L1 nicht dieselbe wie die Adresse des äquivalenten ERC-20 auf L2. Sie können die Liste der Token-Adressen hier einsehen (opens in a new tab). Die Adresse mit der chainId 1 befindet sich auf L1 (Mainnet) und die Adresse mit der chainId 10 befindet sich auf L2 (Optimism). Die anderen beiden chainId-Werte sind für das Kovan-Testnetzwerk (42) und das Optimistic Kovan-Testnetzwerk (69).

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

Es ist möglich, Transfers Notizen hinzuzufügen. In diesem Fall werden sie den Ereignissen hinzugefügt, die sie melden.

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

Derselbe Brücken-Vertrag verarbeitet Transfers in beide Richtungen. Im Falle der L1-Brücke bedeutet dies die Initialisierung von Einzahlungen und die Finalisierung von Abhebungen.

Diese Funktion wird nicht wirklich benötigt, da es sich auf L2 um einen vorab bereitgestellten Vertrag handelt, sodass er sich immer an der Adresse 0x4200000000000000000000000000000000000010 befindet. Sie ist hier aus Symmetriegründen zur L2-Brücke vorhanden, da die Adresse der L1-Brücke nicht trivial zu ermitteln ist.

Der Parameter _l2Gas ist die Menge an L2-Gas, die die Transaktion ausgeben darf. Bis zu einem bestimmten (hohen) Limit ist dies kostenlos (opens in a new tab). Sofern der ERC-20-Vertrag beim Prägen also nichts wirklich Seltsames tut, sollte dies kein Problem darstellen. Diese Funktion kümmert sich um das häufige Szenario, bei dem ein Benutzer Vermögenswerte an dieselbe Adresse auf einer anderen Blockchain über die Brücke transferiert.

Diese Funktion ist fast identisch mit depositERC20, ermöglicht es Ihnen jedoch, den ERC-20 an eine andere Adresse zu senden.

Abhebungen (und andere Nachrichten von L2 zu L1) in Optimism sind ein zweistufiger Prozess:

  1. Eine initiierende Transaktion auf L2.
  2. Eine finalisierende oder beanspruchende Transaktion auf L1. Diese Transaktion muss stattfinden, nachdem die Fehleranfechtungsfrist (Fault Challenge Period) (opens in a new tab) für die L2-Transaktion endet.

IL1StandardBridge

Diese Schnittstelle ist hier definiert (opens in a new tab). Diese Datei enthält Ereignis- und Funktionsdefinitionen für ETH. Diese Definitionen sind denen sehr ähnlich, die oben in IL1ERC20Bridge für ERC-20 definiert wurden.

Die Brücken-Schnittstelle ist auf zwei Dateien aufgeteilt, da einige ERC-20-Token eine benutzerdefinierte Verarbeitung erfordern und nicht von der Standard-Brücke verarbeitet werden können. Auf diese Weise kann die benutzerdefinierte Brücke, die ein solches Token verarbeitet, IL1ERC20Bridge implementieren und muss nicht auch ETH über die Brücke transferieren.

Dieses Ereignis ist fast identisch mit der ERC-20-Version (ERC20DepositInitiated), außer ohne die L1- und L2-Token-Adressen. Dasselbe gilt für die anderen Ereignisse und die Funktionen.

CrossDomainEnabled

Dieser Vertrag (opens in a new tab) wird von beiden Brücken (L1 und L2) geerbt, um Nachrichten an den anderen Layer zu senden.

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

/* Interface-Importe */
import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";

Diese Schnittstelle (opens in a new tab) teilt dem Vertrag mit, wie Nachrichten unter Verwendung des domänenübergreifenden Messengers an den anderen Layer gesendet werden. Dieser domänenübergreifende Messenger ist ein völlig anderes System und verdient einen eigenen Artikel, den ich hoffentlich in Zukunft schreiben werde.

Der einzige Parameter, den der Vertrag kennen muss, ist die Adresse des domänenübergreifenden Messengers auf diesem Layer. Dieser Parameter wird einmal im Konstruktor festgelegt und ändert sich nie.

Das domänenübergreifende Messaging ist für jeden Vertrag auf der Blockchain zugänglich, auf der es ausgeführt wird (entweder Ethereum Mainnet oder Optimism). Wir benötigen jedoch, dass die Brücke auf jeder Seite bestimmten Nachrichten nur vertraut, wenn sie von der Brücke auf der anderen Seite stammen.

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

Nur Nachrichten vom entsprechenden domänenübergreifenden Messenger (messenger, wie Sie unten sehen) können als vertrauenswürdig eingestuft werden.


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

Die Art und Weise, wie der domänenübergreifende Messenger die Adresse bereitstellt, die eine Nachricht an den anderen Layer gesendet hat, ist die Funktion .xDomainMessageSender() (opens in a new tab). Solange sie in der Transaktion aufgerufen wird, die durch die Nachricht initiiert wurde, kann sie diese Informationen bereitstellen.

Wir müssen sicherstellen, dass die empfangene Nachricht von der anderen Brücke stammt.

Diese Funktion gibt den domänenübergreifenden Messenger zurück. Wir verwenden eine Funktion anstelle der Variablen messenger, um Verträgen, die von diesem erben, die Verwendung eines Algorithmus zu ermöglichen, der angibt, welcher domänenübergreifende Messenger verwendet werden soll.

Schließlich die Funktion, die eine Nachricht an den anderen Layer sendet.

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

Slither (opens in a new tab) ist ein statischer Analysator, den Optimism auf jedem Vertrag ausführt, um nach Schwachstellen und anderen potenziellen Problemen zu suchen. In diesem Fall löst die folgende Zeile zwei Schwachstellen aus:

  1. Wiedereintritt-Ereignisse (Reentrancy Events) (opens in a new tab)
  2. Gutartiger Wiedereintritt (Benign Reentrancy) (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

In diesem Fall machen wir uns keine Sorgen über einen Wiedereintritt. Wir wissen, dass getCrossDomainMessenger() eine vertrauenswürdige Adresse zurückgibt, auch wenn Slither keine Möglichkeit hat, dies zu wissen.

Der L1-Brücken-Vertrag

Der Quellcode für diesen Vertrag befindet sich hier (opens in a new tab).

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

Die Schnittstellen können Teil anderer Verträge sein, daher müssen sie eine breite Palette von Solidity-Versionen unterstützen. Aber die Brücke selbst ist unser Vertrag, und wir können streng sein, welche Solidity-Version sie verwendet.

/* Interface-Importe */
import { IL1StandardBridge } from "./IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";

IL1ERC20Bridge und IL1StandardBridge werden oben erklärt.

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

Diese Schnittstelle (opens in a new tab) ermöglicht es uns, Nachrichten zur Steuerung der Standard-Brücke auf L2 zu erstellen.

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

Diese Schnittstelle (opens in a new tab) ermöglicht es uns, ERC-20-Verträge zu steuern. Sie können hier mehr darüber lesen.

/* Bibliotheks-Importe */
import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";

Wie oben erklärt, wird dieser Vertrag für das Messaging zwischen den Layern verwendet.

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

Lib_PredeployAddresses (opens in a new tab) enthält die Adressen für die L2-Verträge, die immer dieselbe Adresse haben. Dies schließt die Standard-Brücke auf L2 ein.

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

Die Adress-Dienstprogramme von OpenZeppelin (opens in a new tab). Sie werden verwendet, um zwischen Vertragsadressen und solchen zu unterscheiden, die zu externen Konten (Externally Owned Accounts, EOA) gehören.

Beachten Sie, dass dies keine perfekte Lösung ist, da es keine Möglichkeit gibt, zwischen direkten Aufrufen und Aufrufen aus dem Konstruktor eines Vertrags zu unterscheiden. Zumindest können wir so jedoch einige häufige Benutzerfehler identifizieren und verhindern.

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

Der ERC-20-Standard (opens in a new tab) unterstützt zwei Möglichkeiten für einen Vertrag, einen Fehler zu melden:

  1. Rückgängig machen (Revert)
  2. Rückgabe von false

Die Behandlung beider Fälle würde unseren Code komplizierter machen. Stattdessen verwenden wir SafeERC20 von OpenZeppelin (opens in a new tab), was sicherstellt, dass alle Fehler dazu führen, dass die Transaktion rückgängig gemacht wird (opens in a new tab).

Mit dieser Zeile geben wir an, dass der Wrapper SafeERC20 jedes Mal verwendet werden soll, wenn wir die Schnittstelle IERC20 verwenden.


    /********************************
     * Externe Vertragsreferenzen *
     ********************************/

    address public l2TokenBridge;

Die Adresse der L2StandardBridge.


    // Ordnet L1-Token dem L2-Token und dem Guthaben des eingezahlten L1-Tokens zu
    mapping(address => mapping(address => uint256)) public deposits;

Ein doppeltes Mapping (opens in a new tab) wie dieses ist die Art und Weise, wie Sie ein zweidimensionales Sparse-Array (opens in a new tab) definieren. Werte in dieser Datenstruktur werden als deposit[L1 token addr][L2 token addr] identifiziert. Der Standardwert ist null. Nur Zellen, die auf einen anderen Wert gesetzt sind, werden in den Speicher geschrieben.


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

    // Dieser Vertrag befindet sich hinter einem Proxy, daher bleiben die Konstruktor-Parameter ungenutzt.
    constructor() CrossDomainEnabled(address(0)) {}

Wir möchten in der Lage sein, diesen Vertrag zu aktualisieren, ohne alle Variablen im Speicher kopieren zu müssen. Dazu verwenden wir einen Proxy (opens in a new tab), einen Vertrag, der delegatecall (opens in a new tab) verwendet, um Aufrufe an einen separaten Vertrag zu übertragen, dessen Adresse vom Proxy-Contract gespeichert wird (beim Upgrade weisen Sie den Proxy an, diese Adresse zu ändern). Wenn Sie delegatecall verwenden, bleibt der Speicher der Speicher des aufrufenden Vertrags, sodass die Werte aller Zustandsvariablen des Vertrags unberührt bleiben.

Ein Effekt dieses Musters ist, dass der Speicher des Vertrags, der der Aufgerufene von delegatecall ist, nicht verwendet wird und daher die an ihn übergebenen Konstruktorwerte keine Rolle spielen. Dies ist der Grund, warum wir dem Konstruktor CrossDomainEnabled einen unsinnigen Wert übergeben können. Es ist auch der Grund, warum die unten stehende Initialisierung vom Konstruktor getrennt ist.

Dieser Slither-Test (opens in a new tab) identifiziert Funktionen, die nicht aus dem Vertragscode aufgerufen werden und daher als external anstelle von public deklariert werden könnten. Die Gaskosten von external-Funktionen können niedriger sein, da sie mit Parametern in den Aufrufdaten (Calldata) versehen werden können. Funktionen, die als public deklariert sind, müssen aus dem Vertrag heraus zugänglich sein. Verträge können ihre eigenen Aufrufdaten nicht ändern, daher müssen sich die Parameter im Arbeitsspeicher (Memory) befinden. Wenn eine solche Funktion extern aufgerufen wird, ist es notwendig, die Aufrufdaten in den Arbeitsspeicher zu kopieren, was Gas kostet. In diesem Fall wird die Funktion nur einmal aufgerufen, sodass die Ineffizienz für uns keine Rolle spielt.

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

Die Funktion initialize sollte nur einmal aufgerufen werden. Wenn sich die Adresse des L1-domänenübergreifenden Messengers oder der L2-Token-Brücke ändert, erstellen wir einen neuen Proxy und eine neue Brücke, die ihn aufruft. Dies ist unwahrscheinlich, es sei denn, das gesamte System wird aktualisiert, was ein sehr seltenes Ereignis ist.

Beachten Sie, dass diese Funktion keinen Mechanismus hat, der einschränkt, wer sie aufrufen kann. Das bedeutet, dass ein Angreifer theoretisch warten könnte, bis wir den Proxy und die erste Version der Brücke bereitstellen, und dann Front-Running (opens in a new tab) betreiben könnte, um vor dem legitimen Benutzer zur Funktion initialize zu gelangen. Es gibt jedoch zwei Methoden, um dies zu verhindern:

  1. Wenn die Verträge nicht direkt von einem EOA bereitgestellt werden, sondern in einer Transaktion, in der ein anderer Vertrag sie erstellt (opens in a new tab), kann der gesamte Prozess atomar sein und abgeschlossen werden, bevor eine andere Transaktion ausgeführt wird.
  2. Wenn der legitime Aufruf von initialize fehlschlägt, ist es immer möglich, den neu erstellten Proxy und die Brücke zu ignorieren und neue zu erstellen.
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

Dies sind die beiden Parameter, die die Brücke kennen muss.

Dies ist der Grund, warum wir die Address-Dienstprogramme von OpenZeppelin benötigten.

Diese Funktion existiert zu Testzwecken. Beachten Sie, dass sie nicht in den Schnittstellendefinitionen erscheint – sie ist nicht für den normalen Gebrauch bestimmt.

Diese beiden Funktionen sind Wrapper um _initiateETHDeposit, die Funktion, die die eigentliche ETH-Einzahlung verarbeitet.

Die Art und Weise, wie domänenübergreifende Nachrichten funktionieren, besteht darin, dass der Zielvertrag mit der Nachricht als seinen Aufrufdaten aufgerufen wird. Solidity-Verträge interpretieren ihre Aufrufdaten immer in Übereinstimmung mit den ABI-Spezifikationen (opens in a new tab). Die Solidity-Funktion abi.encodeWithSelector (opens in a new tab) erstellt diese Aufrufdaten.

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

Die Nachricht hier besteht darin, die Funktion finalizeDeposit (opens in a new tab) mit diesen Parametern aufzurufen:

ParameterWertBedeutung
_l1Tokenaddress(0)Spezieller Wert, der für ETH (das kein ERC-20-Token ist) auf L1 steht
_l2TokenLib_PredeployAddresses.OVM_ETHDer L2-Vertrag, der ETH auf Optimism verwaltet, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (dieser Vertrag ist nur für die interne Verwendung durch Optimism bestimmt)
_from_fromDie Adresse auf L1, die die ETH sendet
_to_toDie Adresse auf L2, die die ETH empfängt
amountmsg.valueMenge der gesendeten Wei (die bereits an die Brücke gesendet wurden)
_data_dataZusätzliche Daten, die an die Einzahlung angehängt werden sollen
        // Aufrufdaten an L2 senden
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

Senden Sie die Nachricht über den domänenübergreifenden Messenger.

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

Geben Sie ein Ereignis aus, um jede Dezentrale Anwendung (Dapp), die zuhört, über diesen Transfer zu informieren.

Diese beiden Funktionen sind Wrapper um _initiateERC20Deposit, die Funktion, die die eigentliche ERC-20-Einzahlung verarbeitet.

Diese Funktion ähnelt _initiateETHDeposit oben, mit einigen wichtigen Unterschieden. Der erste Unterschied besteht darin, dass diese Funktion die Token-Adressen und den zu transferierenden Betrag als Parameter erhält. Im Falle von ETH beinhaltet der Aufruf der Brücke bereits den Transfer des Vermögenswerts auf das Brückenkonto (msg.value).

        // Wenn eine Einzahlung auf L1 initiiert wird, transferiert die L1-Brücke das Guthaben an sich selbst für zukünftige
        // Abhebungen. safeTransferFrom prüft auch, ob der Vertrag Code hat, daher schlägt dies fehl, wenn
        // _from ein EOA oder address(0) ist.
        // slither-disable-next-line reentrancy-events, reentrancy-benign
        IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);

ERC-20-Token-Transfers folgen einem anderen Prozess als ETH:

  1. Der Benutzer (_from) gibt der Brücke einen Freigabebetrag, um die entsprechenden Token zu transferieren.
  2. Der Benutzer ruft die Brücke mit der Adresse des Token-Vertrags, dem Betrag usw. auf.
  3. Die Brücke transferiert die Token (an sich selbst) als Teil des Einzahlungsprozesses.

Der erste Schritt kann in einer separaten Transaktion von den letzten beiden erfolgen. Front-Running ist jedoch kein Problem, da die beiden Funktionen, die _initiateERC20Deposit aufrufen (depositERC20 und depositERC20To), diese Funktion nur mit msg.sender als Parameter _from aufrufen.

Fügen Sie den eingezahlten Betrag an Token zur Datenstruktur deposits hinzu. Es könnte mehrere Adressen auf L2 geben, die demselben L1-ERC-20-Token entsprechen. Daher reicht es nicht aus, den Saldo der Brücke an L1-ERC-20-Token zu verwenden, um Einzahlungen zu verfolgen.

Die L2-Brücke sendet eine Nachricht an den L2-domänenübergreifenden Messenger, was dazu führt, dass der L1-domänenübergreifende Messenger diese Funktion aufruft (natürlich erst, sobald die Transaktion, die die Nachricht finalisiert (opens in a new tab), auf L1 übermittelt wurde).

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

Stellen Sie sicher, dass dies eine legitime Nachricht ist, die vom domänenübergreifenden Messenger stammt und ihren Ursprung in der L2-Token-Brücke hat. Diese Funktion wird verwendet, um ETH von der Brücke abzuheben, daher müssen wir sicherstellen, dass sie nur vom autorisierten Aufrufer aufgerufen wird.

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

Der Weg, ETH zu transferieren, besteht darin, den Empfänger mit der Menge an Wei in msg.value aufzurufen.

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

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

Geben Sie ein Ereignis über die Abhebung aus.

Diese Funktion ähnelt finalizeETHWithdrawal oben, mit den notwendigen Änderungen für ERC-20-Token.

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

Aktualisieren Sie die Datenstruktur deposits.

Es gab eine frühere Implementierung der Brücke. Als wir von dieser Implementierung zu dieser hier wechselten, mussten wir alle Vermögenswerte verschieben. ERC-20-Token können einfach verschoben werden. Um jedoch ETH an einen Vertrag zu transferieren, benötigen Sie die Genehmigung dieses Vertrags, was uns donateETH bietet.

ERC-20-Token auf L2

Damit ein ERC-20-Token in die Standard-Brücke passt, muss es der Standard-Brücke und nur der Standard-Brücke erlauben, Token zu prägen. Dies ist notwendig, da die Brücken sicherstellen müssen, dass die Anzahl der auf Optimism zirkulierenden Token der Anzahl der im L1-Brücken-Vertrag gesperrten Token entspricht. Wenn es zu viele Token auf L2 gibt, könnten einige Benutzer ihre Vermögenswerte nicht über die Brücke zurück zu L1 transferieren. Anstelle einer vertrauenswürdigen Brücke würden wir im Wesentlichen das Mindestreservesystem (Fractional Reserve Banking) (opens in a new tab) nachbilden. Wenn es zu viele Token auf L1 gibt, würden einige dieser Token für immer im Brücken-Vertrag gesperrt bleiben, da es keine Möglichkeit gibt, sie freizugeben, ohne L2-Token zu verbrennen.

IL2StandardERC20

Jedes ERC-20-Token auf L2, das die Standard-Brücke verwendet, muss diese Schnittstelle (opens in a new tab) bereitstellen, die über die Funktionen und Ereignisse verfügt, die die Standard-Brücke benötigt.

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

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

Die Standard-ERC-20-Schnittstelle (opens in a new tab) enthält die Funktionen mint und burn nicht. Diese Methoden werden vom ERC-20-Standard (opens in a new tab) nicht verlangt, der die Mechanismen zum Erstellen und Zerstören von Token unspezifiziert lässt.

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

Die ERC-165-Schnittstelle (opens in a new tab) wird verwendet, um anzugeben, welche Funktionen ein Vertrag bereitstellt. Sie können den Standard hier lesen (opens in a new tab).

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

Diese Funktion stellt die Adresse des L1-Tokens bereit, das über die Brücke zu diesem Vertrag transferiert wird. Beachten Sie, dass wir keine ähnliche Funktion in die entgegengesetzte Richtung haben. Wir müssen in der Lage sein, jedes L1-Token über die Brücke zu transferieren, unabhängig davon, ob bei seiner Implementierung L2-Unterstützung geplant war oder nicht.


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

Funktionen und Ereignisse zum Prägen (Erstellen) und Verbrennen (Zerstören) von Token. Die Brücke sollte die einzige Entität sein, die diese Funktionen ausführen kann, um sicherzustellen, dass die Anzahl der Token korrekt ist (gleich der Anzahl der auf L1 gesperrten Token).

L2StandardERC20

Dies ist unsere Implementierung der Schnittstelle IL2StandardERC20 (opens in a new tab). Sofern Sie keine benutzerdefinierte Logik benötigen, sollten Sie diese verwenden.

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

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

Der ERC-20-Vertrag von OpenZeppelin (opens in a new tab). Optimism glaubt nicht daran, das Rad neu zu erfinden, insbesondere wenn das Rad gut geprüft ist und vertrauenswürdig genug sein muss, um Vermögenswerte zu halten.

import "./IL2StandardERC20.sol";

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

Dies sind die beiden zusätzlichen Konfigurationsparameter, die wir benötigen und die ERC-20 normalerweise nicht benötigt.

Rufen Sie zuerst den Konstruktor für den Vertrag auf, von dem wir erben (ERC20(_name, _symbol)), und legen Sie dann unsere eigenen Variablen fest.

Auf diese Weise funktioniert ERC-165 (opens in a new tab). Jede Schnittstelle besteht aus einer Reihe unterstützter Funktionen und wird als Exklusiv-Oder (XOR) (opens in a new tab) der ABI-Funktionsselektoren (opens in a new tab) dieser Funktionen identifiziert.

Die L2-Brücke verwendet ERC-165 als Plausibilitätsprüfung (Sanity Check), um sicherzustellen, dass der ERC-20-Vertrag, an den sie Vermögenswerte sendet, ein IL2StandardERC20 ist.

Hinweis: Es gibt nichts, was einen bösartigen Vertrag daran hindert, falsche Antworten auf supportsInterface zu geben. Dies ist also ein Plausibilitätsprüfungsmechanismus, kein Sicherheitsmechanismus.

Nur die L2-Brücke darf Vermögenswerte prägen und verbrennen.

_mint und _burn sind tatsächlich im ERC-20-Vertrag von OpenZeppelin definiert. Dieser Vertrag macht sie nur nicht extern zugänglich, da die Bedingungen zum Prägen und Verbrennen von Token so vielfältig sind wie die Anzahl der Möglichkeiten, ERC-20 zu verwenden.

L2-Brücken-Code

Dies ist der Code, der die Brücke auf Optimism ausführt. Die Quelle für diesen Vertrag befindet sich hier (opens in a new tab).

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

/* Interface-Importe */
import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";
import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";

Die Schnittstelle IL2ERC20Bridge (opens in a new tab) ist dem L1-Äquivalent, das wir oben gesehen haben, sehr ähnlich. Es gibt zwei wesentliche Unterschiede:

  1. Auf L1 initiieren Sie Einzahlungen und finalisieren Abhebungen. Hier initiieren Sie Abhebungen und finalisieren Einzahlungen.
  2. Auf L1 ist es notwendig, zwischen ETH und ERC-20-Token zu unterscheiden. Auf L2 können wir für beides dieselben Funktionen verwenden, da ETH-Salden auf Optimism intern als ERC-20-Token mit der Adresse 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab) behandelt werden.

Behalten Sie die Adresse der L1-Brücke im Auge. Beachten Sie, dass wir im Gegensatz zum L1-Äquivalent diese Variable hier benötigen. Die Adresse der L1-Brücke ist im Voraus nicht bekannt.

Diese beiden Funktionen initiieren Abhebungen. Beachten Sie, dass die L1-Token-Adresse nicht angegeben werden muss. Es wird erwartet, dass L2-Token uns die Adresse des L1-Äquivalents mitteilen.

Beachten Sie, dass wir uns nicht auf den Parameter _from verlassen, sondern auf msg.sender, was viel schwerer zu fälschen ist (soweit ich weiß, unmöglich).


        // Aufrufdaten für l1TokenBridge.finalizeERC20Withdrawal(_to, _amount) erstellen
        // slither-disable-next-line reentrancy-events
        address l1Token = IL2StandardERC20(_l2Token).l1Token();
        bytes memory message;

        if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {

Auf L1 ist es notwendig, zwischen ETH und ERC-20 zu unterscheiden.

Diese Funktion wird von L1StandardBridge aufgerufen.

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

Stellen Sie sicher, dass die Quelle der Nachricht legitim ist. Dies ist wichtig, da diese Funktion _mint aufruft und verwendet werden könnte, um Token auszugeben, die nicht durch Token gedeckt sind, die die Brücke auf L1 besitzt.

        // Prüfen, ob das Ziel-Token konform ist und
        // verifizieren, dass das eingezahlte Token auf L1 mit der hier eingezahlten L2-Token-Repräsentation übereinstimmt
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()

Plausibilitätsprüfungen:

  1. Die richtige Schnittstelle wird unterstützt.
  2. Die L1-Adresse des L2-ERC-20-Vertrags stimmt mit der L1-Quelle der Token überein.
        ) {
            // Wenn eine Einzahlung abgeschlossen ist, schreiben wir dem Konto auf L2 den gleichen Betrag an
            // Token gut.
            // 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);

Wenn die Plausibilitätsprüfungen erfolgreich sind, finalisieren Sie die Einzahlung:

  1. Prägen Sie die Token.
  2. Geben Sie das entsprechende Ereignis aus.

Wenn ein Benutzer einen erkennbaren Fehler gemacht hat, indem er die falsche L2-Token-Adresse verwendet hat, möchten wir die Einzahlung stornieren und die Token auf L1 zurückgeben. Die einzige Möglichkeit, dies von L2 aus zu tun, besteht darin, eine Nachricht zu senden, die die Fehleranfechtungsfrist abwarten muss. Das ist für den Benutzer jedoch viel besser, als die Token dauerhaft zu verlieren.

Fazit

Die Standard-Brücke ist der flexibelste Mechanismus für den Transfer von Vermögenswerten. Da sie jedoch so generisch ist, ist sie nicht immer der am einfachsten zu verwendende Mechanismus. Insbesondere für Abhebungen ziehen es die meisten Benutzer vor, Brücken von Drittanbietern (opens in a new tab) zu verwenden, die nicht die Anfechtungsfrist abwarten und keinen Merkle-Nachweis erfordern, um die Abhebung zu finalisieren.

Diese Brücken funktionieren typischerweise so, dass sie über Vermögenswerte auf L1 verfügen, die sie gegen eine geringe Gebühr (oft weniger als die Gaskosten für eine Standard-Brücken-Abhebung) sofort bereitstellen. Wenn die Brücke (oder die Personen, die sie betreiben) voraussieht, dass L1-Vermögenswerte knapp werden, transferiert sie ausreichend Vermögenswerte von L2. Da es sich hierbei um sehr große Abhebungen handelt, amortisieren sich die Abhebungskosten über einen großen Betrag und machen einen viel geringeren Prozentsatz aus.

Hoffentlich hat Ihnen dieser Artikel geholfen, besser zu verstehen, wie Layer 2 funktioniert und wie man Solidity-Code schreibt, der klar und sicher ist.

Weitere meiner Arbeiten finden Sie hier (opens in a new tab).