Saltar al contenido principal

Recorrido por el contrato del puente estándar de Optimism

Solidity
puente
capa 2
Intermedio
Ori Pomerantz
30 de marzo de 2022
35 minuto leído

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 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, todos los datos se escriben en la L1 para que todo pueda probarse y reconstruirse con todas las garantías de integridad y disponibilidad de la red principal.

Para usar activos de L1 en Optimism (o cualquier otra L2), los activos deben ser transferidos mediante un puente. Una forma de lograr esto es que los usuarios bloqueen activos (ETH y los tokens ERC-20 son los más comunes) en L1, y reciban activos equivalentes para usar en L2. Eventualmente, quien termine con ellos podría querer puentearlos de vuelta a L1. Al hacer esto, los activos se queman en L2 y luego se liberan de nuevo al usuario en L1.

Así es como funciona el puente estándar de Optimism (opens in a new tab). En este artículo, repasamos el código fuente de ese puente para ver cómo funciona y estudiarlo como ejemplo de código de Solidity bien escrito.

Flujos de control

El puente tiene dos flujos principales:

  • Depósito (de L1 a L2)
  • Retirada (de L2 a L1)

Flujo de depósito

Capa 1

  1. Si se deposita un ERC-20, el depositante le da al puente un permiso para gastar la cantidad que se está depositando
  2. El depositante llama al puente de L1 (depositERC20, depositERC20To, depositETH, o depositETHTo)
  3. 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 el permiso proporcionado por el depositante
  4. El puente L1 utiliza el mecanismo de mensajes entre dominios para llamar a finalizeDeposit en el puente L2

Capa 2

  1. El puente de L2 verifica que la llamada a finalizeDeposit sea legítima:
    • Procede del contrato de mensajes entre dominios
    • Originalmente era del puente en L1
  2. El puente de L2 comprueba si el contrato de token ERC-20 en L2 es el correcto:
    • El contrato de L2 informa que su contraparte de L1 es la misma de la que provienen los tokens en L1
    • El contrato de L2 informa que admite la interfaz correcta (usando ERC-165 (opens in a new tab)).
  3. 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 retirada para permitir al usuario reclamar los tokens en L1.

Flujo de retirada

Capa 2

  1. Quien realiza la retirada llama al puente de L2 (withdraw o withdrawTo)
  2. El puente L2 quema el número apropiado de tokens pertenecientes a msg.sender
  3. El puente de L2 utiliza el mecanismo de mensajes entre dominios para llamar a finalizeETHWithdrawal o finalizeERC20Withdrawal en el puente de L1

Capa 1

  1. El puente de L1 verifica que la llamada a finalizeETHWithdrawal o finalizeERC20Withdrawal sea legítima:
    • Procede del mecanismo de mensajes entre dominios
    • Originalmente era del puente en L2
  2. El puente de L1 transfiere el activo apropiado (ETH o ERC-20) a la dirección apropiada

Código de la capa 1

Este es el código que se ejecuta en la 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.

// SPDX-License-Identifier: MIT

La mayor parte del código de Optimism se publica bajo la licencia MIT (opens in a new tab).

pragma solidity >0.5.0 <0.9.0;

En el momento de escribir este artículo, la última versión de Solidity es la 0.8.12. Hasta que se lance la versión 0.9.0, no sabemos si este código es compatible con ella o no.

En la terminología del puente de Optimism, deposit significa una transferencia de L1 a L2, y withdrawal significa una transferencia de L2 a L1.

        address indexed _l1Token,
        address indexed _l2Token,

En la mayoría de los casos, la dirección de un ERC-20 en L1 no es la misma que la 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 de chainId son para la red de prueba de Kovan (42) y la red de prueba de Optimistic Kovan (69).

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

Es posible añadir notas a las transferencias, en cuyo caso se añaden a los eventos que las notifican.

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

El mismo contrato de puente gestiona las transferencias en ambas direcciones. En el caso del puente de L1, esto significa la inicialización de los depósitos y la finalización de las retiradas.

Esta función no es realmente necesaria, porque en L2 es un contrato preimplementado, por lo que siempre está en la dirección 0x4200000000000000000000000000000000000010. Está aquí por simetría con el puente de L2, porque la dirección del puente de L1 no es trivial de conocer.

El parámetro _l2Gas es la cantidad de gas de L2 que la transacción puede gastar. Hasta un cierto límite (alto), esto es gratuito (opens in a new tab), por lo que, a menos que el contrato ERC-20 haga algo realmente extraño al mintear, no debería ser un problema. Esta función se encarga del escenario común, en el que un usuario transfiere activos mediante un puente a la misma dirección en una cadena de bloques diferente.

Esta función es casi idéntica a depositERC20, pero le permite enviar el ERC-20 a una dirección diferente.

Las retiradas (y otros mensajes de L2 a L1) en Optimism son un proceso de dos pasos:

  1. Una transacción iniciadora en L2.
  2. Una transacción de finalización o reclamación en L1. Esta transacción debe ocurrir después de que finalice el período de impugnación de errores (opens in a new tab) para la transacción de 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 anteriormente 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 gestiona dicho token puede implementar IL1ERC20Bridge y no tener que puentear también ETH.

Este evento es casi idéntico a la versión ERC-20 (ERC20DepositInitiated), excepto que no incluye las direcciones de los tokens de L1 y L2. Lo mismo ocurre con los demás eventos y funciones.

CrossDomainEnabled

Este contrato (opens in a new tab) es heredado por ambos puentes (L1 y L2) para enviar mensajes a la otra capa.

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

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

Esta interfaz (opens in a new tab) le indica al contrato cómo enviar mensajes a la otra capa, utilizando el mensajero entre dominios. Este mensajero entre dominios es un sistema completamente diferente y merece su propio artículo, que espero escribir en el futuro.

El único parámetro que el contrato necesita saber es la dirección del mensajero entre dominios en esta capa. Este parámetro se establece una vez, en el constructor, y nunca cambia.

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 confíe únicamente en ciertos mensajes si provienen del puente del otro lado.

        require(
            msg.sender == address(getCrossDomainMessenger()),
            "OVM_XCHAIN: contrato de mensajero no autenticado"
        );

Solo se puede confiar en los mensajes del mensajero entre dominios apropiado (messenger, como verá a continuación).


        require(
            getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,
            "OVM_XCHAIN: remitente incorrecto del mensaje entre dominios"
        );

La forma en que el mensajero entre dominios proporciona la dirección que envió un mensaje desde la otra capa es la función .xDomainMessageSender() (opens in a new tab). Siempre que 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 provenga del otro puente.

Esta función devuelve el mensajero entre dominios. Utilizamos una función en lugar de la variable messenger para permitir que los contratos que heredan de este usen un algoritmo para especificar qué mensajero entre dominios usar.

Finalmente, la función que envía un mensaje a la otra capa.

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

Slither (opens in a new tab) es un analizador estático que Optimism ejecuta en cada contrato para buscar vulnerabilidades y otros problemas potenciales. En este caso, la siguiente línea activa dos vulnerabilidades:

  1. Eventos de reentrada (opens in a new tab)
  2. Reentrada benigna (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

En este caso, no nos preocupa la reentrada, ya que sabemos que getCrossDomainMessenger() devuelve una dirección confiable, incluso si Slither no tiene forma de saberlo.

El contrato del puente de L1

El código fuente de este contrato está aquí (opens in a new tab).

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

Las interfaces pueden ser parte de otros contratos, por lo que tienen que admitir una amplia gama de versiones de Solidity. Pero el puente en sí es nuestro contrato, y podemos ser estrictos sobre qué versión de Solidity utiliza.

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

IL1ERC20Bridge e IL1StandardBridge se explican más arriba.

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

Esta interfaz (opens in a new tab) nos permite crear mensajes para controlar el puente estándar en L2.

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

Esta interfaz (opens in a new tab) nos permite controlar los contratos ERC-20. Puede leer más al respecto aquí.

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

Como se explicó anteriormente, este contrato se utiliza para la mensajería entre capas.

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

Lib_PredeployAddresses (opens in a new tab) tiene las direcciones de los contratos de L2 que siempre tienen la misma dirección. Esto incluye el puente estándar en L2.

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

Utilidades de dirección de OpenZeppelin (opens in a new tab). Se utiliza para distinguir entre las direcciones de 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 realizadas desde el constructor de un contrato, pero al menos esto nos permite identificar y prevenir algunos errores comunes de los usuarios.

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

El estándar ERC-20 (opens in a new tab) admite dos formas para que un contrato informe de un fallo:

  1. Revertir
  2. Devolver false

Manejar ambos casos haría nuestro código más complicado, así que en su lugar usamos SafeERC20 de OpenZeppelin (opens in a new tab), que se asegura de que todos los fallos resulten en una reversión (opens in a new tab).

Esta línea es la forma en que especificamos que se use el contenedor SafeERC20 cada vez que usamos la interfaz IERC20.


    /********************************
     * Referencias a contratos externos *
     ********************************/

    address public l2TokenBridge;

La dirección de L2StandardBridge.


    // Asigna el token de L1 al token de L2 al saldo del token de L1 depositado
    mapping(address => mapping(address => uint256)) public deposits;

Un mapeo (opens in a new tab) doble como este es la forma en que se define una matriz dispersa bidimensional (opens in a new tab). Los valores en esta estructura de datos se identifican como deposit[dirección de token L1][dirección de token L2]. El valor por defecto es cero. Solo las celdas que se establecen en un valor diferente se escriben en el almacenamiento.


    /***************
     * Constructor *
     ***************/

    // Este contrato vive detrás de un proxy, por lo que los parámetros del constructor no se utilizarán.
    constructor() CrossDomainEnabled(address(0)) {}

Queremos poder actualizar este contrato sin tener que copiar todas las variables en el almacenamiento. Para ello, utilizamos un Proxy (opens in a new tab), un contrato que utiliza delegatecall (opens in a new tab) para transferir llamadas a un contrato separado cuya dirección es almacenada por el contrato proxy (cuando se actualiza, se le dice al proxy que cambie esa dirección). Cuando se utiliza delegatecall, el almacenamiento sigue siendo el almacenamiento del contrato que llama, por lo que los valores de todas las variables de estado del contrato no se ven afectados.

Un efecto de este patrón es que el almacenamiento del contrato que es el llamado de delegatecall no se utiliza y, por lo tanto, los valores del constructor que se le pasan 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 está separada del constructor.

Esta prueba de Slither (opens in a new tab) identifica las funciones que no se llaman desde el código del contrato y que, por lo tanto, podrían declararse como external en lugar de public. El coste de gas de las funciones external puede ser menor, porque se les pueden proporcionar parámetros en los datos de llamada. Las funciones declaradas como public deben ser accesibles desde dentro del 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 una función de este tipo externamente, es necesario copiar los datos de llamada a la memoria, lo que cuesta gas. En este caso, la función solo se llama una vez, por lo que la ineficiencia no nos importa.

    function initialize(address _l1messenger, address _l2TokenBridge) public {
        require(messenger == address(0), "El contrato ya ha sido inicializado.");

La función initialize solo debe llamarse una vez. Si la dirección del mensajero entre dominios de L1 o del puente de tokens de L2 cambia, creamos un nuevo proxy y un nuevo puente que lo llama. Es poco probable que esto ocurra, excepto cuando se actualiza todo el sistema, lo que es muy raro.

Tenga en cuenta que esta función no tiene ningún mecanismo que restrinja quién puede llamarla. 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-running (opens in a new tab) para llegar a la función initialize antes de que lo haga el usuario legítimo. Pero hay dos métodos para evitarlo:

  1. Si los contratos no son implementados directamente por una EOA, sino en una transacción que hace que otro contrato los cree (opens in a new tab), todo el proceso puede ser atómico y terminar antes de que se ejecute cualquier otra transacción.
  2. Si la llamada legítima a initialize falla, siempre es posible ignorar el proxy y el puente recién creados y crear otros nuevos.
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

Estos son los dos parámetros que el puente necesita conocer.

Esta es la razón por la que necesitábamos las utilidades de Address de OpenZeppelin.

Esta función existe para fines de prueba. Tenga en cuenta que no aparece en las definiciones de la interfaz; no es para uso normal.

Estas dos funciones son envoltorios de _initiateETHDeposit, la función que gestiona el depósito real de ETH.

La forma en que funcionan los mensajes entre dominios es que se llama al contrato de destino con el mensaje como sus datos de llamada. Los contratos de Solidity siempre interpretan 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.

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

El mensaje aquí es para llamar a la función finalizeDeposit (opens in a new tab) con estos parámetros:

ParámetroValorSignificado
_l1Tokenaddress(0)Valor especial para representar ETH (que no es un token ERC-20) en L1
_l2TokenLib_PredeployAddresses.OVM_ETHEl contrato de L2 que gestiona ETH en Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (este contrato es solo para uso interno de Optimism)
_from_fromLa dirección en L1 que envía el ETH
_to_toLa dirección en L2 que recibe el ETH
cantidadmsg.valueCantidad de wei enviados (que ya han sido enviados al puente)
_data_dataDatos adicionales para adjuntar al depósito
        // Enviar datos de llamada a L2
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

Enviar el mensaje a través del mensajero entre dominios.

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

Emitir un evento para informar de esta transferencia a cualquier aplicación descentralizada que escuche.

Estas dos funciones son envoltorios de _initiateERC20Deposit, la función que gestiona el depósito real de ERC-20.

Esta función es similar a _initiateETHDeposit anterior, con algunas diferencias importantes. La primera diferencia es que esta función recibe las direcciones de los tokens 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).

        // Cuando se inicia un depósito en L1, el puente de L1 transfiere los fondos a sí mismo para futuras
        // retiradas. safeTransferFrom también comprueba si el contrato tiene código, por lo que esto fallará si
        // _from es una EOA o address(0).
        // slither-disable-next-line reentrancy-events, reentrancy-benign
        IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);

Las transferencias de tokens ERC-20 siguen un proceso diferente al de ETH:

  1. El usuario (_from) otorga un permiso al puente para transferir los tokens apropiados.
  2. El usuario llama al puente con la dirección del contrato del token, la cantidad, etc.
  3. 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, el 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.

Añada la cantidad de tokens depositados a la estructura de datos deposits. Podría haber varias direcciones en L2 que correspondan al mismo token ERC-20 de L1, por lo que no es suficiente usar el saldo del puente del token ERC-20 de L1 para hacer un seguimiento de los depósitos.

El puente de L2 envía un mensaje al mensajero entre dominios de L2, lo que hace que el mensajero entre dominios de L1 llame a esta función (una vez que se envíe en L1 la transacción que finaliza el mensaje (opens in a new tab), por supuesto).

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

Asegúrese de que este sea un mensaje legítimo, proveniente del mensajero entre dominios y originado en el puente de tokens de L2. Esta función se utiliza para retirar ETH del puente, por lo que tenemos que asegurarnos de que solo sea llamada por el llamador autorizado.

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

La forma de transferir ETH es llamar al destinatario con la cantidad de wei en el msg.value.

        require(success, "TransferHelper::safeTransferETH: Falló la transferencia de ETH");

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

Emitir un evento sobre la retirada.

Esta función es similar a finalizeETHWithdrawal anterior, con los cambios necesarios para los tokens ERC-20.

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

Actualizar la estructura de datos deposits.

Había una implementación anterior del puente. Cuando pasamos de esa implementación a esta, tuvimos que mover todos los activos. Los tokens ERC-20 simplemente se pueden mover. Sin embargo, para transferir ETH a un contrato se necesita la aprobación de ese contrato, que es lo que nos proporciona donateETH.

Tokens ERC-20 en L2

Para que un token ERC-20 encaje en el 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 bloqueados dentro del contrato de puente de 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, estaríamos esencialmente recreando la 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 tokens de L2.

IL2StandardERC20

Cada token ERC-20 en L2 que utiliza el puente estándar necesita proporcionar esta interfaz (opens in a new tab), que tiene las funciones y eventos que el puente estándar necesita.

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

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

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), que deja sin especificar los mecanismos para crear y destruir tokens.

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

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).

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

Esta función proporciona la dirección del token de L1 que se transfiere a este contrato mediante un puente. Tenga en cuenta que no tenemos una función similar en la dirección opuesta. Necesitamos poder puentear cualquier token de L1, independientemente de si el soporte de L2 fue planeado cuando se implementó o no.


    function mint(address _to, uint256 _amount) external;

    function burn(address _from, uint256 _amount) external;

    event Mint(address indexed _account, uint256 _amount);
    event Burn(address indexed _account, uint256 _amount);
}

Funciones y eventos para mintear (crear) y quemar (destruir) tokens. El puente debe ser la única entidad que pueda ejecutar estas funciones para garantizar 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 usar esta.

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

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

El contrato ERC-20 de OpenZeppelin (opens in a new tab). Optimism no cree en reinventar la rueda, especialmente cuando la rueda está bien auditada y necesita ser lo suficientemente confiable como para albergar activos.

import "./IL2StandardERC20.sol";

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

Estos son los dos parámetros de configuración adicionales que requerimos y que ERC-20 normalmente no tiene.

Primero, llame al constructor del contrato del que heredamos (ERC20(_name, _symbol)) y luego establezca nuestras propias variables.

Así es como funciona ERC-165 (opens in a new tab). Cada interfaz es un número de funciones admitidas, y se identifica como el o exclusivo (opens in a new tab) de los selectores de función de ABI (opens in a new tab) de esas funciones.

El puente de L2 utiliza ERC-165 como una comprobación de sanidad 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 malicioso proporcione respuestas falsas a supportsInterface, por lo que este es un mecanismo de comprobación de sanidad, no un mecanismo de seguridad.

Solo el puente de L2 puede mintear y quemar activos.

_mint y _burn se definen en realidad en el contrato ERC-20 de OpenZeppelin. Ese contrato simplemente no los expone externamente, porque las condiciones para mintear y quemar tokens son tan variadas como el número de formas de usar ERC-20.

Código del puente de L2

Este es el código que ejecuta el puente en Optimism. El código fuente de este contrato está aquí (opens in a new tab).

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

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

La interfaz IL2ERC20Bridge (opens in a new tab) es muy similar al equivalente de L1 que vimos anteriormente. Existen dos diferencias significativas:

  1. En L1 se inician los depósitos y se finalizan las retiradas. Aquí se inician las retiradas y se finalizan los depósitos.
  2. 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 se gestionan como un token ERC-20 con la dirección 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab).

Mantener un registro de la dirección del puente de L1. Tenga en cuenta que, a diferencia del equivalente de L1, aquí necesitamos esta variable. La dirección del puente de L1 no se conoce de antemano.

Estas dos funciones inician las retiradas. Tenga en cuenta que no es necesario especificar la dirección del token de L1. Se espera que los tokens de L2 nos indiquen la dirección equivalente en L1.

Tenga en cuenta que no estamos confiando en el parámetro _from, sino en msg.sender, que es mucho más difícil de falsificar (imposible, que yo sepa).


        // Construir datos de llamada para l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)
        // slither-disable-next-line reentrancy-events
        address l1Token = IL2StandardERC20(_l2Token).l1Token();
        bytes memory message;

        if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {

En L1 es necesario distinguir entre ETH y ERC-20.

Esta función es llamada por L1StandardBridge.

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

Asegúrese de que el origen del mensaje sea legítimo. Esto es importante porque esta función llama a _mint y podría usarse para dar tokens que no están cubiertos por los tokens que el puente posee en L1.

        // Comprobar que el token de destino es compatible y
        // verificar que el token depositado en L1 coincide con la representación del token depositado en L2 aquí
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()

Comprobaciones de sanidad:

  1. La interfaz correcta es compatible
  2. La dirección de L1 del contrato ERC-20 de L2 coincide con el origen de los tokens en L1
        ) {
            // Cuando se finaliza un depósito, acreditamos la cuenta en L2 con la misma cantidad de
            // tokens.
            // slither-disable-next-line reentrancy-events
            IL2StandardERC20(_l2Token).mint(_to, _amount);
            // slither-disable-next-line reentrancy-events
            emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);

Si las comprobaciones de sanidad pasan, finalice el depósito:

  1. Mintear los tokens
  2. Emitir el evento apropiado

Si un usuario cometió un error detectable al usar la dirección de token de L2 incorrecta, queremos cancelar el depósito y devolver los tokens en L1. La única forma en que podemos hacer esto desde L2 es enviar un mensaje que tendrá que esperar el período de impugnación de errores, pero eso es mucho mejor para el usuario que perder los tokens permanentemente.

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 usar. Especialmente para las retiradas, la mayoría de los usuarios prefieren usar puentes de terceros (opens in a new tab) que no esperan el período de impugnación y no requieren una prueba de Merkle para finalizar la retirada.

Estos puentes suelen funcionar teniendo activos en L1, que proporcionan inmediatamente por una pequeña tarifa (a menudo menor que el coste de gas para una retirada de puente estándar). Cuando el puente (o las personas que lo gestionan) prevé que se quedará sin activos en L1, transfiere suficientes activos desde L2. Como se trata de retiradas muy grandes, el coste de la retirada se amortiza sobre una gran cantidad y es un porcentaje mucho menor.

Esperamos 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 que sea claro y seguro.

Vea aquí más de mi trabajo (opens in a new tab).

Última actualización de la página: 3 de abril de 2026

¿Te resultó útil este tutorial?