Zum Hauptinhalt springen

Walkthrough des Optimism-Standardvertrags für kettenübergreifende Brücken

Solidity
kettenübergreifende Brücke
Ebene 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 Ebene 1 oder L1) verarbeiten, da Transaktionen nur von wenigen Blockchain-Knoten anstatt von jedem Blockchain-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 einer anderen Ebene 2) zu verwenden, müssen die Vermögenswerte über eine kettenübergreifende Brücke übertragen 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 eine kettenübergreifende Brücke zurück zu L1 übertragen. Dabei werden die Vermögenswerte auf L2 verbrannt und dann auf L1 wieder an den Benutzer freigegeben.

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

Kontrollflüsse

Die kettenübergreifende Brücke hat zwei Hauptflüsse:

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

Einzahlungsfluss

Ebene 1

  1. Bei der Einzahlung eines ERC-20-Tokens erteilt der Einzahler der kettenübergreifenden Brücke die Erlaubnis (Allowance), den einzuzahlenden Betrag auszugeben.
  2. Der Einzahler ruft die kettenübergreifende L1-Brücke auf (depositERC20, depositERC20To, depositETH oder depositETHTo).
  3. Die kettenübergreifende L1-Brücke übernimmt den Besitz des überbrückten Vermögenswerts.
    • ETH: Der Vermögenswert wird vom Einzahler als Teil des Aufrufs übertragen.
    • ERC-20: Der Vermögenswert wird von der kettenübergreifenden Brücke unter Verwendung der vom Einzahler bereitgestellten Erlaubnis an sich selbst übertragen.
  4. Die kettenübergreifende L1-Brücke verwendet den Cross-Domain-Nachrichtenmechanismus, um finalizeDeposit auf der kettenübergreifenden L2-Brücke aufzurufen.

Ebene 2

  1. Die kettenübergreifende L2-Brücke überprüft, ob der Aufruf von finalizeDeposit legitim ist:
    • Kam vom Cross-Domain-Nachrichtenvertrag.
    • Stammte ursprünglich von der kettenübergreifenden Brücke auf L1.
  2. Die kettenübergreifende 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 an die entsprechende Adresse zu prägen. Wenn nicht, wird ein Auszahlungsprozess gestartet, damit der Benutzer die Token auf L1 beanspruchen kann.

Auszahlungsfluss

Ebene 2

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

Ebene 1

  1. Die kettenübergreifende L1-Brücke überprüft, ob der Aufruf von finalizeETHWithdrawal oder finalizeERC20Withdrawal legitim ist:
    • Kam vom Cross-Domain-Nachrichtenmechanismus.
    • Stammte ursprünglich von der kettenübergreifenden Brücke auf L2.
  2. Die kettenübergreifende L1-Brücke überträgt den entsprechenden Vermögenswert (ETH oder ERC-20) an die entsprechende Adresse.

Ebene-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 die Überbrückung von ERC-20-Token erforderlich sind.

// SPDX-License-Identifier: MIT

Der Großteil des Codes von Optimism ist 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 zur Veröffentlichung der Version 0.9.0 wissen wir nicht, ob dieser Code damit kompatibel ist oder nicht.

In der Terminologie der kettenübergreifenden Brücke von Optimism bedeutet Einzahlung (deposit) eine Übertragung von L1 zu L2 und Auszahlung (withdrawal) eine Übertragung 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 entsprechenden 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-Testnet (42) und das Optimistic Kovan-Testnet (69).

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

Es ist möglich, Übertragungen 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 Vertrag für die kettenübergreifende Brücke verarbeitet Übertragungen in beide Richtungen. Im Falle der kettenübergreifenden L1-Brücke bedeutet dies die Initialisierung von Einzahlungen und die Finalisierung von Auszahlungen.

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 mit der kettenübergreifenden L2-Brücke vorhanden, da die Adresse der kettenübergreifenden 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). Solange der ERC-20-Vertrag beim Prägen also nichts wirklich Seltsames tut, sollte dies kein Problem sein. Diese Funktion kümmert sich um das häufige Szenario, bei dem ein Benutzer Vermögenswerte über eine kettenübergreifende Brücke an dieselbe Adresse auf einer anderen Blockchain überträgt.

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

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

  1. Eine initiierende Transaktion auf L2.
  2. Eine abschließende 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 abgelaufen ist.

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 Schnittstelle der kettenübergreifenden Brücke ist auf zwei Dateien aufgeteilt, da einige ERC-20-Token eine benutzerdefinierte Verarbeitung erfordern und nicht von der kettenübergreifenden Standardbrücke verarbeitet werden können. Auf diese Weise kann die benutzerdefinierte kettenübergreifende Brücke, die einen solchen Token verarbeitet, IL1ERC20Bridge implementieren und muss nicht auch ETH überbrücken.

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

CrossDomainEnabled

Dieser Vertrag (opens in a new tab) wird von beiden kettenübergreifenden Brücken (L1 und L2) geerbt, um Nachrichten an die andere Ebene zu senden.

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

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

Diese Schnittstelle (opens in a new tab) teilt dem Vertrag mit, wie Nachrichten unter Verwendung des Cross-Domain-Messengers an die andere Ebene gesendet werden. Dieser Cross-Domain-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 Cross-Domain-Messengers auf dieser Ebene. Dieser Parameter wird einmal im Konstruktor festgelegt und ändert sich nie.

Das Cross-Domain-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 kettenübergreifende Brücke auf jeder Seite nur bestimmten Nachrichten vertraut, wenn sie von der kettenübergreifenden Brücke auf der anderen Seite stammen.

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

Nur Nachrichten vom entsprechenden Cross-Domain-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 Cross-Domain-Messenger die Adresse bereitstellt, die eine Nachricht an die andere Ebene 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 kettenübergreifenden Brücke stammt.

Diese Funktion gibt den Cross-Domain-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, mit dem angegeben wird, welcher Cross-Domain-Messenger verwendet werden soll.

Schließlich die Funktion, die eine Nachricht an die andere Ebene 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. Reentrancy-Ereignisse (opens in a new tab)
  2. Gutartige Reentrancy (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

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

Der L1-Brückenvertrag

Der Quellcode für diesen Vertrag ist hier zu finden (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 kettenübergreifende Brücke selbst ist unser Vertrag, und wir können streng sein, welche Solidity-Version sie verwendet.

/* Schnittstellen-Importe */
/* Interface Imports */
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 kettenübergreifenden Standardbrü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. Hier können Sie mehr darüber lesen.

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

Wie oben erklärt, wird dieser Vertrag für das Messaging zwischen den Ebenen 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 kettenübergreifende Standardbrücke auf L2 ein.

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

OpenZeppelins Address-Dienstprogramme (opens in a new tab). Sie werden verwendet, um zwischen Vertragsadressen und solchen zu unterscheiden, die zu extern verwalteten 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. Revert (Rückgängig machen)
  2. Rückgabe von false

Die Behandlung beider Fälle würde unseren Code komplizierter machen. Stattdessen verwenden wir OpenZeppelins SafeERC20 (opens in a new tab), was sicherstellt, dass alle Fehler zu einem Revert führen (opens in a new tab).

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

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 spärliches Array (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.

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-Vertrag 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 Vertragszustandsvariablen 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 anstatt als public deklariert werden könnten. Die Gaskosten von external-Funktionen können niedriger sein, da sie mit Parametern in den 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 Calldata nicht ändern, daher müssen sich die Parameter im Arbeitsspeicher (Memory) befinden. Wenn eine solche Funktion extern aufgerufen wird, ist es notwendig, die Calldata 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-Cross-Domain-Messengers oder der kettenübergreifenden L2-Token-Brücke ändert, erstellen wir einen neuen Proxy und eine neue kettenübergreifende Brücke, die ihn aufruft. Dies ist unwahrscheinlich, außer wenn das gesamte System aktualisiert wird, was ein sehr seltenes Ereignis ist.

Beachten Sie, dass diese Funktion keinen Mechanismus hat, der einschränkt, wer sie aufrufen kann. Dies bedeutet, dass ein Angreifer theoretisch warten könnte, bis wir den Proxy und die erste Version der kettenübergreifenden 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 (Extern verwaltetes Konto), sondern in einer Transaktion bereitgestellt werden, bei 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 kettenübergreifende Brücke zu ignorieren und neue zu erstellen.
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

Dies sind die beiden Parameter, die die kettenübergreifende 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 Cross-Domain-Nachrichten funktionieren, besteht darin, dass der Zielvertrag mit der Nachricht als seinen Calldata aufgerufen wird. Solidity-Verträge interpretieren ihre Calldata immer in Übereinstimmung mit den ABI-Spezifikationen (opens in a new tab). Die Solidity-Funktion abi.encodeWithSelector (opens in a new tab) erstellt diese Calldata.

            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:

ParameterValueMeaning
_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 Nutzung 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 kettenübergreifende Brücke gesendet wurden)
_data_dataZusätzliche Daten, die der Einzahlung beigefügt werden sollen
        // Sendet Calldata an L2
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

Senden Sie die Nachricht über den Cross-Domain-Messenger.

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

Geben Sie ein Ereignis aus, um jede dezentralisierte Anwendung, die zuhört, über diese Übertragung 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 übertragenden Betrag als Parameter erhält. Im Falle von ETH beinhaltet der Aufruf der kettenübergreifenden Brücke bereits die Übertragung des Vermögenswerts auf das Brückenkonto (msg.value).

        // Wenn eine Einzahlung auf L1 initiiert wird, überträgt die L1-kettenübergreifende Brücke die 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-Übertragungen folgen einem anderen Prozess als ETH:

  1. Der Benutzer (_from) erteilt der kettenübergreifenden Brücke die Erlaubnis (Allowance), die entsprechenden Token zu übertragen.
  2. Der Benutzer ruft die kettenübergreifende Brücke mit der Adresse des Token-Vertrags, dem Betrag usw. auf.
  3. Die kettenübergreifende Brücke überträgt 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 Token-Betrag 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 kettenübergreifenden Brücke des L1-ERC-20-Tokens zu verwenden, um Einzahlungen zu verfolgen.

Die kettenübergreifende L2-Brücke sendet eine Nachricht an den L2-Cross-Domain-Messenger, was dazu führt, dass der L1-Cross-Domain-Messenger diese Funktion aufruft (natürlich erst, sobald die Transaktion, die die Nachricht abschließt (opens in a new tab), auf L1 übermittelt wurde).

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

Stellen Sie sicher, dass dies eine legitime Nachricht ist, die vom Cross-Domain-Messenger stammt und von der kettenübergreifenden L2-Token-Brücke ausgeht. Diese Funktion wird verwendet, um ETH von der kettenübergreifenden 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 zur Übertragung von ETH 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 Auszahlung 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 kettenübergreifenden Brücke. Als wir von dieser Implementierung zu dieser wechselten, mussten wir alle Vermögenswerte verschieben. ERC-20-Token können einfach verschoben werden. Um jedoch ETH an einen Vertrag zu übertragen, benötigen Sie die Genehmigung dieses Vertrags, was uns donateETH bietet.

ERC-20-Token auf L2

Damit ein ERC-20-Token in die kettenübergreifende Standardbrücke passt, muss er es der kettenübergreifenden Standardbrücke und nur der kettenübergreifenden Standardbrücke erlauben, Token zu prägen. Dies ist notwendig, da die kettenübergreifenden Brücken sicherstellen müssen, dass die Anzahl der auf Optimism zirkulierenden Token der Anzahl der im Vertrag der kettenübergreifenden L1-Brücke gesperrten Token entspricht. Wenn es zu viele Token auf L2 gibt, könnten einige Benutzer ihre Vermögenswerte nicht über die kettenübergreifende Brücke zurück zu L1 übertragen. Anstelle einer vertrauenswürdigen kettenübergreifenden 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ückenvertrag gesperrt bleiben, da es keine Möglichkeit gibt, sie freizugeben, ohne L2-Token zu verbrennen.

IL2StandardERC20

Jeder ERC-20-Token auf L2, der die kettenübergreifende Standardbrücke verwendet, muss diese Schnittstelle (opens in a new tab) bereitstellen, die über die Funktionen und Ereignisse verfügt, die die kettenübergreifende Standardbrü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 nicht die Funktionen mint und burn. 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 liefert die Adresse des L1-Tokens, der über eine kettenübergreifende Brücke mit diesem Vertrag verbunden ist. Beachten Sie, dass wir keine ähnliche Funktion in der entgegengesetzten Richtung haben. Wir müssen in der Lage sein, jeden L1-Token über eine kettenübergreifende Brücke zu übertragen, 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 kettenübergreifende 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 OpenZeppelin-ERC-20-Vertrag (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.

So funktioniert ERC-165 (opens in a new tab). Jede Schnittstelle ist eine Anzahl unterstützter Funktionen und wird als das exklusive Oder (XOR) (opens in a new tab) der ABI-Funktionsselektoren (opens in a new tab) dieser Funktionen identifiziert.

Die kettenübergreifende 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 kettenübergreifende L2-Brücke darf Vermögenswerte prägen und verbrennen.

_mint und _burn sind tatsächlich im OpenZeppelin-ERC-20-Vertrag definiert. Dieser Vertrag macht sie nur nicht nach außen hin 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.

Code der kettenübergreifenden L2-Brücke

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

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

/* Schnittstellen-Importe */
/* Interface Imports */
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 schließen Auszahlungen ab. Hier initiieren Sie Auszahlungen und schließen Einzahlungen ab.
  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-Guthaben auf Optimism intern als ERC-20-Token mit der Adresse 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab) behandelt werden.

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

Diese beiden Funktionen initiieren Auszahlungen. 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 (unmöglich, soweit ich weiß).


        // Erstellt Calldata für l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)
        // 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 kettenübergreifende Brücke auf L1 besitzt.

        // Prüft, ob der Ziel-Token konform ist und
        // verifiziert, dass der 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 (Sanity Checks):

  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, schließen Sie die Einzahlung ab:

  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. Dies ist jedoch für den Benutzer viel besser, als die Token dauerhaft zu verlieren.

Fazit

Die kettenübergreifende Standardbrücke ist der flexibelste Mechanismus für Vermögensübertragungen. Da sie jedoch so generisch ist, ist sie nicht immer der am einfachsten zu verwendende Mechanismus. Insbesondere für Auszahlungen ziehen es die meisten Benutzer vor, kettenübergreifende Brücken von Drittanbietern (opens in a new tab) zu verwenden, die nicht auf die Anfechtungsfrist warten und keinen Merkle-Proof benötigen, um die Auszahlung abzuschließen.

Diese kettenübergreifenden Brücken funktionieren in der Regel 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 Auszahlung über eine kettenübergreifende Standardbrücke) sofort bereitstellen. Wenn die kettenübergreifende Brücke (oder die Personen, die sie betreiben) voraussieht, dass L1-Vermögenswerte knapp werden, überträgt sie ausreichende Vermögenswerte von L2. Da es sich um sehr große Auszahlungen handelt, werden die Auszahlungskosten über einen großen Betrag amortisiert und machen einen viel geringeren Prozentsatz aus.

Hoffentlich hat Ihnen dieser Artikel geholfen, mehr darüber zu verstehen, wie Ebene 2 funktioniert und wie man Solidity-Code schreibt, der klar und sicher ist.

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

Letzte Aktualisierung der Seite: 3. April 2026

War dieses Tutorial hilfreich?