Recorrido por contrato de puente estándar de Optimism
Optimism(opens in a new tab) es un Optimistic rollup. Los Optimistic rollups pueden procesar transacciones por un precio mucho más bajo que la red principal de Ethereum (también conocida como capa 1 o L1) porque las transacciones solo son procesadas por unos pocos nodos, en lugar de cada nodo de la red. Al mismo tiempo, los datos se escriben en L1 para que todo pueda ser probado y reconstruido con todas las garantías de integridad y disponibilidad de la red principal.
Para utilizar activos de L1 en Optimism (o cualquier otra L2), los activos deben "puentearse". Una manera de lograr esto es que los usuarios bloqueen activos (ETH y los tokens ERC-20 son los más comunes) en L1 y recibir activos equivalentes para usar en L2. Eventualmente, quien acabe poseyéndolos puede querer puentearlos de vuelta a la L1. Al hacer esto, los activos se queman en L2 y luego se liberan nuevamente al usuario en L1.
Así es como funciona el puente estándar de Optimism(opens in a new tab). En este artículo analizaremos el código fuente de ese puente para ver cómo funciona y lo estudiaremos como ejemplo de código de Solidity bien escrito.
Flujos de control
El puente tiene dos flujos principales:
- Depósito (de L1 a L2)
- Retiro (de L2 a L1)
Flujo de depósito
Capa 1
- Si se deposita un ERC-20, el depositante le da al puente una asignación para gastar la cantidad depositada.
- El depositante llama al puente L1 (
depositERC20
,depositERC20To
,depositETH
odepositETHTo
). - El puente L1 toma posesión del activo puenteado.
- ETH: El activo es transferido por el depositante como parte de la llamada.
- ERC-20: El activo es transferido por el puente a sí mismo utilizando la asignación proporcionada por el depositante.
- El puente L1 utiliza el mecanismo de mensajes de dominio cruzado para llamar a
finalizeDeposit
en el puente L2
Capa 2
- El puente L2 verifica que la llamada a
finalizeDeposit
sea legítima:- Procede del contrato de mensajes de dominio cruzado
- Era originalmente del puente en L1
- El puente de L2 comprueba si el contrato de token ERC-20 en L2 es el correcto:
- El contrato L2 informa de que su contraparte en L1 es la misma de la que provienen los tokens de L1
- El contrato de L2 informa que soporta la interfaz correcta (usando ERC-165(opens in a new tab)).
- Si el contrato L2 es el correcto, llámelo para mintear el número apropiado de tokens a la dirección apropiada. Si no, inicie un proceso de retiro para permitir al usuario reclamar los tokens en L1.
Flujo de retiro
Capa 2
- El que hace el retiro llama al puente de L2 (
withdraw
owithdrawTo
) - El puente L2 quema el número apropiado de tokens pertenecientes a
msg.sender
- El puente L2 utiliza el mecanismo de mensajes entre dominios para llamar a
finalizeETHWithdrawal
ofinalizeERC20Withdrawal
en el puente L1
Capa 1
- El puente L1 verifica que la llamada a
finalizeETHWithdrawal
ofinalizeERC20Withdrawal
sea legítima:- Procede del mecanismo de mensajes entre dominios
- Era originalmente del puente en L2
- El puente L1 transfiere el activo apropiado (ETH o ERC-20) a la dirección apropiada
Código de capa 1
Este es el código que se ejecuta en L1, la Red principal de Ethereum.
IL1ERC20Bridge
Esta interfaz se define aquí(opens in a new tab). Incluye funciones y definiciones requeridas para puentear tokens ERC-20.
1// SPDX-License-Identifier: MITCopiar
La mayor parte del código de Optimism se libera bajo la licencia MIT(opens in a new tab).
1pragma solidity >0.5.0 <0.9.0;Copiar
Al momento de escribir este artículo, la última versión de Solidity es 0.8.12. Hasta que la versión 0.9.0 sea liberada, no sabemos si este código será compatible.
1/**2 * @title IL1ERC20Bridge3 */4interface IL1ERC20Bridge {5 /**********6 * Events *7 **********/89 event ERC20DepositInitiated(Mostrar todoCopiar
En la terminología de puentes de Optimism, deposit significa transferir de L1 a L2, y withdrawal significa transferir de L2 a L1.
1 address indexed _l1Token,2 address indexed _l2Token,Copiar
En la mayoría de los casos, la dirección de un ERC-20 en L1 no es la misma dirección del ERC-20 equivalente en L2. Puede ver la lista de direcciones de tokens aquí(opens in a new tab). La dirección con chainId
1 está en L1 (Red Principal) y la dirección con chainId
10 está en L2 (Optimism). Los otros dos valores chainId
son para la red de pruebas Kovan (42) y la red de pruebas Optimistic Kovan (69).
1 address indexed _from,2 address _to,3 uint256 _amount,4 bytes _data5 );Copiar
Es posible agregar notas a las transferencias, en cuyo caso se añaden a los eventos que las reportan.
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
El mismo contrato de puente maneja las transferencias en ambas direcciones. En el caso del puente L1, esto significa inicialización de depósitos y finalización de retiros.
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);Mostrar todoCopiar
Esta función no es realmente necesaria, porque en L2 es un contrato preimplementado, así que siempre está en la dirección 0x4200000000000000000000000000000000000010
. Está aquí por simetría con el puente L2, porque la dirección del puente L1 no es trivial de conocer.
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;Mostrar todoCopiar
El parámetro _l2Gas
es la cantidad de gas de L2 que la transacción puede gastar. Hasta cierto límite (alto), es gratuito(opens in a new tab), así que a menos que el contrato ERC haga algo realmente extraño a la hora de mintear, no debería ser un problema. Esta función se encarga del escenario común, donde un usuario puentea activos a la misma dirección en una cadena de bloques 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;Mostrar todoCopiar
Esta función es casi idéntica a depositERC20
, pero le permite enviar el ERC-20 a una dirección diferente.
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}Mostrar todoCopiar
Los retiros (y otros mensajes de L2 a L1) en Optimism son un proceso de dos pasos:
- Una transacción iniciante en L2.
- Una transacción de finalización o reclamación en L1. Esta transacción debe ocurrir después de que finalice el periodo de desafío (o reclamo) por falta(opens in a new tab) para la transacción L2.
IL1StandardBridge
Esta interfaz se define aquí(opens in a new tab). Este archivo contiene definiciones de eventos y funciones para ETH. Estas definiciones son muy similares a las definidas en IL1ERC20Bridge
arriba para ERC-20.
La interfaz de puente está dividida entre dos archivos porque algunos tokens ERC-20 requieren un procesamiento personalizado y no pueden ser manejados por el puente estándar. De esta manera, el puente personalizado que maneja tal token puede implementar IL1ERC20Bridge
y no tener que puentear también 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 );Mostrar todoCopiar
Este evento es casi idéntico a la versión ERC-20 (ERC20DepositInitiated
), excepto que no incluye las direcciones del token en L1 y L2. Lo mismo es válido para otros eventos y las funciones.
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}Mostrar todoCopiar
CrossDomainEnabled
Este contrato(opens in a new tab) es heredado por ambos puentes (L1 y L2) para enviar mensajes a la otra capa.
1// SPDX-License-Identifier: MIT2pragma solidity >0.5.0 <0.9.0;34/* Interface Imports */5import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";Copiar
Esta interfaz(opens in a new tab) le dice al contrato cómo enviar mensajes a la otra capa usando el mensajero de dominio cruzado. Este mensajero de dominio cruzado es un sistema completamente diferente y merece su propio artículo, que espero escribir en el 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 }Mostrar todoCopiar
El único parámetro que el contrato necesita saber, la dirección del mensajero de dominio cruzado en esta capa. Este parámetro se establece una vez, en el constructor, y nunca cambia.
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) {Mostrar todoCopiar
La mensajería entre dominios es accesible para cualquier contrato en la cadena de bloques donde se esté ejecutando (ya sea la Red Principal de Ethereum u Optimism). Pero necesitamos que el puente de cada lado solo confíe en ciertos mensajes si provienen del puente del otro lado.
1 require(2 msg.sender == address(getCrossDomainMessenger()),3 "OVM_XCHAIN: messenger contract unauthenticated"4 );Copiar
Solo se puede confiar en los mensajes del mensajero de dominio cruzado apropiado (messenger
, como ve a continuación).
12 require(3 getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,4 "OVM_XCHAIN: wrong sender of cross-domain message"5 );Copiar
La forma en que el mensajero de dominio cruzado proporciona la dirección que envió un mensaje con la otra capa es la función .xDomainMessageSender()
(opens in a new tab). Siempre y cuando se la llame en la transacción iniciada por el mensaje, puede proporcionar esta información.
Tenemos que asegurarnos de que el mensaje que recibimos haya venido del otro puente.
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 }Mostrar todoCopiar
Esta función devuelve el mensajero de dominio cruzado. Utilizamos una función en lugar de la variable messenger
para permitir que los contratos que heredan de esta usen un algoritmo para especificar qué mensajero de dominio cruzado 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 _messageMostrar todoCopiar
Finalmente, la función que envía un mensaje a la otra capa.
1 ) internal {2 // slither-disable-next-line reentrancy-events, reentrancy-benignCopiar
Slither(opens in a new tab) es un analizador estático que Optimism ejecuta en cada contrato para buscar vulnerabilidades y otros potenciales problemas. En este caso, la siguiente línea dispara o activa dos vulnerabilidades:
1 getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);2 }3}Copiar
En este caso no estamos preocupados sobre reentradas, ya que sabemos que getCrossDomainMessenger()
devuelve una dirección confiable, incluso si Slither no tiene manera de saberlo.
El contrato de puente L1
El códgo fuente para este contrato está aquí(opens in a new tab).
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;Copiar
Las interfaces pueden ser parte de otros contratos, por lo que tienen que admitir un amplio rango de versiones de Solidity. Pero el puente en sí es nuestro contrato, y podemos ser estrictos en cuanto a la versión de Solidity utilizada.
1/* Interface Imports */2import { IL1StandardBridge } from "./IL1StandardBridge.sol";3import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";Copiar
IL1ERC20Bridge y IL1StandardBridge se explican arriba.
1import { IL2ERC20Bridge } from "../../L2/messaging/IL2ERC20Bridge.sol";Copiar
Esta interfaz(opens in a new tab) nos permite crear mensajes para controlar el puente estándar en L2.
1import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";Copiar
Esta interfaz(opens in a new tab) nos permite controlar contratos ERC-20. Puede leer más al respecto aquí.
1/* Library Imports */2import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";Copiar
Como se explicó más arriba, este contrato se utiliza para mensajes entre capas.
1import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";Copiar
Lib_PredeployAddresses
(opens in a new tab) tiene las direcciones para los contratos en L2 que siempre tienen la misma dirección. Esto incluye el puente estándar en L2.
1import { Address } from "@openzeppelin/contracts/utils/Address.sol";Copiar
Utilidades de dirección de OpenZeppelin(opens in a new tab). Se utiliza para distinguir entre las direcciones del contrato y las que pertenecen a cuentas de propiedad externa (EOA).
Tenga en cuenta que esta no es una solución perfecta, porque no hay forma de distinguir entre llamadas directas y llamadas hechas desde el constructor de un contrato, pero al menos esto nos permite identificar y prevenir algunos errores de usuario comunes.
1import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";Copiar
El estándar ERC-20(opens in a new tab) permite dos formas para que un contrato reporte fallas:
- Revertir
- Devolver
false
Manejar ambos casos complicaría nuestro código, así que en su lugar utilizamos SafeERC20 de OpenZeppelin
(opens in a new tab), el cual asegura que todas las fallas resulten en una reversión(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;Mostrar todoCopiar
Esta línea es cómo especificamos usar el wrapper SafeERC20
cada vez que usamos la interfaz IERC20
.
12 /********************************3 * External Contract References *4 ********************************/56 address public l2TokenBridge;Copiar
La dirección de L2StandardBridge.
12 // Maps L1 token to L2 token to balance of the L1 token deposited3 mapping(address => mapping(address => uint256)) public deposits;Copiar
Un mapeo(opens in a new tab) doble como este es la forma en que se define una sparse array bidimensional(opens in a new tab). Los valores de esta estructura de datos se identifican como deposit[L1 token addr][L2 token addr]
. El valor por defecto es cero. Solo las celdas configuradas con un valor diferente se escriben en el almacenamiento.
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 poder actualizar este contrato sin tener que copiar todas las variables en el almacenamiento. Para ello usamos un Proxy
(opens in a new tab), un contrato que usa delegatecall
(opens in a new tab) para transferir llamadas a un contacto separado cuya dirección se almacena en el contrato proxy (cuando actualiza le dice al proxy que cambie esa dirección). Cuando usa delegatecall
el almacenamiento sigue siendo el almacenamiento del contrato invocante, así que los valores de todas las variables de estado del contrato no se vean afectados.
Un efecto de este patrón es que el almacenamiento del contrato invocado de delegatecall
no se utiliza y, por tanto, los valores del constructor que le son pasados no importan. Esta es la razón por la que podemos proporcionar un valor sin sentido al constructor CrossDomainEnabled
. También es la razón por la que la inicialización siguiente es independiente del constructor.
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-functionMostrar todoCopiar
Esta prueba de Slither(opens in a new tab) identifica funciones que no son llamadas desde el código del contrato y, por lo tanto, podrían declararse como external
en lugar de public
. El costo de gas de las funciones external
puede ser menor, porque pueden ser proporcionadas con parámetros en los datos de llamada. Las funciones declaradas como public
deben ser accesibles desde el contrato. Los contratos no pueden modificar sus propios datos de llamada, por lo que los parámetros deben estar en memoria. Cuando se llama a tal función externamente, es necesario copiar los datos de llamada en la memoria, lo que cuesta gas. En este caso se invoca la función solo una vez, por lo que la ineficiencia no nos importa.
1 function initialize(address _l1messenger, address _l2TokenBridge) public {2 require(messenger == address(0), "Contract has already been initialized.");Copiar
La función initialize
debe ser invocada una única vez. Si la dirección del mensajero de dominio cruzado L1 o el puente del token L2 cambia, creamos un nuevo proxy y un nuevo puente que lo llame. Es poco probable que esto ocurra, excepto cuando se actualiza todo el sistema, algo muy raro.
Tenga en cuenta que esta función no tiene ningún mecanismo que restrinja quién puede llamarlo. Esto significa que en teoría un atacante podría esperar hasta que implementemos el proxy y la primera versión del puente, y luego hacer front-run(opens in a new tab) para llegar a la función initialize
antes de que el usuario legítimo lo haga. Pero hay dos métodos para prevenir esto:
- Si los contratos son implementados no directamente por una EOA, sino en una transacción que hace que otro contrato los cree(opens in a new tab), el proceso completo puede ser atómico y terminar antes de que se ejecute cualquier otra transacción.
- Si falla la llamada legítima a
initialize
, siempre es posible ignorar el proxy y el puente recién creados, y crear otros.
1 messenger = _l1messenger;2 l2TokenBridge = _l2TokenBridge;3 }Copiar
Estos son los dos parámetros que el puente necesita conocer.
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 }Mostrar todoCopiar
Esta es la razón por la que necesitábamos las utilidades de Address
de 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 }Mostrar todoCopiar
Esta función existe con fines de prueba. Tenga en cuenta que no aparece en las definiciones de la interfaz: no es para uso normal.
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 }Mostrar todoCopiar
Estas dos funciones son wrappers alrededor de _initiateETHDeposit
, la función que gestiona el depósito de 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(Mostrar todoCopiar
La forma en que funcionan los mensajes entre dominios es que el contrato de destino es llamado con el mensaje como sus datos de llamada. Los contratos de Solidity interpretan siempre sus datos de llamada de acuerdo con las especificaciones de la ABI(opens in a new tab). La función de Solidity abi.encodeWithSelector
(opens in a new tab) crea esos datos de llamada.
1 IL2ERC20Bridge.finalizeDeposit.selector,2 address(0),3 Lib_PredeployAddresses.OVM_ETH,4 _from,5 _to,6 msg.value,7 _data8 );Copiar
El mensaje aquí es llamar a la función finalizeDeposit
(opens in a new tab) con estos parámetros:
Parámetro | Valor | Significado |
---|---|---|
_l1Token | address(0) | Valor especial para representar ETH (que no es un token ERC-20) en L1 |
_l2Token | Lib_PredeployAddresses.OVM_ETH | El contrato L2 que administra ETH en Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (este contrato es solo para uso interno en Optimism) |
_from | _from | La dirección en L1 que envía el ETH |
_to | _to | La dirección en L2 que recibe el ETH |
amount | msg.value | Cantidad de wei enviado (que ya ha sido enviado al puente) |
_data | _data | Fecha adicional a adjuntar al depósito |
1 // Send calldata into L22 // slither-disable-next-line reentrancy-events3 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);Copiar
Enviar el mensaje a través del mensajero de dominio cruzado.
1 // slither-disable-next-line reentrancy-events2 emit ETHDepositInitiated(_from, _to, msg.value, _data);3 }Copiar
Emitir un evento para informar de cualquier aplicación descentralizada que escuche esta transferencia.
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 }Mostrar todoCopiar
Estas dos funciones son wrappers alrededor de _initiateERC20Deposit
, la función que gestiona el depósito de ERC-20 real.
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 {Mostrar todoCopiar
Esta función es similar a la función _initiateETHDeposit
anterior, con algunas diferencias importantes. La primera diferencia es que esta función recibe las direcciones del token y la cantidad a transferir como parámetros. En el caso de ETH la llamada al puente ya incluye la transferencia del activo a la cuenta del puente (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
Las transferencias de tokens ERC-20 siguen un proceso diferente de ETH:
- El usuario (
_from
) le da una autorización al puente para transferir los tokens apropiados. - El usuario llama al puente con la dirección del contrato de token, la cantidad, etc.
- El puente transfiere los tokens (a sí mismo) como parte del proceso de depósito.
El primer paso puede ocurrir en una transacción separada de los dos últimos. Sin embargo, hacer front-running no es un problema porque las dos funciones que llaman a _initiateERC20Deposit
(depositERC20
y depositERC20To
) solo llaman a esta función con msg.sender
como el 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;Mostrar todoCopiar
Añada la cantidad de tokens depositados a la estructura de datos de deposits
. Podría haber varias direcciones en L2 que correspondan al mismo token ERC-20 L1, por lo que no es suficiente usar el saldo del puente del token ERC-20 L1 para hacer un seguimiento de los 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 _dataMostrar todoCopiar
El puente de L2 envía un mensaje al mensajero de dominio cruzado L2 que hace que el mensajero de dominio cruzado L1 llame a esta función (una vez que la transacción que finaliza el mensaje(opens in a new tab) se envíe en L1, por supuesto).
1 ) external onlyFromCrossDomainAccount(l2TokenBridge) {Copiar
Asegúrese de que este sea un mensaje legítimo, proveniente del mensajero de dominio cruzado y que se origine con el puente de token L2. Esta función se utiliza para retirar ETH del puente, así que tenemos que asegurarnos de que solo sea invocada por el llamador autorizado.
1 // slither-disable-next-line reentrancy-events2 (bool success, ) = _to.call{ value: _amount }(new bytes(0));Copiar
La forma de transferir ETH es llamar al destinatario con la cantidad de wei en el msg.value
.
1 require(success, "TransferHelper::safeTransferETH: ETH transfer failed");23 // slither-disable-next-line reentrancy-events4 emit ETHWithdrawalFinalized(_from, _to, _amount, _data);Copiar
Emita un evento sobre el retiro.
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) {Mostrar todoCopiar
Esta función es similar a la función finalizeETHWithdrawal
anterior, con los cambios necesarios para los tokens ERC-20.
1 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] - _amount;Copiar
Actualizar la estructura de datos de deposits
.
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}Mostrar todoCopiar
Había una implementación anterior del puente. Cuando pasamos de la implementación a esta, tuvimos que mover todos los activos. Los tokens ERC-20 pueden moverse sin más. Sin embargo, para transferir ETH a un contrato, necesita la aprobación de ese contrato, que es lo que donateETH
nos proporciona.
Tokens ERC-20 en L2
Para que un token ERC-20 se ajuste al puente estándar, necesita permitir que el puente estándar, y solo el puente estándar, mintee tokens. Esto es necesario porque los puentes deben garantizar que el número de tokens que circulan en Optimism sea igual al número de tokens que se encuentran bloqueados dentro del contrato de puente L1. Si hay demasiados tokens en L2, algunos usuarios no podrían puentear sus activos de vuelta a L1. En lugar de un puente de confianza, esencialmente recrearíamos banca de reserva fraccionaria(opens in a new tab). Si hay demasiados tokens en L1, algunos de esos tokens permanecerían bloqueados dentro del contrato de puente para siempre porque no hay forma de liberarlos sin quemar los tokens de L2.
IL2StandardERC20
Todos los tokens ERC-20 en L2 que utilicen el puente estándar deben proporcionar esta interfaz(opens in a new tab), que tiene las funciones y eventos que el puente estándar necesita.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";Copiar
La interfaz estándar ERC-20(opens in a new tab) no incluye las funciones mint
y burn
. Esos métodos no son requeridos por el estándar ERC-20(opens in a new tab), lo que deja sin especificar los mecanismos para crear y destruir tokens.
1import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";Copiar
La interfaz ERC-165(opens in a new tab) se utiliza para especificar qué funciones proporciona un contrato. Puede leer el estándar aquí(opens in a new tab).
1interface IL2StandardERC20 is IERC20, IERC165 {2 function l1Token() external returns (address);Copiar
Esta función proporciona la dirección del token L1 puenteado a este contrato. Tenga en cuenta que no tenemos una función similar en la dirección opuesta. Tenemos que ser capaces de puentear cualquier token L1, independientemente de que el soporte a L2 se haya planificado o no cuando se implementó.
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
Funciones y eventos para mintear (cear) y quemar (destruir) tokens. El puente debe ser la única entidad que puede ejecutar estas funciones para asegurar que el número de tokens sea correcto (igual al número de tokens bloqueados en L1).
L2StandardERC20
Esta es nuestra implementación de la interfaz IL2StandardERC20
(opens in a new tab). A menos que necesite algún tipo de lógica personalizada, debería utilizar esta.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";Copiar
El contrato OpenZeppelin ERC-20(opens in a new tab). Optimism no cree en reinventar la rueda, especialmente cuando la rueda está bien auditada y necesita ser lo suficientemente fiable como para mantener los activos.
1import "./IL2StandardERC20.sol";23contract L2StandardERC20 is IL2StandardERC20, ERC20 {4 address public l1Token;5 address public l2Bridge;Copiar
Estos son los dos parámetros de configuración adicionales que requerimos, y ERC-20 normalmente no lo hace.
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 }Mostrar todoCopiar
Primero llamamos al constructor del contrato del que heredamos (ERC20(_name, _symbol)
) y luego establecemos nuestras propias variables.
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 }Mostrar todoCopiar
Esta es la manera en que funciona ERC-165(opens in a new tab). Cada interfaz es un número de funciones soportadas, y se identifica como la exclusiva o(opens in a new tab) de los selectores de funciones ABI(opens in a new tab) de esas funciones.
El puente L2 utiliza ERC-165 como sanity check (comprobación de cordura) para asegurarse de que el contrato ERC-20 al que envía activos sea un IL2StandardERC20
.
Nota: No hay nada que impida que un contrato deshonesto proporcione respuestas falsas a supportsInterface
, por lo que esto es un mecanismo de comprobación de cordura, no un mecanismo de seguridad.
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}Mostrar todoCopiar
Solo el puente L2 puede mintear y quemar activos.
_mint
y _burn
están en realidad definidos en el contrato OpenZeppelin ERC-20. Ese contrato simplemente no los expone externamente, porque las condiciones para mintear y quemar tokens son tan variadas como el número de maneras de usar ERC-20.
Código del puente L2
Este es el código que ejecuta el puente en Optimism. La fuente de este contrato está aquí(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
La interfaz IL2ERC20Bridge(opens in a new tab) es muy similar a la equivalente en L1 que vimos arriba. Hay dos diferencias significativas:
- En L1 usted inicia depósitos y finaliza retiros. Aquí usted inicia retiros y finaliza depósitos.
- En L1 es necesario distinguir entre ETH y tokens ERC-20. En L2 podemos usar las mismas funciones para ambos porque internamente los saldos de ETH en Optimism son manejados como un token ERC-20 con la dirección 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD000(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;Mostrar todoCopiar
Mantener un registro de la dirección del puente L1. Tenga en cuenta que en contraste con el equivalente en L1, aquí necesitamos esta variable. La dirección del puente L1 no se conoce de antemano.
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 }Mostrar todoCopiar
Estas dos funciones inician retiros. Tenga en cuenta que no hay necesidad de especificar la dirección del token L1. Se espera que los tokens L2 nos indiquen la dirección equivalente en 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);Mostrar todoCopiar
Tenga en cuenta que no usamos el parámetro _from
, sino msg.sender
, que es mucho más difícil de falsificar (imposible, por lo que sé).
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
En L1 es necesario distinguir entre ETH y tokens 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 _dataMostrar todoCopiar
Esta función es llamada por L1StandardBridge
.
1 ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {Copiar
Asegúrese de que la fuente del mensaje es legítima. Esto es importante porque esta función llama a _mint
y podría ser usada para entregar tokens que no estén cubiertos por los tokens que el puente posee en 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
Pruebas de cordura (sanity checks):
- Se admite la interfaz correcta.
- La dirección L1 del contrato ERC-20 L2 coincide con la fuente L1 de los 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
Si las pruebas de cordura son satisfactorias, finalice el depósito:
- Mintee los tokens
- Emita el evento apropiado
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.Mostrar todoCopiar
Si un usuario realizó un error detectable mediante el uso de la dirección de token L2 incorrecta, queremos cancelar el depósito y devolver los tokens en L1. La única forma de hacerlo desde L2 es enviar un mensaje que tenga que esperar el período de desafío por falta, pero eso es mucho mejor para el usuario que perder los 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}Mostrar todoCopiar
Conclusión
El puente estándar es el mecanismo más flexible para las transferencias de activos. Sin embargo, debido a que es muy genérico, no siempre es el mecanismo más fácil de utilizar. Especialmente para los retiros, la mayoría de los usuarios prefieren usar puentes de terceros(opens in a new tab) que no esperen el periodo de desafío y no requieran una prueba de Merkle para finalizar el retiro.
Estos puentes normalmente funcionan teniendo activos en L1, que proporcionan inmediatamente por una pequeña tarifa (a menudo menor que el costo del gas para un retiro de puente estándar). Cuando el puente (o la gente que lo ejecuta) anticipa quedarse con pocos activos en L1, transfiere suficientes activos de L2. Como se trata de retiros muy grandes, el costo de la retirada se amortiza en grandes cantidades y resulta en un porcentaje mucho menor.
Esperemos que este artículo le haya ayudado a entender más sobre cómo funciona la capa 2 y cómo escribir código de Solidity de manera clara y segura.
Última edición: @lukassim(opens in a new tab), 26 de abril de 2024