Vai al contenuto principale

Panoramica del contratto del ponte standard di Optimism

Solidity
ponte
livello 2
Intermedio
Ori Pomerantz
30 marzo 2022
34 minuti di lettura

Optimism (opens in a new tab) è un rollup ottimistico. I rollup ottimistici possono elaborare le transazioni a un prezzo molto inferiore rispetto alla rete principale di Ethereum (nota anche come livello 1 o L1) perché le transazioni vengono elaborate solo da pochi nodi, invece che da ogni nodo della rete. Allo stesso tempo, i dati vengono tutti scritti su L1, in modo che tutto possa essere provato e ricostruito con tutte le garanzie di integrità e disponibilità della rete principale.

Per utilizzare gli asset di L1 su Optimism (o su qualsiasi altro L2), gli asset devono essere trasferiti tramite un ponte. Un modo per ottenere questo risultato è che gli utenti blocchino gli asset (ETH e i token ERC-20 sono i più comuni) su L1 e ricevano asset equivalenti da utilizzare su L2. Alla fine, chiunque ne entri in possesso potrebbe volerli riportare su L1 tramite il ponte. Quando si fa questo, gli asset vengono bruciati su L2 e poi rilasciati nuovamente all'utente su L1.

Questo è il modo in cui funziona il ponte standard di Optimism (opens in a new tab). In questo articolo esaminiamo il codice sorgente di quel ponte per vedere come funziona e studiarlo come esempio di codice Solidity ben scritto.

Flussi di controllo

Il ponte ha due flussi principali:

  • Deposito (da L1 a L2)
  • Prelievo (da L2 a L1)

Flusso di deposito

Livello 1

  1. Se si deposita un ERC-20, il depositante concede al ponte un'indennità (allowance) per spendere l'importo depositato
  2. Il depositante chiama il ponte di L1 (depositERC20, depositERC20To, depositETH o depositETHTo)
  3. Il ponte di L1 prende possesso dell'asset trasferito
    • ETH: L'asset viene trasferito dal depositante come parte della chiamata
    • ERC-20: L'asset viene trasferito dal ponte a se stesso utilizzando l'indennità fornita dal depositante
  4. Il ponte di L1 utilizza il meccanismo di messaggistica tra domini per chiamare finalizeDeposit sul ponte di L2

Livello 2

  1. Il ponte di L2 verifica che la chiamata a finalizeDeposit sia legittima:
    • Proviene dal contratto di messaggistica tra domini
    • Proviene originariamente dal ponte su L1
  2. Il ponte di L2 controlla se il contratto del token ERC-20 su L2 è quello corretto:
    • Il contratto di L2 segnala che la sua controparte di L1 è la stessa da cui provengono i token su L1
    • Il contratto di L2 segnala che supporta l'interfaccia corretta (utilizzando ERC-165 (opens in a new tab)).
  3. Se il contratto di L2 è quello corretto, lo chiama per coniare il numero appropriato di token all'indirizzo appropriato. In caso contrario, avvia un processo di prelievo per consentire all'utente di reclamare i token su L1.

Flusso di prelievo

Livello 2

  1. Chi preleva chiama il ponte di L2 (withdraw o withdrawTo)
  2. Il ponte di L2 brucia il numero appropriato di token appartenenti a msg.sender
  3. Il ponte di L2 utilizza il meccanismo di messaggistica tra domini per chiamare finalizeETHWithdrawal o finalizeERC20Withdrawal sul ponte di L1

Livello 1

  1. Il ponte di L1 verifica che la chiamata a finalizeETHWithdrawal o finalizeERC20Withdrawal sia legittima:
    • Proviene dal meccanismo di messaggistica tra domini
    • Proviene originariamente dal ponte su L2
  2. Il ponte di L1 trasferisce l'asset appropriato (ETH o ERC-20) all'indirizzo appropriato

Codice di Livello 1

Questo è il codice che viene eseguito su L1, la rete principale di Ethereum.

IL1ERC20Bridge

Questa interfaccia è definita qui (opens in a new tab). Include funzioni e definizioni necessarie per trasferire tramite ponte i token ERC-20.

// SPDX-License-Identifier: MIT

La maggior parte del codice di Optimism è rilasciata sotto licenza MIT (opens in a new tab).

pragma solidity >0.5.0 <0.9.0;

Al momento della stesura, l'ultima versione di Solidity è la 0.8.12. Fino al rilascio della versione 0.9.0, non sappiamo se questo codice sarà compatibile o meno.

Nella terminologia del ponte di Optimism, deposito significa trasferimento da L1 a L2, e prelievo significa un trasferimento da L2 a L1.

        address indexed _l1Token,
        address indexed _l2Token,

Nella maggior parte dei casi l'indirizzo di un ERC-20 su L1 non è lo stesso dell'indirizzo dell'ERC-20 equivalente su L2. Puoi vedere l'elenco degli indirizzi dei token qui (opens in a new tab). L'indirizzo con chainId 1 è su L1 (rete principale) e l'indirizzo con chainId 10 è su L2 (Optimism). Gli altri due valori di chainId sono per la rete di test Kovan (42) e la rete di test Optimistic Kovan (69).

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

È possibile aggiungere note ai trasferimenti, nel qual caso vengono aggiunte agli eventi che li segnalano.

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

Lo stesso contratto del ponte gestisce i trasferimenti in entrambe le direzioni. Nel caso del ponte di L1, questo significa l'inizializzazione dei depositi e la finalizzazione dei prelievi.

Questa funzione non è realmente necessaria, perché su L2 è un contratto pre-distribuito, quindi si trova sempre all'indirizzo 0x4200000000000000000000000000000000000010. È qui per simmetria con il ponte di L2, perché l'indirizzo del ponte di L1 non è banale da conoscere.

Il parametro _l2Gas è la quantità di gas di L2 che la transazione è autorizzata a spendere. Fino a un certo limite (elevato), questo è gratuito (opens in a new tab), quindi a meno che il contratto ERC-20 non faccia qualcosa di veramente strano durante il conio, non dovrebbe essere un problema. Questa funzione si occupa dello scenario comune, in cui un utente trasferisce tramite ponte gli asset allo stesso indirizzo su una blockchain diversa.

Questa funzione è quasi identica a depositERC20, ma ti consente di inviare l'ERC-20 a un indirizzo diverso.

I prelievi (e altri messaggi da L2 a L1) in Optimism sono un processo in due fasi:

  1. Una transazione di avvio su L2.
  2. Una transazione di finalizzazione o reclamo su L1. Questa transazione deve avvenire dopo la fine del periodo di contestazione dei guasti (opens in a new tab) per la transazione di L2.

IL1StandardBridge

Questa interfaccia è definita qui (opens in a new tab). Questo file contiene le definizioni di eventi e funzioni per gli ETH. Queste definizioni sono molto simili a quelle definite in IL1ERC20Bridge sopra per gli ERC-20.

L'interfaccia del ponte è divisa in due file perché alcuni token ERC-20 richiedono un'elaborazione personalizzata e non possono essere gestiti dal ponte standard. In questo modo il ponte personalizzato che gestisce tale token può implementare IL1ERC20Bridge e non dover trasferire anche gli ETH.

Questo evento è quasi identico alla versione ERC-20 (ERC20DepositInitiated), tranne per l'assenza degli indirizzi dei token di L1 e L2. Lo stesso vale per gli altri eventi e le funzioni.

CrossDomainEnabled

Questo contratto (opens in a new tab) viene ereditato da entrambi i ponti (L1 e L2) per inviare messaggi all'altro livello.

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

/* Importazioni di Interfacce */
/* Interface Imports */
import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";

Questa interfaccia (opens in a new tab) indica al contratto come inviare messaggi all'altro livello, utilizzando il messaggero tra domini. Questo messaggero tra domini è un sistema completamente diverso e merita un articolo a sé, che spero di scrivere in futuro.

L'unico parametro che il contratto deve conoscere è l'indirizzo del messaggero tra domini su questo livello. Questo parametro viene impostato una volta, nel costruttore, e non cambia mai.

La messaggistica tra domini è accessibile da qualsiasi contratto sulla blockchain in cui è in esecuzione (sia la rete principale di Ethereum che Optimism). Ma abbiamo bisogno che il ponte su ciascun lato si fidi solo di determinati messaggi se provengono dal ponte sull'altro lato.

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

Ci si può fidare solo dei messaggi provenienti dal messaggero tra domini appropriato (messenger, come vedi di seguito).


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

Il modo in cui il messaggero tra domini fornisce l'indirizzo che ha inviato un messaggio con l'altro livello è la funzione .xDomainMessageSender() (opens in a new tab). Finché viene chiamata nella transazione avviata dal messaggio, può fornire queste informazioni.

Dobbiamo assicurarci che il messaggio ricevuto provenga dall'altro ponte.

Questa funzione restituisce il messaggero tra domini. Utilizziamo una funzione anziché la variabile messenger per consentire ai contratti che ereditano da questo di utilizzare un algoritmo per specificare quale messaggero tra domini utilizzare.

Infine, la funzione che invia un messaggio all'altro livello.

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

Slither (opens in a new tab) è un analizzatore statico che Optimism esegue su ogni contratto per cercare vulnerabilità e altri potenziali problemi. In questo caso, la riga seguente innesca due vulnerabilità:

  1. Eventi di rientranza (opens in a new tab)
  2. Rientranza benigna (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

In questo caso non ci preoccupiamo della rientranza, sappiamo che getCrossDomainMessenger() restituisce un indirizzo affidabile, anche se Slither non ha modo di saperlo.

Il contratto del ponte di L1

Il codice sorgente per questo contratto è qui (opens in a new tab).

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

Le interfacce possono far parte di altri contratti, quindi devono supportare un'ampia gamma di versioni di Solidity. Ma il ponte stesso è il nostro contratto e possiamo essere rigorosi su quale versione di Solidity utilizza.

/* Importazioni di Interfacce */
/* Interface Imports */
import { IL1StandardBridge } from "./IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";

IL1ERC20Bridge e IL1StandardBridge sono spiegati sopra.

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

Questa interfaccia (opens in a new tab) ci consente di creare messaggi per controllare il ponte standard su L2.

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

Questa interfaccia (opens in a new tab) ci consente di controllare i contratti ERC-20. Puoi leggere di più a riguardo qui.

/* Importazioni di Librerie */
/* Library Imports */
import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";

Come spiegato sopra, questo contratto viene utilizzato per la messaggistica tra livelli.

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

Lib_PredeployAddresses (opens in a new tab) contiene gli indirizzi per i contratti di L2 che hanno sempre lo stesso indirizzo. Questo include il ponte standard su L2.

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

Le utilità Address di OpenZeppelin (opens in a new tab). Vengono utilizzate per distinguere tra gli indirizzi dei contratti e quelli appartenenti agli account controllati esternamente (EOA).

Nota che questa non è una soluzione perfetta, perché non c'è modo di distinguere tra chiamate dirette e chiamate effettuate dal costruttore di un contratto, ma almeno questo ci consente di identificare e prevenire alcuni errori comuni degli utenti.

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

Lo standard ERC-20 (opens in a new tab) supporta due modi per un contratto di segnalare un fallimento:

  1. Revert (Annullamento)
  2. Restituire false

Gestire entrambi i casi renderebbe il nostro codice più complicato, quindi utilizziamo invece SafeERC20 di OpenZeppelin (opens in a new tab), che si assicura che tutti i fallimenti si traducano in un annullamento (opens in a new tab).

Questa riga è il modo in cui specifichiamo di utilizzare il wrapper SafeERC20 ogni volta che utilizziamo l'interfaccia IERC20.

L'indirizzo di L2StandardBridge.


    // Mappa il token L1 al token L2 al saldo del token L1 depositato
    mapping(address => mapping(address => uint256)) public deposits;

Una doppia mappatura (opens in a new tab) come questa è il modo in cui si definisce un array sparso bidimensionale (opens in a new tab). I valori in questa struttura dati sono identificati come deposit[L1 token addr][L2 token addr]. Il valore predefinito è zero. Solo le celle impostate su un valore diverso vengono scritte nell'archiviazione.

Vogliamo poter aggiornare questo contratto senza dover copiare tutte le variabili nell'archiviazione. Per farlo utilizziamo un Proxy (opens in a new tab), un contratto che utilizza delegatecall (opens in a new tab) per trasferire le chiamate a un contratto separato il cui indirizzo è memorizzato dal contratto proxy (quando si esegue l'aggiornamento si dice al proxy di cambiare quell'indirizzo). Quando si utilizza delegatecall l'archiviazione rimane quella del contratto chiamante, quindi i valori di tutte le variabili di stato del contratto non vengono influenzati.

Un effetto di questo modello è che l'archiviazione del contratto che è il chiamato di delegatecall non viene utilizzata e pertanto i valori del costruttore passati ad esso non hanno importanza. Questo è il motivo per cui possiamo fornire un valore senza senso al costruttore CrossDomainEnabled. È anche il motivo per cui l'inizializzazione di seguito è separata dal costruttore.

Questo test di Slither (opens in a new tab) identifica le funzioni che non vengono chiamate dal codice del contratto e potrebbero quindi essere dichiarate external invece di public. Il costo del gas delle funzioni external può essere inferiore, perché possono essere fornite con parametri nei calldata. Le funzioni dichiarate public devono essere accessibili dall'interno del contratto. I contratti non possono modificare i propri calldata, quindi i parametri devono essere in memoria. Quando una tale funzione viene chiamata esternamente, è necessario copiare i calldata in memoria, il che costa gas. In questo caso la funzione viene chiamata solo una volta, quindi l'inefficienza non ci importa.

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

La funzione initialize dovrebbe essere chiamata solo una volta. Se l'indirizzo del messaggero tra domini di L1 o del ponte dei token di L2 cambia, creiamo un nuovo proxy e un nuovo ponte che lo chiama. È improbabile che ciò accada, tranne quando l'intero sistema viene aggiornato, un evento molto raro.

Nota che questa funzione non ha alcun meccanismo che limiti chi può chiamarla. Ciò significa che in teoria un utente malintenzionato potrebbe aspettare fino a quando non distribuiamo il proxy e la prima versione del ponte e poi eseguire un front-running (opens in a new tab) per arrivare alla funzione initialize prima dell'utente legittimo. Ma ci sono due metodi per impedirlo:

  1. Se i contratti non vengono distribuiti direttamente da un EOA ma in una transazione in cui un altro contratto li crea (opens in a new tab), l'intero processo può essere atomico e terminare prima che venga eseguita qualsiasi altra transazione.
  2. Se la chiamata legittima a initialize fallisce, è sempre possibile ignorare il proxy e il ponte appena creati e crearne di nuovi.
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

Questi sono i due parametri che il ponte deve conoscere.

Questo è il motivo per cui avevamo bisogno delle utilità Address di OpenZeppelin.

Questa funzione esiste a scopo di test. Nota che non appare nelle definizioni dell'interfaccia: non è per un uso normale.

Queste due funzioni sono wrapper attorno a _initiateETHDeposit, la funzione che gestisce l'effettivo deposito di ETH.

Il modo in cui funzionano i messaggi tra domini è che il contratto di destinazione viene chiamato con il messaggio come suoi calldata. I contratti Solidity interpretano sempre i propri calldata in conformità con le specifiche ABI (opens in a new tab). La funzione Solidity abi.encodeWithSelector (opens in a new tab) crea quei calldata.

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

Il messaggio qui è di chiamare la funzione finalizeDeposit (opens in a new tab) con questi parametri:

ParametroValoreSignificato
_l1Tokenaddress(0)Valore speciale per rappresentare gli ETH (che non sono un token ERC-20) su L1
_l2TokenLib_PredeployAddresses.OVM_ETHIl contratto di L2 che gestisce gli ETH su Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (questo contratto è solo per uso interno di Optimism)
_from_fromL'indirizzo su L1 che invia gli ETH
_to_toL'indirizzo su L2 che riceve gli ETH
amountmsg.valueQuantità di wei inviati (che sono già stati inviati al ponte)
_data_dataDati aggiuntivi da allegare al deposito
        // Invia i calldata in L2
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

Invia il messaggio tramite il messaggero tra domini.

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

Emette un evento per informare qualsiasi applicazione decentralizzata in ascolto di questo trasferimento.

Queste due funzioni sono wrapper attorno a _initiateERC20Deposit, la funzione che gestisce l'effettivo deposito di ERC-20.

Questa funzione è simile a _initiateETHDeposit sopra, con alcune importanti differenze. La prima differenza è che questa funzione riceve gli indirizzi dei token e l'importo da trasferire come parametri. Nel caso degli ETH, la chiamata al ponte include già il trasferimento dell'asset all'account del ponte (msg.value).

        // Quando un deposito viene avviato su L1, il ponte L1 trasferisce i fondi a se stesso per futuri
        // prelievi. safeTransferFrom controlla anche se il contratto ha del codice, quindi questo fallirà se
        // _from è un EOA o address(0).
        // slither-disable-next-line reentrancy-events, reentrancy-benign
        IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);

I trasferimenti di token ERC-20 seguono un processo diverso rispetto agli ETH:

  1. L'utente (_from) concede un'indennità al ponte per trasferire i token appropriati.
  2. L'utente chiama il ponte con l'indirizzo del contratto del token, l'importo, ecc.
  3. Il ponte trasferisce i token (a se stesso) come parte del processo di deposito.

Il primo passaggio può avvenire in una transazione separata rispetto agli ultimi due. Tuttavia, il front-running non è un problema perché le due funzioni che chiamano _initiateERC20Deposit (depositERC20 e depositERC20To) chiamano questa funzione solo con msg.sender come parametro _from.

Aggiunge l'importo depositato di token alla struttura dati deposits. Potrebbero esserci più indirizzi su L2 che corrispondono allo stesso token ERC-20 di L1, quindi non è sufficiente utilizzare il saldo del ponte del token ERC-20 di L1 per tenere traccia dei depositi.

Il ponte di L2 invia un messaggio al messaggero tra domini di L2 che fa sì che il messaggero tra domini di L1 chiami questa funzione (una volta che la transazione che finalizza il messaggio (opens in a new tab) viene inviata su L1, ovviamente).

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

Si assicura che questo sia un messaggio legittimo, proveniente dal messaggero tra domini e originato dal ponte dei token di L2. Questa funzione viene utilizzata per prelevare ETH dal ponte, quindi dobbiamo assicurarci che venga chiamata solo dal chiamante autorizzato.

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

Il modo per trasferire ETH è chiamare il destinatario con la quantità di wei in msg.value.

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

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

Emette un evento relativo al prelievo.

Questa funzione è simile a finalizeETHWithdrawal sopra, con le modifiche necessarie per i token ERC-20.

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

Aggiorna la struttura dati deposits.

C'era un'implementazione precedente del ponte. Quando siamo passati da quell'implementazione a questa, abbiamo dovuto spostare tutti gli asset. I token ERC-20 possono semplicemente essere spostati. Tuttavia, per trasferire ETH a un contratto è necessaria l'approvazione di quel contratto, che è ciò che ci fornisce donateETH.

Token ERC-20 su L2

Affinché un token ERC-20 si adatti al ponte standard, deve consentire al ponte standard, e solo al ponte standard, di coniare token. Questo è necessario perché i ponti devono garantire che il numero di token in circolazione su Optimism sia uguale al numero di token bloccati all'interno del contratto del ponte di L1. Se ci sono troppi token su L2, alcuni utenti non sarebbero in grado di riportare i propri asset su L1 tramite il ponte. Invece di un ponte affidabile, ricreeremmo essenzialmente il sistema bancario a riserva frazionaria (opens in a new tab). Se ci sono troppi token su L1, alcuni di quei token rimarrebbero bloccati per sempre all'interno del contratto del ponte perché non c'è modo di rilasciarli senza bruciare i token di L2.

IL2StandardERC20

Ogni token ERC-20 su L2 che utilizza il ponte standard deve fornire questa interfaccia (opens in a new tab), che ha le funzioni e gli eventi di cui il ponte standard ha bisogno.

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

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

L'interfaccia ERC-20 standard (opens in a new tab) non include le funzioni mint e burn. Questi metodi non sono richiesti dallo standard ERC-20 (opens in a new tab), che lascia non specificati i meccanismi per creare e distruggere i token.

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

L'interfaccia ERC-165 (opens in a new tab) viene utilizzata per specificare quali funzioni fornisce un contratto. Puoi leggere lo standard qui (opens in a new tab).

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

Questa funzione fornisce l'indirizzo del token di L1 che è collegato tramite ponte a questo contratto. Nota che non abbiamo una funzione simile nella direzione opposta. Dobbiamo essere in grado di trasferire tramite ponte qualsiasi token di L1, indipendentemente dal fatto che il supporto per L2 fosse previsto o meno al momento della sua implementazione.


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

Funzioni ed eventi per coniare (creare) e bruciare (distruggere) i token. Il ponte dovrebbe essere l'unica entità in grado di eseguire queste funzioni per garantire che il numero di token sia corretto (uguale al numero di token bloccati su L1).

L2StandardERC20

Questa è la nostra implementazione dell'interfaccia IL2StandardERC20 (opens in a new tab). A meno che tu non abbia bisogno di qualche tipo di logica personalizzata, dovresti usare questa.

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

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

Il contratto ERC-20 di OpenZeppelin (opens in a new tab). Optimism non crede nel reinventare la ruota, specialmente quando la ruota è ben verificata e deve essere abbastanza affidabile da contenere asset.

import "./IL2StandardERC20.sol";

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

Questi sono i due parametri di configurazione aggiuntivi che richiediamo e che l'ERC-20 normalmente non richiede.

Prima chiama il costruttore per il contratto da cui ereditiamo (ERC20(_name, _symbol)) e poi imposta le nostre variabili.

Questo è il modo in cui funziona ERC-165 (opens in a new tab). Ogni interfaccia è un numero di funzioni supportate ed è identificata come l'or esclusivo (opens in a new tab) dei selettori di funzione ABI (opens in a new tab) di quelle funzioni.

Il ponte di L2 utilizza ERC-165 come controllo di integrità per assicurarsi che il contratto ERC-20 a cui invia gli asset sia un IL2StandardERC20.

Nota: Non c'è nulla che impedisca a un contratto malevolo di fornire risposte false a supportsInterface, quindi questo è un meccanismo di controllo dell'integrità, non un meccanismo di sicurezza.

Solo il ponte di L2 è autorizzato a coniare e bruciare asset.

_mint e _burn sono in realtà definiti nel contratto ERC-20 di OpenZeppelin. Quel contratto semplicemente non li espone esternamente, perché le condizioni per coniare e bruciare token sono tanto varie quanto il numero di modi per utilizzare l'ERC-20.

Codice del ponte di L2

Questo è il codice che esegue il ponte su Optimism. Il sorgente per questo contratto è qui (opens in a new tab).

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

/* Importazioni di Interfacce */
/* Interface Imports */
import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";
import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";

L'interfaccia IL2ERC20Bridge (opens in a new tab) è molto simile all'equivalente di L1 che abbiamo visto sopra. Ci sono due differenze significative:

  1. Su L1 si avviano i depositi e si finalizzano i prelievi. Qui si avviano i prelievi e si finalizzano i depositi.
  2. Su L1 è necessario distinguere tra ETH e token ERC-20. Su L2 possiamo utilizzare le stesse funzioni per entrambi perché internamente i saldi di ETH su Optimism sono gestiti come un token ERC-20 con l'indirizzo 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab).

Tiene traccia dell'indirizzo del ponte di L1. Nota che, a differenza dell'equivalente di L1, qui abbiamo bisogno di questa variabile. L'indirizzo del ponte di L1 non è noto in anticipo.

Queste due funzioni avviano i prelievi. Nota che non è necessario specificare l'indirizzo del token di L1. Ci si aspetta che i token di L2 ci dicano l'indirizzo dell'equivalente di L1.

Nota che non facciamo affidamento sul parametro _from ma su msg.sender che è molto più difficile da falsificare (impossibile, per quanto ne so).


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

        if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {

Su L1 è necessario distinguere tra ETH ed ERC-20.

Questa funzione è chiamata da L1StandardBridge.

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

Si assicura che la fonte del messaggio sia legittima. Questo è importante perché questa funzione chiama _mint e potrebbe essere utilizzata per fornire token che non sono coperti dai token che il ponte possiede su L1.

        // Controlla che il token di destinazione sia conforme e
        // verifica che il token depositato su L1 corrisponda alla rappresentazione del token depositato su L2 qui
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()

Controlli di integrità:

  1. L'interfaccia corretta è supportata
  2. L'indirizzo di L1 del contratto ERC-20 di L2 corrisponde alla fonte di L1 dei token
        ) {
            // Quando un deposito viene finalizzato, accreditiamo sull'account su L2 la stessa quantità di
            // token.
            // 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);

Se i controlli di integrità vengono superati, finalizza il deposito:

  1. Conia i token
  2. Emette l'evento appropriato

Se un utente ha commesso un errore rilevabile utilizzando l'indirizzo del token di L2 sbagliato, vogliamo annullare il deposito e restituire i token su L1. L'unico modo in cui possiamo farlo da L2 è inviare un messaggio che dovrà attendere il periodo di contestazione dei guasti, ma questo è molto meglio per l'utente rispetto alla perdita permanente dei token.

Conclusione

Il ponte standard è il meccanismo più flessibile per i trasferimenti di asset. Tuttavia, essendo così generico, non è sempre il meccanismo più facile da usare. Soprattutto per i prelievi, la maggior parte degli utenti preferisce utilizzare ponti di terze parti (opens in a new tab) che non attendono il periodo di contestazione e non richiedono una prova di Merkle per finalizzare il prelievo.

Questi ponti in genere funzionano avendo asset su L1, che forniscono immediatamente per una piccola commissione (spesso inferiore al costo del gas per un prelievo dal ponte standard). Quando il ponte (o le persone che lo gestiscono) prevede di essere a corto di asset su L1, trasferisce asset sufficienti da L2. Poiché si tratta di prelievi molto grandi, il costo del prelievo viene ammortizzato su un importo elevato e rappresenta una percentuale molto più piccola.

Speriamo che questo articolo ti abbia aiutato a capire meglio come funziona il livello 2 e come scrivere codice Solidity chiaro e sicuro.

Vedi qui per altri miei lavori (opens in a new tab).

Ultimo aggiornamento della pagina: 3 aprile 2026

Questo tutorial è stato utile?