Paseo por el contrato estándar de puente en Optimism
Optimism(opens in a new tab) es un Rollup Optimista. Los Rollups Optimistas pueden procesar transacciones por un precio mucho más bajo que la red Mainnet de Ethereum (también conocido como capa 1 o L1) porque las transacciones sólo 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 Mainnet.
Para utilizar activos L1 en Optimism (o cualquier otra L2), los activos deben ser puenteados. Una manera de lograr esto es que los usuarios bloqueen activos (ETH y tokens ERC-20 son los más comunes) en L1, y recibir activos equivalentes a 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 al usuario en L1.
Así es como funciona el puente estándar Optimism(opens in a new tab). En este artículo pasamos por el código fuente de ese puente para ver cómo funciona y estudiarlo como un 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 depósito 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 puentado
- 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 depósito
- 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
esté legitimada:- 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 L1 es la misma de la que provienen los tokens del L1
- El contrato 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 acuñar el número apropiado de tokens a la dirección apropiada. Si no, inicie un proceso de retiro para permitir al usuario reclamar las fichas en L1.
Flujo de retiro
Capa 2
- Aquél que realiza el retiro llama al puente L2 (
withdraw
orwithdrawTo
) - 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 cruzados 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 Ethereum.
IL1ERC20Bridge
Esta interfaz está definida 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 está liberado bajo la licencia MIT(opens in a new tab).
1pragma solidity >0.5.0 <0.9.0;Copiar
Al instante de esta escritura 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 es compatible con él o no.
1/**2 * @title IL1ERC20Bridge3 */4interface IL1ERC20Bridge {5 /**********6 * Events *7 **********/89 event ERC20DepositInitiated(Mostrar todoCopiar
En la terminología de puente 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. Puedes 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 predesplegado, 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 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 acuñar, 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 finalización o reclamación de transacción en L1. Esta transacción debe ocurrir después de que finalice el periodo de desafío de falta(opens in a new tab) para la transacción L2.
IL1StandardBridge
Esta interfaz está definida 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 sin las direcciones L1 y L2 del token. 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 puestes (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 mesajes 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 por 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 sólo 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 pueden confiar en los mensajes del mensajero transversal apropiado (messenger
, como ves 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 .xDomainMessageSender()
función(opens in a new tab). Siempre y cuando se llame en la transacción que fue iniciada por el mensaje puede proporcionar esta información.
Tenemos que asegurarnos de que el mensaje que recibimos vino 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 ésta 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 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 soportar una amplio rango de versiones de Solidity. Pero el puente en sí es nuestro contrato, y podemos ser estrictos con qué versión de Solidity utiliza.
1/* Interface Imports */2import { IL1StandardBridge } from "./IL1StandardBridge.sol";3import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";Copiar
IL1ERC20Bridge y IL1StandardBridge están explicados 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. Puedes 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 mensajear 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 del constructor de un contrato, pero al menos 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) soporta dos formas para que un contrato reporte fallido:
- Revertir
- Devolver
false
Manejar ambos casos complicaría nuestro código, así que en su lugar utilizamosSafeERC20de OpenZeppelin
(opens in a new tab), el cual aseguraque todos los fallos 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 envoltorio 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 éste es la forma en que se define una matriz bidimensional dispersa(opens in a new tab). Los valores en esta estructura de datos se identifican como deposit[L1 token addr][L2 token addr]
. El valor por defecto es cero. Sólo las celdas que están configuradas en 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 actualice se le dice al proxy que cambie esa dirección). Cuando usas delegatecall
el almacenamiento sigue siendo el almacenamiento del contrato de llamada, para que los valores de todas las variables del estado del contrato no se vean afectados.
Un efecto de este patrón es que el almacenamiento del contrato que es el called 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 a continuación 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 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 external
en lugar de public
. El coste de gas de external
puede ser menor, porque pueden ser proporcionadas con parámetros en los datos de llamada. Las funciones declaradas 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 tal función se llama externamente, es necesario copiar los datos de llamada a la memoria, lo que cuesta gas. En este caso la función sólo se llama 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 llamada una única vez. Si la dirección del mensajero de dominio cruzado L1 o el token de puente 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 restringe quién puede llamarlo. Esto significa que en teoría un atacante podría esperar hasta que despliegue el proxy y la primera versión del puente y luego 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 se despliegan no directamente por un EOA sino en una transacción que tiene otro contrato que los crea(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 recién creado y el puente, y crear uno nuevo.
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 envolturas alrededor de _initiateETHDeposit
, la función que gestiona el depósito ETH actual.
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 que sus datos de llamada están de acuerdo con las especificaciones 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 sólo para uso interno de 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 enviados (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 envolturas alrededor de _initiateERC20Deposit
, la función que gestiona el depósito ERC-20 actual.
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, ejecutar 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ñade la cantidad de tokens depositados a la estructura de datos de depósitos
. Puede haber varias direcciones en L2 que correspondan con el mismo token ERC-20 L1, por lo que no es suficiente con 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 L2 envía un mensaje al mensajero de dominio cruzado L2 que causa 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ía en L1, por supuesto).
1 ) external onlyFromCrossDomainAccount(l2TokenBridge) {Copiar
Asegúrate de que este es un mensaje legítimo, proveniente del mensajero de dominio cruzado y que se origina con el token de puente L2. Esta función se utiliza para retirar ETH del puente, así que tenemos que asegurarnos de que sólo es llamada 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 recipiente 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
Emitir 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 de ERC-20.
1 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] - _amount;Copiar
Actualizar la estructura de datos de depósitos
.
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
Hubo una implementación anterior del puente. Cuando pasamos de la implementación a ésta, tuvimos que mover todos los activos. Los tokens ERC-20 pueden moverse sin más. Sin embargo, para transferir ETH a un contrato necesitas 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 el puente estándar, y solo el puente estándar, para acuñar el token. 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 de 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 que funciones proporciona un contrato. Puedes 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 que está 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 acuñar (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 es 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 ERC-165(opens in a new tab) funciona. 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 comprobación de la cordura para asegurarse de que el contrato ERC-20 al que envía activos es un IL2StandardERC20
.
Nota: No hay nada que impida que un contrato deshonesto proporcione respuestas falsas a supportsInterface
, por lo que se trata de un mecanismo de comprobación de salubridad, no de 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
Sólo el puente L2 puede acuñar y quemar activos.
_mint
y _burn
están actualmente definidos en el contrato OpenZeppelin ERC-20. Ese contrato simplemente no los expone externamente, porque las condiciones para acuñar y quemar tokens son tan variadas como el número de maneras de usar ERC-20.
Código Puente L2
Este es el código que ejecuta el puente sobre 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 L1 equivalente 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 ETH en Optimism son manejados como un token ERC-20 con la dirección0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD000(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 L1, aquí necesitamos esta variable. La dirección del puente L1 no es conocida 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 digan 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
Ten en cuenta que no dependemos del parámetro _from
sino de 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
Comprobaciones de sanidad:
- La interfaz correcta está soportada
- La dirección del contrato ERC-20 L2 en L1 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 comprobaciones de sanidad se superan, finaliza el depósito:
- Acuña los tokens
- Emite 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 vía de hacerlo desde L2 es enviar un mensaje que tenga que esperar el período del desafío de 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 tan genérico no siempre es el mecanismo más fácil de utilizar. Especialmente para 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 requieren 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 corto en activos L1 transfiere suficientes activos de L2. Como se trata de retiros muy grandes, el coste de la retirada se amortiza sobre una gran cantidad y es 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 el código de Solidity de manera clara y segura.
Última edición: , 21 de febrero de 2024