Passo a passo do contrato de ponte padrão da Optimism
Optimism(opens in a new tab) é uma Optimistic Rollup. Optimistic rollups podem processar transações por um preço muito inferior ao Ethereum Mainnet (também conhecido como layer 1 ou L1) porque as transações são processadas apenas por alguns nós, em vez de cada nó na rede. Ao mesmo tempo, os dados são todos escritos em L1, de modo que tudo pode ser provado e reconstruído com todas as garantias de integridade e disponibilidade da rede principal.
Para usar ativos L1 na Optimism (ou qualquer outra L2), os ativos precisam ser enviados pela ponte. Uma maneira de conseguir isso é os usuários bloquearem ativos (ETH e tokens ERC-20 são os mais comuns) na L1, e receber ativos equivalentes para usar na L2. Por fim, quem for que acabe com eles, talvez queira enviá-los de volta para a L1. Ao fazer isso, os ativos são queimados na L2 e, em seguida, liberados para o usuário na L1.
É assim que a ponte padrão Optimism(opens in a new tab) funciona. Neste artigo, passamos pelo código-fonte para essa ponte para ver como ele funciona e estudá-lo como um exemplo de código Solidity bem escrito.
Fluxo de controle
A ponte tem dois fluxos principais:
- Depósito (de L1 a L2)
- Saque de (L2 para L1)
Fluxo de depósitos
Camada 1
- Se depositar um ERC-20, o depositante dá à ponte uma permissão para gastar o valor que está sendo depositado
- O depositor chama a ponte L1(
depositERC20
,depositERC20To
,depositETH
, oudepositETHTo
) - A ponte L1 toma posse do ativo que está na ponte
- ETH: O ativo é transferido pelo depositante como parte da chamada
- ERC-20: O ativo é transferido pela ponte para si mesmo usando a permissão fornecida pelo depositante
- A ponte L1 usa o mecanismo de mensagem entre domínios para chamar
finalizeDeposit
na ponte L2
Camada 2
- A ponte L2 verifica que a chamada do
finalizeDeposit
é legítima:- Se veio do contrato de mensagem entre domínios
- Era originalmente da ponte em L1
- A ponte L2 checa se o contrato do token ERC-20 na L2 é o correto:
- O contrato L2 reporta que sua contraparte L1 é a mesma de onde vieram os tokens da L1
- O contrato L2 reporta que suporta a interface correta (usando ERC-165(opens in a new tab)).
- Se o contrato L2 é o correto, chame-o para cunhar o número apropriado de tokens para o endereço apropriado. Se não, comece o processo de retirada para permitir o usuário reclamar os tokens no L1.
Fluxo de retirada
Camada 2
- O sacador chama a ponte L2 (
withdraw
ouwithdrawTo
) - A ponte L2 queima o número apropriado de tokens pertencentes a
msg.sender
- A ponte L2 usa o mecanismo de mensagens entre domínios para chamar
finalizeETHWithdrawal
oufinalizeERC20Withdrawal
na ponte L1
Camada 1
- A ponte L1 verifica a chamada a
finalizeETHWithdrawal
oufinalizeERC20Withdrawal
é legitima:- Veio de um mecanismo de mensagens entre domínios
- Foi originada da ponte no L2
- A ponte L1 transfere o ativo apropriado (ETH ou ERC-20) para o endereço apropriado
Código Camada 1
Este é o código que roda na L1, a Rede Principal do Ethereum.
IL1ERC20Bridge
Esta interface é definida aqui(opens in a new tab). Ela inclui funções e definições exigidas para realizar a ponte de tokens ERC-20.
1// SPDX-License-Identifier: MITCopiar
Maioria do código da Optimism é lançada sob a licença MIT(opens in a new tab).
1pragma solidity >0.5.0 <0.9.0;Copiar
Neste momento, a última versão do Solidity é 0.8.12. Até versão 0.9.0 ser lançada, nós não sabemos se este código é compatível com ele ou não.
1/**2 * @title IL1ERC20Bridge3 */4interface IL1ERC20Bridge {5 /**********6 * Events *7 **********/89 event ERC20DepositInitiated(Exibir tudoCopiar
Na terminologia de ponte Optimism deposit significa transferência de L2 para L2, e withdrawal significa uma transferência de L2 para L1.
1 address indexed _l1Token,2 address indexed _l2Token,Copiar
Na maioria dos casos o endereço de um ERC-20 na L1 não é o mesmo endereço do equivalente ERC-20 na L2. Você pode ver a lista de endereços de tokens aqui(opens in a new tab). O endereço com chainId
1 está na L1 (Mainnet) e o endereço com chainId
10 está na L2 (Optimism). Os outros dois valores chainId
são para a rede de testes Kovan (42) e a rede de testes Optimistic Kovan (69).
1 address indexed _from,2 address _to,3 uint256 _amount,4 bytes _data5 );Copiar
É possível adicionar notas para transferências, caso no qual elas são adicionadas para os eventos que as reportam.
1 event ERC20WithdrawalFinalized(2 address indexed _l1Token,3 address indexed _l2Token,4 address indexed _from,5 address _to,6 uint256 _amount,7 bytes _data8 );Copiar
O mesmo contrato de ponte manipula transferências em ambas as direções. No caso da ponte L1, isto significa inicialização de depósitos e finalização de retiradas.
12 /********************3 * Public Functions *4 ********************/56 /**7 * @dev get the address of the corresponding L2 bridge contract.8 * @return Address of the corresponding L2 bridge contract.9 */10 function l2TokenBridge() external returns (address);Exibir tudoCopiar
Esta função não é realmente necessária, porque na L2 ela é um contrato pré-implantado, então ela está sempre no endereço 0x4200000000000000000000000000000000000010
. Ela está aqui por simetria com a ponte L2, porque o endereço da ponte L1 não é trivial de saber.
1 /**2 * @dev deposit an amount of the ERC20 to the caller's balance on L2.3 * @param _l1Token Address of the L1 ERC20 we are depositing4 * @param _l2Token Address of the L1 respective L2 ERC205 * @param _amount Amount of the ERC20 to deposit6 * @param _l2Gas Gas limit required to complete the deposit on L2.7 * @param _data Optional data to forward to L2. This data is provided8 * solely as a convenience for external contracts. Aside from enforcing a maximum9 * length, these contracts provide no guarantees about its content.10 */11 function depositERC20(12 address _l1Token,13 address _l2Token,14 uint256 _amount,15 uint32 _l2Gas,16 bytes calldata _data17 ) external;Exibir tudoCopiar
O parâmetro _l2Gas
é a quantidade de gas L2 que a transação tem permissão de gastar. Até um certo (alto) limite, isto é grátis(opens in a new tab), portanto a menos que o contrato ERC-20 faça algo realmente estranho quando cunhando, isto não deveria ser um problema. Esta função cuida do cenário comum, onde um usuário faz a ponte dos ativos para o mesmo endereço em uma blockchain diferente.
1 /**2 * @dev deposit an amount of ERC20 to a recipient's balance on L2.3 * @param _l1Token Address of the L1 ERC20 we are depositing4 * @param _l2Token Address of the L1 respective L2 ERC205 * @param _to L2 address to credit the withdrawal to.6 * @param _amount Amount of the ERC20 to deposit.7 * @param _l2Gas Gas limit required to complete the deposit on L2.8 * @param _data Optional data to forward to L2. This data is provided9 * solely as a convenience for external contracts. Aside from enforcing a maximum10 * length, these contracts provide no guarantees about its content.11 */12 function depositERC20To(13 address _l1Token,14 address _l2Token,15 address _to,16 uint256 _amount,17 uint32 _l2Gas,18 bytes calldata _data19 ) external;Exibir tudoCopiar
Esta função é quase idêntica a depositERC20
, mas ela deixa você enviar o ERC-20 para diferentes endereços.
1 /*************************2 * Cross-chain Functions *3 *************************/45 /**6 * @dev Complete a withdrawal from L2 to L1, and credit funds to the recipient's balance of the7 * L1 ERC20 token.8 * This call will fail if the initialized withdrawal from L2 has not been finalized.9 *10 * @param _l1Token Address of L1 token to finalizeWithdrawal for.11 * @param _l2Token Address of L2 token where withdrawal was initiated.12 * @param _from L2 address initiating the transfer.13 * @param _to L1 address to credit the withdrawal to.14 * @param _amount Amount of the ERC20 to deposit.15 * @param _data Data provided by the sender on L2. This data is provided16 * solely as a convenience for external contracts. Aside from enforcing a maximum17 * length, these contracts provide no guarantees about its content.18 */19 function finalizeERC20Withdrawal(20 address _l1Token,21 address _l2Token,22 address _from,23 address _to,24 uint256 _amount,25 bytes calldata _data26 ) external;27}Exibir tudoCopiar
Saques (e outras mensagens de L2 para L1) na Optimism é um processo em duas etapas:
- Uma transação inicial no L2.
- Uma transação de finalização ou de reclamação na L1. Esta transação precisa acontecer depois do período de desafio de falha(opens in a new tab) para a transação L2 terminar.
IL1StandardBridge
Esta interface é definida aqui(opens in a new tab). Este arquivo contém definições de evento e função para ETH. Estas definições são muito similares com aquelas definidas em IL1ERC20Bridge
acima para ERC-20.
A ponte interface é dividida entre dois arquivos, porque alguns tokens ERC-20 requerem processamento customizado e não podem ser manipulados pela ponte padrão. Dessa maneira a ponte customizada que manipula este token pode implementar IL1ERC20Bridge
e não ter que também fazer a ponte ETH.
1// SPDX-License-Identifier: MIT2pragma solidity >0.5.0 <0.9.0;34import "./IL1ERC20Bridge.sol";56/**7 * @title IL1StandardBridge8 */9interface IL1StandardBridge is IL1ERC20Bridge {10 /**********11 * Events *12 **********/13 event ETHDepositInitiated(14 address indexed _from,15 address indexed _to,16 uint256 _amount,17 bytes _data18 );Exibir tudoCopiar
Este evento é praticamente idêntico à versão ERC-20 (ERC20DepositInitiated
), exceto por não ter os endereços de token L1 e L2. O mesmo é verdade para outros eventos e funções.
1 event ETHWithdrawalFinalized(2 .3 .4 .5 );67 /********************8 * Public Functions *9 ********************/1011 /**12 * @dev Deposit an amount of the ETH to the caller's balance on L2.13 .14 .15 .16 */17 function depositETH(uint32 _l2Gas, bytes calldata _data) external payable;1819 /**20 * @dev Deposit an amount of ETH to a recipient's balance on L2.21 .22 .23 .24 */25 function depositETHTo(26 address _to,27 uint32 _l2Gas,28 bytes calldata _data29 ) external payable;3031 /*************************32 * Cross-chain Functions *33 *************************/3435 /**36 * @dev Complete a withdrawal from L2 to L1, and credit funds to the recipient's balance of the37 * L1 ETH token. Since only the xDomainMessenger can call this function, it will never be called38 * before the withdrawal is finalized.39 .40 .41 .42 */43 function finalizeETHWithdrawal(44 address _from,45 address _to,46 uint256 _amount,47 bytes calldata _data48 ) external;49}Exibir tudoCopiar
CrossDomainEnabled
Este contrato(opens in a new tab) é herdado por ambas pontes (L1 e L2) para enviar mensagens para a outra camada.
1// SPDX-License-Identifier: MIT2pragma solidity >0.5.0 <0.9.0;34/* Interface Imports */5import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";Copiar
Esta interface(opens in a new tab) mostra ao contrato como enviar mensagens para a outra camada, usando o mensageiro entre domínios. Este mensageiro entre domínios é todo um outro sistema, e merece um artigo próprio, que espero escrever no futuro.
1/**2 * @title CrossDomainEnabled3 * @dev Helper contract for contracts performing cross-domain communications4 *5 * Compiler used: defined by inheriting contract6 */7contract CrossDomainEnabled {8 /*************9 * Variables *10 *************/1112 // Messenger contract used to send and receive messages from the other domain.13 address public messenger;1415 /***************16 * Constructor *17 ***************/1819 /**20 * @param _messenger Address of the CrossDomainMessenger on the current layer.21 */22 constructor(address _messenger) {23 messenger = _messenger;24 }Exibir tudoCopiar
O único parâmetro que o contrato precisa saber é o endereço do mensageiro entre domínios nessa camada. Este parâmetro é configurado uma vez, no construtor, e nunca muda.
12 /**********************3 * Function Modifiers *4 **********************/56 /**7 * Enforces that the modified function is only callable by a specific cross-domain account.8 * @param _sourceDomainAccount The only account on the originating domain which is9 * authenticated to call this function.10 */11 modifier onlyFromCrossDomainAccount(address _sourceDomainAccount) {Exibir tudoCopiar
O mensageiro entre domínios é acessível por qualquer contrato na blockchain onde estiver rodando (seja Ethereum mainnet ou Optimism). Mas nós precisamos da ponte em cada lado para apenas confiar em certas mensagens se eles vierem da ponte do outro lado.
1 require(2 msg.sender == address(getCrossDomainMessenger()),3 "OVM_XCHAIN: messenger contract unauthenticated"4 );Copiar
Somente mensagens do mensageiro entre domínios apropriado (messenger
, como você vê abaixo) pode ser confiado.
12 require(3 getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,4 "OVM_XCHAIN: wrong sender of cross-domain message"5 );Copiar
A maneira que o mensageiro entre domínios fornece o endereço que enviou uma mensagem com a outra camada é a função .xDomainMessageSender()
(opens in a new tab). Enquanto ele for chamado na transação que foi iniciada pela mensagem, ele pode fornecer esta informação.
Nós precisamos nos certificar que a mensagem que nós recebemos veio da outra ponte.
12 _;3 }45 /**********************6 * Internal Functions *7 **********************/89 /**10 * Gets the messenger, usually from storage. This function is exposed in case a child contract11 * needs to override.12 * @return The address of the cross-domain messenger contract which should be used.13 */14 function getCrossDomainMessenger() internal virtual returns (ICrossDomainMessenger) {15 return ICrossDomainMessenger(messenger);16 }Exibir tudoCopiar
Esta função retorna o mensageiro entre domínios. Nós usamos uma função ao invés da variável messenger
para permitir contratos que herdam deste para usar um algoritmo para especificar qual mensageiro entre domínios usar.
12 /**3 * Sends a message to an account on another domain4 * @param _crossDomainTarget The intended recipient on the destination domain5 * @param _message The data to send to the target (usually calldata to a function with6 * `onlyFromCrossDomainAccount()`)7 * @param _gasLimit The gasLimit for the receipt of the message on the target domain.8 */9 function sendCrossDomainMessage(10 address _crossDomainTarget,11 uint32 _gasLimit,12 bytes memory _messageExibir tudoCopiar
Finalmente, a função que envia a mensagem para a outra camada.
1 ) internal {2 // slither-disable-next-line reentrancy-events, reentrancy-benignCopiar
Slither(opens in a new tab)é um analisador estático que a Optimism roda em cada contrato para procurar por vulnerabilidades e outros problemas em potencial. Nesse caso, as seguintes linhas disparam duas vulnerabilidades:
1 getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);2 }3}Copiar
Neste caso nós não estamos preocupados sobre reentrância. Nós sabemos que getCrossDomainMessenger()
returna um endereço confiável, mesmo se Slither não tem como saber isso.
O contrato da ponte L1
O código-fonte para este contrato está aqui(opens in a new tab).
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;Copiar
A interface pode ser parte de outros contratos, então eles têm de suportar uma larga faixa de versões de Solidity. Mas a ponte por ela mesma é o nosso contrato, e nós podemos ser estritos sobre qual versão Solidity ela usa.
1/* Interface Imports */2import { IL1StandardBridge } from "./IL1StandardBridge.sol";3import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";Copiar
IL1ERC20Bridge e IL1StandardBridge são explicados acima.
1import { IL2ERC20Bridge } from "../../L2/messaging/IL2ERC20Bridge.sol";Copiar
Esta interface(opens in a new tab) nos deixa criar mensagens para controlar a ponte padrão em L2.
1import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";Copiar
Esta interface(opens in a new tab) nos deixa controlar contratos ERC-20. Você pode ler mais sobre ela aqui.
1/* Library Imports */2import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";Copiar
Como explicado acima, este contrato é usado para mensageria entre camadas.
1import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";Copiar
Lib_PredeployAddresses
(opens in a new tab) tem os endereços dos contratos L2 que sempre tem o mesmo endereço. Isto inclui a ponte padrão em L2.
1import { Address } from "@openzeppelin/contracts/utils/Address.sol";Copiar
Utilitários de endereços OpenZeppelin(opens in a new tab). Ele é usado para distinguir entre endereços de contrato e aqueles pertencentes a contas de propriedade externa (EOA).
Note que isto não é a solução perfeita, porque não há como distinguir entre chamadas diretas e chamadas feitas de um construtor de contrato, mas pelo menos isto nos deixa identificar e evitar alguns erros comuns de usuário.
1import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";Copiar
O padrão ERC-20 (opens in a new tab) suporta duas maneiras para um contrato reportar falha:
- Revert
- Return
false
Gerenciar ambos casos faria nosso código mais complicado, então ao invés disso, usamos OpenZeppelin SafeERC20
(opens in a new tab), que garante que todas as falhas resultem num revert(opens in a new tab).
1/**2 * @title L1StandardBridge3 * @dev The L1 ETH and ERC20 Bridge is a contract which stores deposited L1 funds and standard4 * tokens that are in use on L2. It synchronizes a corresponding L2 Bridge, informing it of deposits5 * and listening to it for newly finalized withdrawals.6 *7 */8contract L1StandardBridge is IL1StandardBridge, CrossDomainEnabled {9 using SafeERC20 for IERC20;Exibir tudoCopiar
Esta linha é como especificamos para usar o wrapper SafeERC20
cada vez que nós usamos a interface IERC20
.
12 /********************************3 * External Contract References *4 ********************************/56 address public l2TokenBridge;Copiar
O endereço de L2StandardBridge.
12 // Maps L1 token to L2 token to balance of the L1 token deposited3 mapping(address => mapping(address => uint256)) public deposits;Copiar
Um mapeamento(opens in a new tab) em dobro como este é a maneira de você definir uma array esparsa bi-dimensional(opens in a new tab). Valores nesta estrutura de dados são identificados como deposit[L1 token addr][L2 token addr]
. O valor padrão é zero. Somente células que são configuradas para um valor diferente são escritas no storage.
12 /***************3 * Constructor *4 ***************/56 // This contract lives behind a proxy, so the constructor parameters will go unused.7 constructor() CrossDomainEnabled(address(0)) {}Copiar
Para querer ser capaz de atualizar este contrato sem ter que copiar todas as variáveis no storage. Para fazer isso, nós usamos um Proxy
(opens in a new tab), um contrato que usa delegatecall
(opens in a new tab) para transferir chamadas para um contato separado cujo endereço é armazenado pelo contrato proxy (quando você atualiza, você diz ao proxy para mudar o endereço). Quando você usa delegatecall
o storage permanece com o valor do contrato chamador, então os valores de todas as variáveis de estado do contrato não são afetadas.
Um efeito deste padrão é que o storage do contrato que é chamado pelo delegatecall
não é usado, e portanto os valores do construtor passados para ele não importam. Esta é a razão pela qual nós podemos fornecer um valor sem sentido para o construtor CrossDomainEnabled
. É também a razão que a inicialização abaixo é separada do construtor.
1 /******************2 * Initialization *3 ******************/45 /**6 * @param _l1messenger L1 Messenger address being used for cross-chain communications.7 * @param _l2TokenBridge L2 standard bridge address.8 */9 // slither-disable-next-line external-functionExibir tudoCopiar
Este teste Slither(opens in a new tab) identifica funções que não são chamadas do código do contrato e poderiam portanto serem declaradas external
ao invés de public
. As funções de custo de gas external
podem ser menores, porque elas podem ser fornecidas com parâmetros no calldata. Funções declaradas public
têm de ser acessíveis de dentro do contrato. Contratos não podem modificar seus próprios calldata, então os parâmetros têm que estar na memória. Quando esta função é chamada externamente, é necessário copiar o calldata para a memória, que custa gas. Nesse caso a função é chamada somente uma vez, então a ineficiência não importa para nós.
1 function initialize(address _l1messenger, address _l2TokenBridge) public {2 require(messenger == address(0), "Contract has already been initialized.");Copiar
A função initialize
deve ser chamada só uma vez. Se o endereço do mensageiro entre domínios L1 ou se a ponte do token L2 mudam, nós criamos um novo proxy e uma nova ponte que chama ele. Isto é improvável de acontecer, exceto quando o sistema inteiro é atualizado, uma ocorrência muito rara.
Note que esta função não tem nenhum mecanismo que restringe quem pode chamá-la. Isto significa que em teoria um atacante poderia esperar até que nós implantassemos o proxy e a primeira versão da ponte e então front-run(opens in a new tab)para pegar a função initialize
antes que o usuário legítimo o faça. Mas há dois métodos para evitar isso:
- Se o contrato for implantado não diretamente por um EOA mas em uma transação que tem outro contrato criando eles,(opens in a new tab) o processo inteiro pode ser atômico, e finalizar antes que qualquer outra transação seja executada.
- Se a chamada legítima para
initialize
falhar, é sempre possível ignorar o proxy recém-criado e fazer a ponte para criar outros novos.
1 messenger = _l1messenger;2 l2TokenBridge = _l2TokenBridge;3 }Copiar
Estes são dois parâmetros que a ponte precisa conhecer.
12 /**************3 * Depositing *4 **************/56 /** @dev Modifier requiring sender to be EOA. This check could be bypassed by a malicious7 * contract via initcode, but it takes care of the user error we want to avoid.8 */9 modifier onlyEOA() {10 // Used to stop deposits from contracts (avoid accidentally lost tokens)11 require(!Address.isContract(msg.sender), "Account not EOA");12 _;13 }Exibir tudoCopiar
É por essa razão que precisamos de utilitários de Address
do OpenZeppelin.
1 /**2 * @dev This function can be called with no data3 * to deposit an amount of ETH to the caller's balance on L2.4 * Since the receive function doesn't take data, a conservative5 * default amount is forwarded to L2.6 */7 receive() external payable onlyEOA {8 _initiateETHDeposit(msg.sender, msg.sender, 200_000, bytes(""));9 }Exibir tudoCopiar
A função existe para finalidade de testes. Note que ela não aparece nas definições de interface - não é para uso corrente.
1 /**2 * @inheritdoc IL1StandardBridge3 */4 function depositETH(uint32 _l2Gas, bytes calldata _data) external payable onlyEOA {5 _initiateETHDeposit(msg.sender, msg.sender, _l2Gas, _data);6 }78 /**9 * @inheritdoc IL1StandardBridge10 */11 function depositETHTo(12 address _to,13 uint32 _l2Gas,14 bytes calldata _data15 ) external payable {16 _initiateETHDeposit(msg.sender, _to, _l2Gas, _data);17 }Exibir tudoCopiar
Estas duas funções são wrappers em volta do _initiateETHDeposit
, a função que manipula o depósito do ETH real.
1 /**2 * @dev Performs the logic for deposits by storing the ETH and informing the L2 ETH Gateway of3 * the deposit.4 * @param _from Account to pull the deposit from on L1.5 * @param _to Account to give the deposit to on L2.6 * @param _l2Gas Gas limit required to complete the deposit on L2.7 * @param _data Optional data to forward to L2. This data is provided8 * solely as a convenience for external contracts. Aside from enforcing a maximum9 * length, these contracts provide no guarantees about its content.10 */11 function _initiateETHDeposit(12 address _from,13 address _to,14 uint32 _l2Gas,15 bytes memory _data16 ) internal {17 // Construct calldata for finalizeDeposit call18 bytes memory message = abi.encodeWithSelector(Exibir tudoCopiar
A maneira que mensagens entre domínios trabalham é que o contrato de destino é chamado com a mensagem como o seu calldata. Contratos Solidity sempre interpretam seu calldata de acordo com a especificação ABI(opens in a new tab). A função Solidity abi.encodeWithSelector
(opens in a new tab) cria este calldata.
1 IL2ERC20Bridge.finalizeDeposit.selector,2 address(0),3 Lib_PredeployAddresses.OVM_ETH,4 _from,5 _to,6 msg.value,7 _data8 );Copiar
A mensagem aqui é chamar a função finalizeDeposit
(opens in a new tab) com estes parâmetros:
Parâmetro | Valores | Significado |
---|---|---|
_l1Token | address(0) | Valor especial para o ETH (que não é um token ERC-20) na L1 |
_l2Token | Lib_PredeployAddresses.OVM_ETH | O contrato L2 que gerencia ETH na Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (este contrato é apenas para uso interno da Optimism) |
_from | _from | Os endereços na L1 que enviam o ETH |
_to | _to | O endereço na L2 que recebe o ETH |
amount | msg.value | Quantidade de wei enviado (que já foi enviado para a ponte) |
_data | _data | Dados adicionais para anexar ao depósito |
1 // Send calldata into L22 // slither-disable-next-line reentrancy-events3 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);Copiar
Enviar a mensagem através de mensageiro entre domínios.
1 // slither-disable-next-line reentrancy-events2 emit ETHDepositInitiated(_from, _to, msg.value, _data);3 }Copiar
Emitir um evento para informar qualquer aplicação descentralizada que escuta esta transferência.
1 /**2 * @inheritdoc IL1ERC20Bridge3 */4 function depositERC20(5 .6 .7 .8 ) external virtual onlyEOA {9 _initiateERC20Deposit(_l1Token, _l2Token, msg.sender, msg.sender, _amount, _l2Gas, _data);10 }1112 /**13 * @inheritdoc IL1ERC20Bridge14 */15 function depositERC20To(16 .17 .18 .19 ) external virtual {20 _initiateERC20Deposit(_l1Token, _l2Token, msg.sender, _to, _amount, _l2Gas, _data);21 }Exibir tudoCopiar
Estas duas funções são wrappers em volta do _initiateERC20Deposit
, a função que manipula o depósito real do ERC-20.
1 /**2 * @dev Performs the logic for deposits by informing the L2 Deposited Token3 * contract of the deposit and calling a handler to lock the L1 funds. (e.g. transferFrom)4 *5 * @param _l1Token Address of the L1 ERC20 we are depositing6 * @param _l2Token Address of the L1 respective L2 ERC207 * @param _from Account to pull the deposit from on L18 * @param _to Account to give the deposit to on L29 * @param _amount Amount of the ERC20 to deposit.10 * @param _l2Gas Gas limit required to complete the deposit on L2.11 * @param _data Optional data to forward to L2. This data is provided12 * solely as a convenience for external contracts. Aside from enforcing a maximum13 * length, these contracts provide no guarantees about its content.14 */15 function _initiateERC20Deposit(16 address _l1Token,17 address _l2Token,18 address _from,19 address _to,20 uint256 _amount,21 uint32 _l2Gas,22 bytes calldata _data23 ) internal {Exibir tudoCopiar
Esta função é similiar a _initiateETHDeposit
acima, com algumas poucas diferenças importantes. A primeira diferença é que esta função recebe o endereço de token e a quantia a transferir 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
).
1 // When a deposit is initiated on L1, the L1 Bridge transfers the funds to itself for future2 // withdrawals. safeTransferFrom also checks if the contract has code, so this will fail if3 // _from is an EOA or address(0).4 // slither-disable-next-line reentrancy-events, reentrancy-benign5 IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);Copiar
Transferências de tokens ERC-20 seguem um processo diferente do ETH:
- O usuário (
_from
) dá uma permissão para a ponte para transferir os tokens apropriados. - O usuário chama a ponte com o endereço do contrato do token, a quantia, etc.
- A ponte transfere os tokens (para ela mesmo) como parte do processo de depósito.
O primeiro passo pode acontecer em uma transação separada das últimas duas. Entretanto, front-running não é um problema porque as duas funções que chamam _initiateERC20Deposit
(depositERC20
e depositERC20To
) somente chamam essa função com msg.sender
como parâmetro _from
.
1 // Construct calldata for _l2Token.finalizeDeposit(_to, _amount)2 bytes memory message = abi.encodeWithSelector(3 IL2ERC20Bridge.finalizeDeposit.selector,4 _l1Token,5 _l2Token,6 _from,7 _to,8 _amount,9 _data10 );1112 // Send calldata into L213 // slither-disable-next-line reentrancy-events, reentrancy-benign14 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);1516 // slither-disable-next-line reentrancy-benign17 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] + _amount;Exibir tudoCopiar
Adicione a quantia depositada de tokens para a estrutura de dados deposits
. Pode haver múltiplos endereços em L2 que correspondam ao mesmo token ERC-20 L1, portanto não é suficiente usar saldo de ponte de token ERC-20 L1 para rastrear os depósitos.
12 // slither-disable-next-line reentrancy-events3 emit ERC20DepositInitiated(_l1Token, _l2Token, _from, _to, _amount, _data);4 }56 /*************************7 * Cross-chain Functions *8 *************************/910 /**11 * @inheritdoc IL1StandardBridge12 */13 function finalizeETHWithdrawal(14 address _from,15 address _to,16 uint256 _amount,17 bytes calldata _dataExibir tudoCopiar
A ponte L2 envia uma mensagem para o mensageiro entre domínios L2 que causa o mensageiro entre domínios L1 chamar esta função (uma vez que a transação que finaliza a mensagem(opens in a new tab) é submetida no L1, claro).
1 ) external onlyFromCrossDomainAccount(l2TokenBridge) {Copiar
Certifique-se que isto é uma mensagem legítima, vinda do mensageiro entre domínios e originada com o token da ponte L2. Esta função é usada para retirar ETH da ponte, então nós temos que nos certificar que é somente chamada pelo chamador autorizado.
1 // slither-disable-next-line reentrancy-events2 (bool success, ) = _to.call{ value: _amount }(new bytes(0));Copiar
A maneira de transferir ETH é chamar o recebedor com a quantia de wei no msg.value
.
1 require(success, "TransferHelper::safeTransferETH: ETH transfer failed");23 // slither-disable-next-line reentrancy-events4 emit ETHWithdrawalFinalized(_from, _to, _amount, _data);Copiar
Emitir um evento sobre o saque.
1 }23 /**4 * @inheritdoc IL1ERC20Bridge5 */6 function finalizeERC20Withdrawal(7 address _l1Token,8 address _l2Token,9 address _from,10 address _to,11 uint256 _amount,12 bytes calldata _data13 ) external onlyFromCrossDomainAccount(l2TokenBridge) {Exibir tudoCopiar
Esta função é similar a finalizeETHWithdrawal
acima, com as mudanças necessárias para os tokens ERC-20.
1 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] - _amount;Copiar
Atualiza a estrutura de dadosdeposits
.
12 // When a withdrawal is finalized on L1, the L1 Bridge transfers the funds to the withdrawer3 // slither-disable-next-line reentrancy-events4 IERC20(_l1Token).safeTransfer(_to, _amount);56 // slither-disable-next-line reentrancy-events7 emit ERC20WithdrawalFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);8 }91011 /*****************************12 * Temporary - Migrating ETH *13 *****************************/1415 /**16 * @dev Adds ETH balance to the account. This is meant to allow for ETH17 * to be migrated from an old gateway to a new gateway.18 * NOTE: This is left for one upgrade only so we are able to receive the migrated ETH from the19 * old contract20 */21 function donateETH() external payable {}22}Exibir tudoCopiar
Houve uma implementação anterior dessa ponte. Quando nos movemos da implementação para esta ponte, tivemos que mover todos os ativos. Tokens ERC-20 podem serem simplesmente movidos. Entretanto, para transferir ETH para um contrato, você precisa da aprovação do contrato, que é o que donateETH
nos fornece.
Tokens ERC-20 na L2
Para um token ERC-20 servir na ponte padrão, ele precisa permitir que a ponte padrão, e somente a ponte padrão, cunhe token. Isto é necessário porque as pontes precisam garantir que o número de tokens circulando na Optimism é igual ao número de tokens travados dentro do contrato da ponte L1. Se houver tokens demais na L2, alguns usuários ficarão incapazes de usar a ponte de volta para os seus ativos para a L1. Ao invés de uma ponte confiável, nós iriamos essencialmente recriar reserva fracionária bancária(opens in a new tab). Se houver tokens demais em L1, alguns desses tokens estaria travados dentro do contrato da ponte para sempre, porque não há maneira de liberá-los sem queimar tokens L2.
IL2StandardERC20
Cada 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 que a ponte padrão necessita.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";Copiar
A interface padrão ERC-20(opens in a new tab) não inclui as funções mint
e burn
. Estes métodos não são necessários pelo padrão ERC-20(opens in a new tab), que não deixa especificado os mecanismos para criar e destruir tokens.
1import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";Copiar
A interface ERC-165 (opens in a new tab)é usada para especificar que funções um contrato fornece. Você pode ler o padrão aqui(opens in a new tab).
1interface IL2StandardERC20 is IERC20, IERC165 {2 function l1Token() external returns (address);Copiar
Esta função fornece o endereço do token L1 que usa a ponte para este contrato. Note que nós não temos uma função similar na direção oposta. Nós precisamos ser capazes de usar a ponte para qualquer token L1, independente se o suporte L2 foi planejado quando foi implementado ou não.
12 function mint(address _to, uint256 _amount) external;34 function burn(address _from, uint256 _amount) external;56 event Mint(address indexed _account, uint256 _amount);7 event Burn(address indexed _account, uint256 _amount);8}Copiar
Funções e eventos para cunhar (criar) e queimar (destruir) tokens. A ponte deveria ser a única entidade que pode rodar estas funções para garantir que o número de tokens esteja correto (igual ao número de tokens travados na L1).
L2StandardERC20
Essa é a nossa implementação da interface IL2StandardERC20
(opens in a new tab). A não ser que você precise de algum tipo de lógica customizada, você deveria usar esta.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";Copiar
O contrato ERC-20 OpenZeppelin(opens in a new tab). A Optimism não acredita em reinventar a roda, especialmente quando a roda é bem auditada e precisa ser estimada o suficiente para manter ativos.
1import "./IL2StandardERC20.sol";23contract L2StandardERC20 is IL2StandardERC20, ERC20 {4 address public l1Token;5 address public l2Bridge;Copiar
Há dois parâmetros de configuração adicionais que nós precisamos, e um ERC-20 normalmente não precisa.
12 /**3 * @param _l2Bridge Address of the L2 standard bridge.4 * @param _l1Token Address of the corresponding L1 token.5 * @param _name ERC20 name.6 * @param _symbol ERC20 symbol.7 */8 constructor(9 address _l2Bridge,10 address _l1Token,11 string memory _name,12 string memory _symbol13 ) ERC20(_name, _symbol) {14 l1Token = _l1Token;15 l2Bridge = _l2Bridge;16 }Exibir tudoCopiar
Primeiro chame o construtor do contrato que nós herdamos (ERC20(_name, _symbol)
) e então configure suas próprias variáveis.
12 modifier onlyL2Bridge() {3 require(msg.sender == l2Bridge, "Only L2 Bridge can mint and burn");4 _;5 }678 // slither-disable-next-line external-function9 function supportsInterface(bytes4 _interfaceId) public pure returns (bool) {10 bytes4 firstSupportedInterface = bytes4(keccak256("supportsInterface(bytes4)")); // ERC16511 bytes4 secondSupportedInterface = IL2StandardERC20.l1Token.selector ^12 IL2StandardERC20.mint.selector ^13 IL2StandardERC20.burn.selector;14 return _interfaceId == firstSupportedInterface || _interfaceId == secondSupportedInterface;15 }Exibir tudoCopiar
Essa é a maneira que oERC-165(opens in a new tab) funciona. Cada interface é um número de funções suportadas, como identificadas no ou exclusivo(opens in a new tab) dos seletores de funções ABI(opens in a new tab) destas funções.
A ponte L2 usa ERC-165 como checagem de sanidade para garantir que o contrato ERC-20 para o qual ela envia ativos é um IL2StandardERC20
.
Note: Não há nada para evitar contratos trapaceiros de fornecer falsas respostas para supportsInterface
, portanto isto é um mecanismo de checagem de sanidade, não um mecanismo de segurança.
1 // slither-disable-next-line external-function2 function mint(address _to, uint256 _amount) public virtual onlyL2Bridge {3 _mint(_to, _amount);45 emit Mint(_to, _amount);6 }78 // slither-disable-next-line external-function9 function burn(address _from, uint256 _amount) public virtual onlyL2Bridge {10 _burn(_from, _amount);1112 emit Burn(_from, _amount);13 }14}Exibir tudoCopiar
Somente a ponte L2 pode cunhar e queimar ativos.
_mint
e _burn
são na verdade definidos no contrato ERC-20 OpenZeppelin. Este contrato só não os expõem externamente, porque as condições para cunhar e queimar tokens são tão variadas como o número de maneiras de usar ERC-20.
Código da ponte L2
Este é o código que roda na ponte na Optimism. A fonte deste contrato é aqui(opens in a new tab).
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34/* Interface Imports */5import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";6import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";7import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";Copiar
A interface IL2ERC20Bridge(opens in a new tab) é muito similar ao equivalente L1 que nós vimos acima. Há duas diferenças significantes:
- Na L1 você inicia depósitos e finaliza retiradas. Aqui você inicia retiradas e finaliza depósitos.
- Na L1 é necessário distinguir entre ETH e tokens ERC-20. Na L2 nós podemos usar as mesmas funções para ambos os casos porque internamente saldos ETH na Optimism são manipulados por um token ERC-20 com o endereço 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000(opens in a new tab).
1/* Library Imports */2import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";3import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";4import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";56/* Contract Imports */7import { IL2StandardERC20 } from "../../standards/IL2StandardERC20.sol";89/**10 * @title L2StandardBridge11 * @dev The L2 Standard bridge is a contract which works together with the L1 Standard bridge to12 * enable ETH and ERC20 transitions between L1 and L2.13 * This contract acts as a minter for new tokens when it hears about deposits into the L1 Standard14 * bridge.15 * This contract also acts as a burner of the tokens intended for withdrawal, informing the L116 * bridge to release L1 funds.17 */18contract L2StandardBridge is IL2ERC20Bridge, CrossDomainEnabled {19 /********************************20 * External Contract References *21 ********************************/2223 address public l1TokenBridge;Exibir tudoCopiar
Acompanhe o endereço da ponte L1. Observe que, em contraste com o equivalente L1, aqui precisamos desta variável. O endereço da ponte L1 não é conhecido antecipadamente.
12 /***************3 * Constructor *4 ***************/56 /**7 * @param _l2CrossDomainMessenger Cross-domain messenger used by this contract.8 * @param _l1TokenBridge Address of the L1 bridge deployed to the main chain.9 */10 constructor(address _l2CrossDomainMessenger, address _l1TokenBridge)11 CrossDomainEnabled(_l2CrossDomainMessenger)12 {13 l1TokenBridge = _l1TokenBridge;14 }1516 /***************17 * Withdrawing *18 ***************/1920 /**21 * @inheritdoc IL2ERC20Bridge22 */23 function withdraw(24 address _l2Token,25 uint256 _amount,26 uint32 _l1Gas,27 bytes calldata _data28 ) external virtual {29 _initiateWithdrawal(_l2Token, msg.sender, msg.sender, _amount, _l1Gas, _data);30 }3132 /**33 * @inheritdoc IL2ERC20Bridge34 */35 function withdrawTo(36 address _l2Token,37 address _to,38 uint256 _amount,39 uint32 _l1Gas,40 bytes calldata _data41 ) external virtual {42 _initiateWithdrawal(_l2Token, msg.sender, _to, _amount, _l1Gas, _data);43 }Exibir tudoCopiar
Estas duas funções iniciam retiradas. Observe que não há necessidade de especificar o endereço do token L1. Espera-se que os tokens L2 nos digam o endereço do equivalente L1.
12 /**3 * @dev Performs the logic for withdrawals by burning the token and informing4 * the L1 token Gateway of the withdrawal.5 * @param _l2Token Address of L2 token where withdrawal is initiated.6 * @param _from Account to pull the withdrawal from on L2.7 * @param _to Account to give the withdrawal to on L1.8 * @param _amount Amount of the token to withdraw.9 * @param _l1Gas Unused, but included for potential forward compatibility considerations.10 * @param _data Optional data to forward to L1. This data is provided11 * solely as a convenience for external contracts. Aside from enforcing a maximum12 * length, these contracts provide no guarantees about its content.13 */14 function _initiateWithdrawal(15 address _l2Token,16 address _from,17 address _to,18 uint256 _amount,19 uint32 _l1Gas,20 bytes calldata _data21 ) internal {22 // When a withdrawal is initiated, we burn the withdrawer's funds to prevent subsequent L223 // usage24 // slither-disable-next-line reentrancy-events25 IL2StandardERC20(_l2Token).burn(msg.sender, _amount);Exibir tudoCopiar
Observe que não estamos contando com o parâmetro _from
, mas com o msg.sender
que é muito mais difícil de falsificar (impossível, até onde eu sei).
12 // Construct calldata for l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)3 // slither-disable-next-line reentrancy-events4 address l1Token = IL2StandardERC20(_l2Token).l1Token();5 bytes memory message;67 if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {Copiar
Na L1 é necessário distinguir entre ETH e ERC-20.
1 message = abi.encodeWithSelector(2 IL1StandardBridge.finalizeETHWithdrawal.selector,3 _from,4 _to,5 _amount,6 _data7 );8 } else {9 message = abi.encodeWithSelector(10 IL1ERC20Bridge.finalizeERC20Withdrawal.selector,11 l1Token,12 _l2Token,13 _from,14 _to,15 _amount,16 _data17 );18 }1920 // Send message up to L1 bridge21 // slither-disable-next-line reentrancy-events22 sendCrossDomainMessage(l1TokenBridge, _l1Gas, message);2324 // slither-disable-next-line reentrancy-events25 emit WithdrawalInitiated(l1Token, _l2Token, msg.sender, _to, _amount, _data);26 }2728 /************************************29 * Cross-chain Function: Depositing *30 ************************************/3132 /**33 * @inheritdoc IL2ERC20Bridge34 */35 function finalizeDeposit(36 address _l1Token,37 address _l2Token,38 address _from,39 address _to,40 uint256 _amount,41 bytes calldata _dataExibir tudoCopiar
Essa função é chamada pelo L1StandardBridge
.
1 ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {Copiar
Certifique-se 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 foram cobertos pelos tokens que a ponte tem na L1.
1 // Check the target token is compliant and2 // verify the deposited token on L1 matches the L2 deposited token representation here3 if (4 // slither-disable-next-line reentrancy-events5 ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&6 _l1Token == IL2StandardERC20(_l2Token).l1Token()Copiar
Verificações de sanidade:
- A interface correta é suportada
- O endereço L1 do contrato ERC-20 L2 bate com a fonte L1 dos tokens
1 ) {2 // When a deposit is finalized, we credit the account on L2 with the same amount of3 // tokens.4 // slither-disable-next-line reentrancy-events5 IL2StandardERC20(_l2Token).mint(_to, _amount);6 // slither-disable-next-line reentrancy-events7 emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);Copiar
Se a checagem de sanidade passar, finalize o depósito:
- Cunhe os tokens
- Emita o evento apropriado
1 } else {2 // Either the L2 token which is being deposited-into disagrees about the correct address3 // of its L1 token, or does not support the correct interface.4 // This should only happen if there is a malicious L2 token, or if a user somehow5 // specified the wrong L2 token address to deposit into.6 // In either case, we stop the process here and construct a withdrawal7 // message so that users can get their funds out in some cases.8 // There is no way to prevent malicious token contracts altogether, but this does limit9 // user error and mitigate some forms of malicious contract behavior.Exibir tudoCopiar
Se um usuário fez um erro detectável usando o endereço de token L2 errado, nós queremos cancelar o depósito e retornar os tokens na L1. A única maneira que nós podemos fazer isso de L2 é enviar uma mensagem que irá ter que esperar pelo período de desafio de falha, mas isto é muito melhor para o usuário que perder seus tokens permanentemente.
1 bytes memory message = abi.encodeWithSelector(2 IL1ERC20Bridge.finalizeERC20Withdrawal.selector,3 _l1Token,4 _l2Token,5 _to, // switched the _to and _from here to bounce back the deposit to the sender6 _from,7 _amount,8 _data9 );1011 // Send message up to L1 bridge12 // slither-disable-next-line reentrancy-events13 sendCrossDomainMessage(l1TokenBridge, 0, message);14 // slither-disable-next-line reentrancy-events15 emit DepositFailed(_l1Token, _l2Token, _from, _to, _amount, _data);16 }17 }18}Exibir tudoCopiar
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) a esperar o período de desafio e também não precisar de 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 gas para uma retirada de uma ponte padrão). Quando a ponte (ou as pessoas que a administram) antecipa a falta de ativos L1, ela transfere ativos suficientes da L2. Como estes são saques muito grandes, o custo do saque é amortizado por uma larga 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.
Última edição: @lukassim(opens in a new tab), 26 de abril de 2024