Saltar al contenido principal

Análisis del contrato del puente estándar de Optimism

Solidity
puente
capa 2
Intermedio
Ori Pomerantz
30 de marzo de 2022
36 minutos de lectura

Optimism (opens in a new tab) es un rollup optimista. Los rollup optimistas pueden procesar transacciones por un precio mucho menor 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 por 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 la l1 en Optimism (o cualquier otra capa 2 (l2)), los activos necesitan ser puenteados. Una forma de lograr esto es que los usuarios bloqueen activos (ETH y tokens ERC-20 son los más comunes) en la l1, y reciban activos equivalentes para usar en la l2. Eventualmente, quien termine con ellos podría querer puentearlos de vuelta a la l1. Al hacer esto, los activos se queman en la l2 y luego se liberan de vuelta al usuario en la l1.

Esta es la forma en que 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 un ejemplo de código Solidity bien escrito.

Flujos de control

El puente tiene dos flujos principales:

  • Depósito (de la l1 a la l2)
  • Retiro (de la l2 a la l1)

Flujo de depósito

Capa 1

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

Capa 2

  1. El puente de la l2 verifica que la llamada a finalizeDeposit sea legítima:
    • Provino del contrato de mensajes entre dominios
    • Era originalmente del puente en la l1
  2. El puente de la l2 comprueba si el contrato del token ERC-20 en la l2 es el correcto:
    • El contrato de la l2 informa que su contraparte de la l1 es la misma de la que provinieron los tokens en la l1
    • El contrato de la l2 informa que es compatible con la interfaz correcta (usando ERC-165 (opens in a new tab)).
  3. Si el contrato de la l2 es el correcto, lo llama para acuñar el número apropiado de tokens a la dirección apropiada. Si no, inicia un proceso de retiro para permitir al usuario reclamar los tokens en la l1.

Flujo de retiro

Capa 2

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

Capa 1

  1. El puente de la l1 verifica que la llamada a finalizeETHWithdrawal o finalizeERC20Withdrawal sea legítima:
    • Provino del mecanismo de mensajes entre dominios
    • Era originalmente del puente en la l2
  2. El puente de la 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;

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

En la terminología del puente de Optimism, depósito significa transferencia de la l1 a la l2, y retiro significa una transferencia de la l2 a la l1.

        address indexed _l1Token,
        address indexed _l2Token,

En la mayoría de los casos, la dirección de un ERC-20 en la l1 no es la misma que la dirección del ERC-20 equivalente en la l2. Puede ver la lista de direcciones de tokens aquí (opens in a new tab). La dirección con chainId 1 está en la l1 (Red principal) y la dirección con chainId 10 está en la l2 (Optimism). Los otros dos valores de chainId son para la red de pruebas Kovan (42) y la red de pruebas 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 reportan.

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

El mismo contrato del puente maneja las transferencias en ambas direcciones. En el caso del puente de la l1, esto significa la inicialización de depósitos y la finalización de retiros.

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

El parámetro _l2Gas es la cantidad de gas de la l2 que la transacción tiene permitido gastar. Hasta un cierto límite (alto), esto es gratis (opens in a new tab), por lo que a menos que el contrato ERC-20 haga algo realmente extraño al 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.

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

Los retiros (y otros mensajes de la l2 a la l1) en Optimism son un proceso de dos pasos:

  1. Una transacción de inicio en la l2.
  2. Una transacción de finalización o reclamo en la l1. Esta transacción debe ocurrir después de que termine el período de desafío de fallas (opens in a new tab) para la transacción de la 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 del puente se divide en 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 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 tiene las direcciones de los tokens de la l1 y la l2. Lo mismo ocurre con los otros eventos y las 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;

/* Importaciones de interfaces */
import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";

Esta interfaz (opens in a new tab) le dice 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 conocer, 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 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 solo confíe en ciertos mensajes si provienen del puente del otro lado.

        require(
            msg.sender == address(getCrossDomainMessenger()),
            "OVM_XCHAIN: messenger contract unauthenticated"
        );

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


        require(
            getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,
            "OVM_XCHAIN: wrong sender of cross-domain message"
        );

La forma en que el mensajero entre dominios proporciona la dirección que envió un mensaje con 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.

Necesitamos asegurarnos de que el mensaje que recibimos provino del otro puente.

Esta función devuelve el mensajero entre dominios. Usamos una función en lugar de la variable messenger para permitir que los contratos que heredan de este utilicen 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 desencadena 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, sabemos que getCrossDomainMessenger() devuelve una dirección confiable, incluso si Slither no tiene forma de saberlo.

El contrato del puente de la 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 soportar 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.

/* Importaciones de interfaces */
import { IL1StandardBridge } from "./IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";

IL1ERC20Bridge e IL1StandardBridge se explican anteriormente.

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 la l2.

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

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

/* Importaciones de bibliotecas */
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 para los contratos de la l2 que siempre tienen la misma dirección. Esto incluye el puente estándar en la l2.

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

Utilidades de Address de OpenZeppelin (opens in a new tab). Se utiliza para distinguir entre direcciones de contratos y aquellas 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 que nuestro código fuera más complicado, por lo que en su lugar usamos SafeERC20 de OpenZeppelin (opens in a new tab), que se asegura de que todos los fallos resulten en revertir (opens in a new tab).

Esta línea es cómo especificamos usar el envoltorio SafeERC20 cada vez que usamos la interfaz IERC20.


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

    address public l2TokenBridge;

La dirección de L2StandardBridge.


    // Mapea el token de l1 al token de l2 al saldo del token de l1 depositado
    mapping(address => mapping(address => uint256)) public deposits;

Un mapping (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[L1 token addr][L2 token addr]. El valor predeterminado 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 hacer eso usamos un Proxy (opens in a new tab), un contrato que usa 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 usa 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 de 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 funciones que no se llaman 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 se les pueden proporcionar parámetros en los datos de llamada. Las funciones declaradas como public tienen que ser accesibles desde dentro del contrato. Los contratos no pueden modificar sus propios datos de llamada, por lo que los parámetros tienen que estar en la memoria. Cuando una función de este tipo se llama 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), "Contract has already been initialized.");

La función initialize solo debe llamarse una vez. Si la dirección del mensajero entre dominios de la l1 o del puente de tokens de la l2 cambia, creamos un nuevo proxy y un nuevo puente que lo llama. Es poco probable que esto suceda, excepto cuando se actualiza todo el sistema, un hecho 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 despleguemos 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 que el usuario legítimo. Pero hay dos métodos para prevenir esto:

  1. Si los contratos no son desplegados 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 unos 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 Address de OpenZeppelin.

Esta función existe con fines de prueba. Note que no aparece en las definiciones de la interfaz: no es para uso normal.

Estas dos funciones son envoltorios alrededor de _initiateETHDeposit, la función que maneja 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 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 la l1
_l2TokenLib_PredeployAddresses.OVM_ETHEl contrato de la l2 que gestiona ETH en Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (este contrato es solo para uso interno de Optimism)
_from_fromLa dirección en la l1 que envía el ETH
_to_toLa dirección en la l2 que recibe el ETH
amountmsg.valueCantidad de Wei enviada (que ya ha sido enviada 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);

Envía el mensaje a través del mensajero entre dominios.

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

Emite un evento para informar a cualquier aplicación descentralizada (dapp) que escuche sobre esta transferencia.

Estas dos funciones son envoltorios alrededor de _initiateERC20Deposit, la función que maneja 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 futuros
        // retiros. 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) le da una asignación 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ñade la cantidad depositada de tokens a la estructura de datos deposits. Podría haber múltiples direcciones en la l2 que correspondan al mismo token ERC-20 de la l1, por lo que no es suficiente usar el saldo del puente del token ERC-20 de la l1 para realizar un seguimiento de los depósitos.

El puente de la l2 envía un mensaje al mensajero entre dominios de la l2, lo que hace que el mensajero entre dominios de la 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 la l1, 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 la 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: ETH transfer failed");

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

Emite un evento sobre el retiro.

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

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

Actualiza la estructura de datos deposits.

Hubo 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 la 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, acuñe el token. Esto es necesario porque los puentes necesitan asegurarse de que el número de tokens que circulan en Optimism sea igual al número de tokens bloqueados dentro del contrato del puente de la l1. Si hay demasiados tokens en la l2, algunos usuarios no podrían puentear sus activos de vuelta a la l1. En lugar de un puente confiable, esencialmente recrearíamos la banca de reserva fraccionaria (opens in a new tab). Si hay demasiados tokens en la l1, algunos de esos tokens se quedarían bloqueados dentro del contrato del puente para siempre porque no hay forma de liberarlos sin quemar tokens de la l2.

IL2StandardERC20

Cada token ERC-20 en la 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 la l1 que está puenteado a este contrato. Tenga en cuenta que no tenemos una función similar en la dirección opuesta. Necesitamos poder puentear cualquier token de la l1, independientemente de si el soporte para la l2 estaba 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 acuñar (crear) y quemar (destruir) tokens. El puente debería ser la única entidad que pueda ejecutar estas funciones para asegurar que el número de tokens sea correcto (igual al número de tokens bloqueados en la 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 mantener 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 requiere.

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

Esta es la forma en que funciona ERC-165 (opens in a new tab). Cada interfaz es un número de funciones compatibles, y se identifica como el o exclusivo (opens in a new tab) de los selectores de funciones de la ABI (opens in a new tab) de esas funciones.

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

Solo el puente de la l2 tiene permitido acuñar y quemar activos.

_mint y _burn están realmente definidos en el contrato ERC-20 de OpenZeppelin. Ese contrato simplemente no los expone externamente, porque las condiciones para acuñar y quemar tokens son tan variadas como el número de formas de usar ERC-20.

Código del puente de la 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;

/* Importaciones de interfaces */
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 la l1 que vimos anteriormente. Hay dos diferencias significativas:

  1. En la l1 se inician depósitos y se finalizan retiros. Aquí se inician retiros y se finalizan depósitos.
  2. En la l1 es necesario distinguir entre ETH y tokens ERC-20. En la l2 podemos usar las mismas funciones para ambos porque internamente los saldos de ETH en Optimism se manejan como un token ERC-20 con la dirección 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab).

Realiza un seguimiento de la dirección del puente de la l1. Tenga en cuenta que, en contraste con el equivalente de la l1, aquí necesitamos esta variable. La dirección del puente de la l1 no se conoce de antemano.

Estas dos funciones inician retiros. Tenga en cuenta que no hay necesidad de especificar la dirección del token de la l1. Se espera que los tokens de la l2 nos digan la dirección del equivalente de la l1.

Note que no dependemos del parámetro _from sino de msg.sender, que es mucho más difícil de falsificar (imposible, hasta donde yo sé).


        // 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 la l1 es necesario distinguir entre ETH y ERC-20.

Esta función es llamada por L1StandardBridge.

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

Asegúrese de que la fuente del mensaje sea legítima. 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 la 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 cordura:

  1. Se admite la interfaz correcta
  2. La dirección de la l1 del contrato ERC-20 de la l2 coincide con la fuente de la l1 de los tokens
        ) {
            // 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 cordura pasan, finaliza el depósito:

  1. Acuña los tokens
  2. Emite el evento apropiado

Si un usuario cometió un error detectable al usar la dirección incorrecta del token de la l2, queremos cancelar el depósito y devolver los tokens en la l1. La única forma en que podemos hacer esto desde la l2 es enviar un mensaje que tendrá que esperar el período de desafío de fallas, 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 los retiros, la mayoría de los usuarios prefieren usar puentes de terceros (opens in a new tab) que no esperan el período de desafío y no requieren una prueba de Merkle para finalizar el retiro.

Estos puentes generalmente funcionan teniendo activos en la l1, que proporcionan de inmediato por una pequeña tarifa (a menudo menor que el costo del gas para un retiro del puente estándar). Cuando el puente (o las personas que lo administran) anticipa que le faltarán activos en la l1, transfiere suficientes activos desde la l2. Como estos son retiros muy grandes, el costo del retiro se amortiza sobre una gran cantidad y es un porcentaje mucho menor.

Con suerte, este artículo le ayudó a comprender más sobre cómo funciona la capa 2 y cómo escribir código Solidity que sea claro y seguro.

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