Pular para o conteúdo principal

Passo a passo do contrato da ponte padrão do Optimism

Solidity
ponte
camada 2
Intermediário
Ori Pomerantz
30 de março de 2022
34 minutos de leitura

Optimism (opens in a new tab) é um Optimistic Rollup. Os optimistic rollups podem processar transações por um preço muito mais baixo do que a Rede Principal da Ethereum (também conhecida como camada 1 ou L1), porque as transações são processadas apenas por alguns nós, em vez de por todos os nós na rede. Ao mesmo tempo, todos os dados são escritos na L1, para que tudo possa ser provado e reconstruído com todas as garantias de integridade e disponibilidade da Rede Principal.

Para usar ativos da L1 no Optimism (ou em qualquer outra L2), os ativos precisam ser transferidos por ponte. Uma maneira de conseguir isso é os usuários bloquearem ativos (ETH e tokens ERC-20 são os mais comuns) na L1 e receberem ativos equivalentes para usar na L2. Eventualmente, quem quer que acabe com eles pode querer transferi-los por ponte de volta para a L1. Ao fazer isso, os ativos são queimados na L2 e então liberados de volta para o usuário na L1.

É assim que a ponte padrão do Optimism (opens in a new tab) funciona. Neste artigo, analisamos o código-fonte dessa ponte para ver como ela funciona e estudá-la como um exemplo de código Solidity bem escrito.

Fluxos de controle

A ponte tem dois fluxos principais:

  • Depósito (da L1 para a L2)
  • Retirada (da L2 para a L1)

Fluxo de depósito

Camada 1

  1. Ao depositar um ERC-20, o depositante concede à ponte uma autorização para gastar o valor que está sendo depositado
  2. O depositante chama a ponte da L1 (depositERC20, depositERC20To, depositETH ou depositETHTo)
  3. A ponte da L1 toma posse do ativo transferido pela ponte
    • ETH: o ativo é transferido pelo depositante como parte da chamada
    • ERC-20: o ativo é transferido pela ponte para si mesma usando a autorização fornecida pelo depositante
  4. A ponte da L1 usa o mecanismo de mensagens entre domínios para chamar finalizeDeposit na ponte da L2

Camada 2

  1. A ponte da L2 verifica se a chamada para finalizeDeposit é legítima:
    • Veio do contrato de mensagens entre domínios
    • Era originalmente da ponte na L1
  2. A ponte da L2 verifica se o contrato do token ERC-20 na L2 é o correto:
    • O contrato da L2 informa que sua contraparte da L1 é a mesma de onde os tokens vieram na L1
    • O contrato da L2 informa que suporta a interface correta (usando ERC-165 (opens in a new tab)).
  3. Se o contrato da L2 for o correto, chame-o para cunhar o número apropriado de tokens para o endereço apropriado. Caso contrário, inicie um processo de retirada para permitir que o usuário resgate os tokens na L1.

Fluxo de retirada

Camada 2

  1. O sacador chama a ponte da L2 (withdraw ou withdrawTo)
  2. A ponte da L2 queima o número apropriado de tokens pertencentes a msg.sender
  3. A ponte da L2 usa o mecanismo de mensagens entre domínios para chamar finalizeETHWithdrawal ou finalizeERC20Withdrawal na ponte da L1

Camada 1

  1. A ponte da L1 verifica se a chamada para finalizeETHWithdrawal ou finalizeERC20Withdrawal é legítima:
    • Veio do mecanismo de mensagens entre domínios
    • Foi originada da ponte na L2
  2. A ponte da L1 transfere o ativo apropriado (ETH ou ERC-20) para o endereço apropriado

Código da Camada 1

Este é o código que é executado na L1, a Rede Principal da Ethereum.

IL1ERC20Bridge

Esta interface é definida aqui (opens in a new tab). Ela inclui funções e definições necessárias para a transferência de tokens ERC-20 por ponte.

// SPDX-License-Identifier: MIT

A maior parte do código do Optimism é lançada sob a licença MIT (opens in a new tab).

pragma solidity >0.5.0 <0.9.0;

No momento em que este artigo foi escrito, a versão mais recente do Solidity era a 0.8.12. Até que a versão 0.9.0 seja lançada, não sabemos se este código é compatível com ela ou não.

Na terminologia de ponte do Optimism, deposit (depósito) significa transferência da L1 para a L2, e withdrawal (retirada) significa uma transferência da L2 para a L1.

        address indexed _l1Token,
        address indexed _l2Token,

Na maioria dos casos, o endereço de um ERC-20 na L1 não é o mesmo que o endereço do ERC-20 equivalente na L2. Você pode ver a lista de endereços de token aqui (opens in a new tab). O endereço com chainId 1 está na L1 (Rede Principal) e o endereço com chainId 10 está na L2 (Optimism). Os outros dois valores de chainId são para a rede de teste Kovan (42) e a rede de teste Optimistic Kovan (69).

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

É possível adicionar anotações às transferências, e nesse caso, elas são adicionadas aos eventos que as relatam.

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

O mesmo contrato de ponte lida com transferências em ambas as direções. No caso da ponte da L1, isso significa a inicialização de depósitos e a finalização de retiradas.

Esta função não é realmente necessária, porque na L2 ela é um contrato pré-implantado, então está sempre no endereço 0x4200000000000000000000000000000000000010. Ela está aqui por simetria com a ponte da L2, porque o endereço da ponte da L1 não é trivial de saber.

O parâmetro _l2Gas é a quantidade de gás da L2 que a transação pode gastar. Até um certo limite (alto), isso é gratuito (opens in a new tab), então, a menos que o contrato ERC-20 faça algo muito estranho durante a cunhagem, não deve ser um problema. Esta função cuida do cenário comum, em que um usuário transfere ativos por ponte para o mesmo endereço em uma blockchain diferente.

Esta função é quase idêntica a depositERC20, mas permite que você envie o ERC-20 para um endereço diferente.

As retiradas (e outras mensagens da L2 para a L1) no Optimism são um processo de duas etapas:

  1. Uma transação de iniciação na L2.
  2. Uma transação de finalização ou de resgate na L1. Essa transação precisa acontecer após o término do período de contestação de falhas (opens in a new tab) para a transação da L2.

IL1StandardBridge

Esta interface é definida aqui (opens in a new tab). Este arquivo contém definições de evento e função para ETH. Essas definições são muito semelhantes às definidas em IL1ERC20Bridge acima para ERC-20.

A interface da ponte é dividida em dois arquivos porque alguns tokens ERC-20 exigem processamento personalizado e não podem ser tratados pela ponte padrão. Dessa forma, a ponte personalizada que lida com esse token pode implementar IL1ERC20Bridge e não precisar também transferir ETH por ponte.

Este evento é quase idêntico à versão ERC-20 (ERC20DepositInitiated), exceto que não tem os endereços de token da L1 e da L2. O mesmo se aplica aos outros eventos e funções.

CrossDomainEnabled

Este contrato (opens in a new tab) é herdado por ambas as pontes (L1 e L2) para enviar mensagens para a outra camada.

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

/* Importações de Interface */
import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";

Esta interface (opens in a new tab) informa ao contrato como enviar mensagens para a outra camada, usando o mensageiro entre domínios. Este mensageiro entre domínios é um sistema completamente diferente, e merece seu próprio artigo, que espero escrever no futuro.

O único parâmetro que o contrato precisa saber é o endereço do mensageiro entre domínios nessa camada. Este parâmetro é definido uma vez, no construtor, e nunca muda.

O sistema de mensagens entre domínios é acessível por qualquer contrato na blockchain em que está sendo executado (seja a Rede Principal da Ethereum ou o Optimism). Mas precisamos que a ponte de cada lado confie apenas em certas mensagens se elas vierem da ponte do outro lado.

        require(
            msg.sender == address(getCrossDomainMessenger()),
            "OVM_XCHAIN: contrato de mensageiro não autenticado"
        );

Apenas mensagens do mensageiro entre domínios apropriado (messenger, como você verá abaixo) são confiáveis.


        require(
            getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,
            "OVM_XCHAIN: remetente incorreto da mensagem entre domínios"
        );

A forma como o mensageiro entre domínios fornece o endereço que enviou uma mensagem para a outra camada é através da função .xDomainMessageSender() (opens in a new tab). Desde que seja chamada na transação que foi iniciada pela mensagem, ela pode fornecer esta informação.

Precisamos ter certeza de que a mensagem que recebemos veio da outra ponte.

Esta função retorna o mensageiro entre domínios. Usamos uma função em vez da variável messenger para permitir que contratos que herdam deste usem um algoritmo para especificar qual mensageiro entre domínios usar.

Finalmente, a função que envia uma mensagem para a outra camada.

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

Slither (opens in a new tab) é um analisador estático que o Optimism executa em cada contrato para procurar por vulnerabilidades e outros problemas em potencial. Nesse caso, a linha seguinte aciona duas vulnerabilidades:

  1. Eventos de reentrância (opens in a new tab)
  2. Reentrância benigna (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

Neste caso, não estamos preocupados com a reentrância, pois sabemos que getCrossDomainMessenger() retorna um endereço confiável, mesmo que o Slither não tenha como saber disso.

O contrato da ponte da L1

O código-fonte para este contrato está aqui (opens in a new tab).

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

As interfaces podem fazer parte de outros contratos, por isso precisam suportar uma ampla gama de versões do Solidity. Mas a ponte em si é nosso contrato, e podemos ser rigorosos sobre qual versão do Solidity ela usa.

/* Importações de Interface */
import { IL1StandardBridge } from "./IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";

IL1ERC20Bridge e IL1StandardBridge são explicados acima.

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

Esta interface (opens in a new tab) nos permite criar mensagens para controlar a ponte padrão na L2.

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

Esta interface (opens in a new tab) nos permite controlar contratos ERC-20. Você pode ler mais sobre isso aqui.

/* Importações de Biblioteca */
import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";

Como explicado acima, este contrato é usado para mensagens entre camadas.

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

Lib_PredeployAddresses (https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts/contracts/libraries/constants/Lib_PredeployAddresses.sol (opens in a new tab)) tem os endereços para os contratos da L2 que sempre têm o mesmo endereço. Isso inclui a ponte padrão na L2.

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

Utilitários de Endereço do OpenZeppelin (opens in a new tab). É usado para distinguir entre endereços de contrato e aqueles pertencentes a contas de propriedade externa (EOA).

Note que esta não é uma solução perfeita, porque não há como distinguir entre chamadas diretas e chamadas feitas a partir do construtor de um contrato, mas pelo menos isso nos permite identificar e prevenir alguns erros comuns de usuários.

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

O padrão ERC-20 (opens in a new tab) suporta duas maneiras para um contrato relatar falhas:

  1. Reverter
  2. Retornar false

Lidar com ambos os casos tornaria nosso código mais complicado, então, em vez disso, usamos o SafeERC20 do OpenZeppelin (opens in a new tab), que garante que todas as falhas resultem em uma reversão (opens in a new tab).

Esta linha é como especificamos o uso do wrapper SafeERC20 toda vez que usamos a interface IERC20.


    /********************************
     * Referências de Contratos Externos *
     ********************************/

    address public l2TokenBridge;

O endereço do L2StandardBridge.


    // Mapeia o token da L1 para o token da L2 para o saldo do token da L1 depositado
    mapping(address => mapping(address => uint256)) public deposits;

Um mapeamento (opens in a new tab) duplo como este é a maneira de definir uma matriz esparsa bidimensional (opens in a new tab). Os valores nesta estrutura de dados são identificados como deposit[L1 token addr][L2 token addr]. O valor padrão é zero. Apenas as células que são definidas com um valor diferente são escritas no armazenamento.


    /***************
     * Construtor *
     ***************/

    // Este contrato vive por trás de um proxy, então os parâmetros do construtor não serão utilizados.
    constructor() CrossDomainEnabled(address(0)) {}

Para poder atualizar este contrato sem ter que copiar todas as variáveis no armazenamento. Para fazer isso, usamos um Proxy (opens in a new tab), um contrato que usa delegatecall (opens in a new tab) para transferir chamadas para um contrato separado cujo endereço é armazenado pelo contrato proxy (quando você atualiza, você diz ao proxy para mudar esse endereço). Quando você usa delegatecall, o armazenamento permanece o armazenamento do contrato chamador, então os valores de todas as variáveis de estado do contrato não são afetados.

Um efeito deste padrão é que o armazenamento do contrato que é o chamado de delegatecall não é usado e, portanto, os valores do construtor passados para ele não importam. Esta é a razão pela qual podemos fornecer um valor sem sentido para o construtor CrossDomainEnabled. É também a razão pela qual a inicialização abaixo é separada do construtor.

Este teste do Slither (opens in a new tab) identifica funções que não são chamadas do código do contrato e que, portanto, poderiam ser declaradas external em vez de public. O custo de gás de funções external pode ser menor, porque elas podem receber parâmetros no calldata. Funções declaradas como public precisam ser acessíveis de dentro do contrato. Contratos não podem modificar seu próprio calldata, então os parâmetros precisam estar na memória. Quando tal função é chamada externamente, é necessário copiar o calldata para a memória, o que custa gás. Neste caso, a função é chamada apenas uma vez, então a ineficiência não importa para nós.

    function initialize(address _l1messenger, address _l2TokenBridge) public {
        require(messenger == address(0), "O contrato já foi inicializado.");

A função initialize deve ser chamada apenas uma vez. Se o endereço do mensageiro entre domínios da L1 ou da ponte de token da L2 mudar, criamos um novo proxy e uma nova ponte que o chama. Isso é improvável de acontecer, exceto quando todo o sistema é atualizado, uma ocorrência muito rara.

Note que esta função não tem nenhum mecanismo que restringe quem pode chamá-la. Isso significa que, em teoria, um invasor poderia esperar até que implantemos o proxy e a primeira versão da ponte e, em seguida, usar front-run (opens in a new tab) para chegar à função initialize antes que o usuário legítimo o faça. Mas há dois métodos para evitar isso:

  1. Se os contratos forem implantados não diretamente por uma EOA, mas em uma transação que tem outro contrato para criá-los (opens in a new tab), todo o processo pode ser atômico e terminar antes que qualquer outra transação seja executada.
  2. Se a chamada legítima para initialize falhar, é sempre possível ignorar o proxy e a ponte recém-criados e criar novos.
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

Estes são os dois parâmetros que a ponte precisa conhecer.

É por essa razão que precisamos de utilitários de Address do OpenZeppelin.

A função existe para finalidade de testes. Note que ela não aparece nas definições de interface - não é para uso corrente.

Estas duas funções são wrappers em volta do _initiateETHDeposit, a função que lida com o depósito real de ETH.

A maneira como as mensagens entre domínios funcionam é que o contrato de destino é chamado com a mensagem como seu calldata. Os contratos Solidity sempre interpretam seu calldata de acordo com as especificações da ABI (opens in a new tab). A função do Solidity abi.encodeWithSelector (opens in a new tab) cria esse calldata.

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

A mensagem aqui é para chamar a função finalizeDeposit (opens in a new tab) com estes parâmetros:

ParâmetroValorSignificado
_l1Tokenaddress(0)Valor especial para representar ETH (que não é um token ERC-20) na L1
_l2TokenLib_PredeployAddresses.OVM_ETHO contrato da L2 que gerencia ETH no Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (este contrato é para uso interno do Optimism apenas)
_from_fromO endereço na L1 que envia o ETH
_to_toO endereço na L2 que recebe o ETH
quantidademsg.valueQuantidade de wei enviado (que já foi enviado para a ponte)
_data_dataDados adicionais para anexar ao depósito
        // Envia o calldata para a L2
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

Envia a mensagem através do mensageiro entre domínios.

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

Emite um evento para informar qualquer aplicativo descentralizado que escuta esta transferência.

Estas duas funções são wrappers em volta do _initiateERC20Deposit, a função que manipula o depósito real do ERC-20.

Esta função é semelhante a _initiateETHDeposit acima, com algumas diferenças importantes. A primeira diferença é que esta função recebe os endereços de token e a quantia a ser transferida como parâmetros. No caso do ETH, a chamada para a ponte já inclui a transferência do ativo para a conta da ponte (msg.value).

        // Quando um depósito é iniciado na L1, a Ponte da L1 transfere os fundos para si mesma para futuras
        // retiradas. safeTransferFrom também verifica se o contrato tem código, então isso falhará se
        // _from for uma EOA ou address(0).
        // slither-disable-next-line reentrancy-events, reentrancy-benign
        IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);

As transferências de token ERC-20 seguem um processo diferente do ETH:

  1. O usuário (_from) concede uma autorização à ponte para transferir os tokens apropriados.
  2. O usuário chama a ponte com o endereço do contrato do token, a quantia, etc.
  3. A ponte transfere os tokens (para si mesma) como parte do processo de depósito.

O primeiro passo pode acontecer em uma transação separada dos dois últimos. No entanto, o front-running não é um problema porque as duas funções que chamam _initiateERC20Deposit (depositERC20 e depositERC20To) só chamam esta função com msg.sender como o parâmetro _from.

Adiciona a quantia depositada de tokens à estrutura de dados deposits. Pode haver múltiplos endereços na L2 que correspondem ao mesmo token ERC-20 da L1, então não é suficiente usar o saldo da ponte do token ERC-20 da L1 para rastrear os depósitos.

A ponte da L2 envia uma mensagem para o mensageiro entre domínios da L2, o que faz com que o mensageiro entre domínios da L1 chame esta função (uma vez que a transação que finaliza a mensagem (opens in a new tab) seja enviada na L1, é claro).

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

Certifique-se de que esta é uma mensagem legítima, vinda do mensageiro entre domínios e originada na ponte de tokens da L2. Esta função é usada para retirar ETH da ponte, então temos que ter certeza de que ela só é chamada pelo chamador autorizado.

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

A maneira de transferir ETH é chamar o destinatário com a quantia de wei no msg.value.

        require(success, "TransferHelper::safeTransferETH: falha na transferência de ETH");

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

Emite um evento sobre a retirada.

Esta função é semelhante a finalizeETHWithdrawal acima, com as alterações necessárias para tokens ERC-20.

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

Atualiza a estrutura de dados deposits.

Havia uma implementação anterior da ponte. Quando mudamos da implementação anterior para esta, tivemos que mover todos os ativos. Os tokens ERC-20 podem simplesmente ser movidos. No entanto, para transferir ETH para um contrato, você precisa da aprovação desse contrato, que é o que donateETH nos fornece.

Tokens ERC-20 na L2

Para um token ERC-20 se encaixar na ponte padrão, ele precisa permitir que a ponte padrão, e apenas a ponte padrão, cunhe o token. Isso é necessário porque as pontes precisam garantir que o número de tokens circulando no Optimism seja igual ao número de tokens bloqueados dentro do contrato da ponte da L1. Se houver muitos tokens na L2, alguns usuários não conseguirão transferir seus ativos de volta para a L1 por meio da ponte. Em vez de uma ponte confiável, estaríamos essencialmente recriando o sistema de reservas fracionárias (opens in a new tab). Se houver tokens demais na L1, alguns desses tokens ficariam bloqueados dentro do contrato da ponte para sempre, porque não há como liberá-los sem queimar tokens da L2.

IL2StandardERC20

Todo token ERC-20 na L2 que usa a ponte padrão precisa fornecer esta interface (opens in a new tab), que tem as funções e eventos de que a ponte padrão precisa.

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

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

A interface padrão ERC-20 (opens in a new tab) não inclui as funções mint e burn. Esses métodos não são exigidos pelo padrão ERC-20 (opens in a new tab), que não especifica os mecanismos para criar e destruir tokens.

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

A interface ERC-165 (opens in a new tab) é usada para especificar quais funções um contrato fornece. Você pode ler o padrão aqui (opens in a new tab).

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

Esta função fornece o endereço do token da L1 que é transferido por ponte para este contrato. Observe que não temos uma função semelhante na direção oposta. Precisamos ser capazes de transferir por ponte qualquer token da L1, independentemente de o suporte à L2 ter sido planejado quando ele foi implementado ou não.


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

Funções e eventos para cunhar (criar) e queimar (destruir) tokens. A ponte deve ser a única entidade que pode executar essas funções para garantir que o número de tokens esteja correto (igual ao número de tokens bloqueados na L1).

L2StandardERC20

Esta é a nossa implementação da interface IL2StandardERC20 (opens in a new tab). A menos que você precise de algum tipo de lógica personalizada, você deve usar esta.

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

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

O contrato ERC-20 do OpenZeppelin (opens in a new tab). O Optimism não acredita em reinventar a roda, especialmente quando a roda é bem auditada e precisa ser confiável o suficiente para guardar ativos.

import "./IL2StandardERC20.sol";

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

Estes são os dois parâmetros de configuração adicionais que exigimos e que um ERC-20 normalmente não exige.

Primeiro chame o construtor do contrato que herdamos (ERC20(_name, _symbol)) e depois defina nossas próprias variáveis.

É assim que o ERC-165 (opens in a new tab) funciona. Cada interface é um número de funções suportadas e é identificada como o ou exclusivo (opens in a new tab) dos seletores de função da ABI (opens in a new tab) dessas funções.

A ponte da L2 usa o ERC-165 como uma verificação de sanidade para garantir que o contrato ERC-20 para o qual envia ativos é um IL2StandardERC20.

Observação: Não há nada que impeça um contrato mal-intencionado de fornecer respostas falsas para supportsInterface, portanto, este é um mecanismo de verificação de sanidade, não um mecanismo de segurança.

Apenas a ponte da L2 pode cunhar e queimar ativos.

_mint e _burn são, na verdade, definidos no contrato ERC-20 da OpenZeppelin. Aquele contrato simplesmente não os expõe externamente, porque as condições para cunhar e queimar tokens são tão variadas quanto o número de maneiras de usar o ERC-20.

Código da Ponte da L2

Este é o código que executa a ponte no Optimism. A fonte para este contrato está aqui (opens in a new tab).

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

/* Importações de Interface */
import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";
import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";

A interface IL2ERC20Bridge (opens in a new tab) é muito semelhante ao equivalente da L1 que vimos acima. Há duas diferenças significativas:

  1. Na L1, você inicia depósitos e finaliza retiradas. Aqui, você inicia retiradas e finaliza depósitos.
  2. Na L1, é necessário distinguir entre tokens ETH e ERC-20. Na L2, podemos usar as mesmas funções para ambos, porque internamente os saldos de ETH no Optimism são tratados como um token ERC-20 com o endereço 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab).

Acompanhe o endereço da ponte da L1. Observe que, em contraste com o equivalente da L1, aqui precisamos desta variável. O endereço da ponte da L1 não é conhecido antecipadamente.

Estas duas funções iniciam as retiradas. Observe que não há necessidade de especificar o endereço do token da L1. Espera-se que os tokens da L2 nos digam o endereço do equivalente da L1.

Observe que não estamos confiando no parâmetro _from, mas em msg.sender, que é muito mais difícil de falsificar (impossível, até onde eu sei).


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

        if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {

Na L1, é necessário distinguir entre ETH e ERC-20.

Essa função é chamada pelo L1StandardBridge.

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

Certifique-se de que a origem da mensagem é legítima. Isso é importante porque a função chama _mint e poderia ser usada para dar tokens que não são cobertos pelos tokens que a ponte possui na L1.

        // Verifica se o token de destino é compatível e
        // verifica se o token depositado na L1 corresponde à representação do token depositado na L2 aqui
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()

Verificações de sanidade:

  1. A interface correta é suportada
  2. O endereço L1 do contrato ERC-20 da L2 corresponde à origem dos tokens na L1
        ) {
            // Quando um depósito é finalizado, creditamos na conta da L2 a mesma quantidade de
            // tokens.
            // 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 a verificação de sanidade passar, finalize o depósito:

  1. Cunhar os tokens
  2. Emitir o evento apropriado

Se um usuário cometeu um erro detectável usando o endereço de token da L2 errado, queremos cancelar o depósito e retornar os tokens na L1. A única maneira de fazer isso a partir da L2 é enviar uma mensagem que terá que esperar o período de contestação de falhas, mas isso é muito melhor para o usuário do que perder os tokens permanentemente.

Conclusão

A ponte padrão é o mecanismo mais flexível para transferência de ativos. Porém, por ser genérico não é sempre o mecanismo mais fácil de usar. Especialmente para retiradas, a maioria dos usuários prefere usar pontes de terceiros (opens in a new tab) que não esperam o período de desafio e não exigem uma prova de Merkle para finalizar a retirada.

Estas pontes tipicamente funcionam tendo ativos na L1, que elas fornecem imediatamente por uma taxa pequena (geralmente menor que o custo de gás para uma retirada de uma ponte padrão). Quando a ponte (ou as pessoas que a administram) antecipa a falta de ativos da L1, ela transfere ativos suficientes da L2. Como estes são saques muito grandes, o custo do saque é amortizado por uma grande quantia e é um percentual muito menor.

Esperamos que este artigo tenha ajudado você a entender mais sobre como a camada 2 funciona e como escrever um código Solidity claro e seguro.

Veja aqui mais do meu trabalho (opens in a new tab).

Última atualização da página: 3 de abril de 2026

Este tutorial foi útil?