Saltar al contenido principal

ERC-20 con medidas de seguridad

erc-20
Principiante
Ori Pomerantz
15 de agosto de 2022
9 minutos de lectura

Introducción

Una de las grandes ventajas de Ethereum es que no existe una autoridad central que pueda modificar o deshacer sus transacciones. Uno de los grandes problemas de Ethereum es que no existe una autoridad central con el poder de deshacer los errores de los usuarios o las transacciones ilícitas. En este artículo aprenderá sobre algunos de los errores comunes que cometen los usuarios con los tokens ERC-20, así como a crear contratos ERC-20 que ayuden a los usuarios a evitar esos errores, o que otorguen cierto poder a una autoridad central (por ejemplo, para congelar cuentas).

Tenga en cuenta que, aunque utilizaremos el contrato de token ERC-20 de OpenZeppelin (opens in a new tab), este artículo no lo explica en gran detalle. Puede encontrar esta información aquí.

Si desea ver el código fuente completo:

  1. Abra el IDE de Remix (opens in a new tab).
  2. Haga clic en el icono de clonar de GitHub (clone github icon).
  3. Clone el repositorio de GitHub https://github.com/qbzzt/20220815-erc20-safety-rails.
  4. Abra contracts > erc20-safety-rails.sol.

Creación de un contrato ERC-20

Antes de poder añadir la funcionalidad de medidas de seguridad, necesitamos un contrato ERC-20. En este artículo utilizaremos el asistente de contratos de OpenZeppelin (opens in a new tab). Ábralo en otro navegador y siga estas instrucciones:

  1. Seleccione ERC20.

  2. Introduzca esta configuración:

    ParámetroValor
    NombreSafetyRailsToken
    SímboloSAFE
    Acuñación previa1000
    CaracterísticasNinguna
    Control de accesoOwnable
    Capacidad de actualizaciónNinguna
  3. Desplácese hacia arriba y haga clic en Open in Remix (para Remix) o en Download para usar un entorno diferente. Voy a suponer que está usando Remix; si usa otra cosa, simplemente haga los cambios correspondientes.

  4. Ahora tenemos un contrato ERC-20 completamente funcional. Puede expandir .deps > npm para ver el código importado.

  5. Compile, despliegue y juegue con el contrato para ver que funciona como un contrato ERC-20. Si necesita aprender a usar Remix, use este tutorial (opens in a new tab).

Errores comunes

Los errores

A veces, los usuarios envían tokens a la dirección equivocada. Aunque no podemos leer sus mentes para saber qué querían hacer, hay dos tipos de errores que ocurren a menudo y son fáciles de detectar:

  1. Enviar los tokens a la propia dirección del contrato. Por ejemplo, el token OP de Optimism (opens in a new tab) logró acumular más de 120.000 (opens in a new tab) tokens OP en menos de dos meses. Esto representa una cantidad significativa de riqueza que presumiblemente la gente simplemente perdió.

  2. Enviar los tokens a una dirección vacía, una que no corresponde a una cuenta de propiedad externa (EOA) o a un contrato inteligente. Aunque no tengo estadísticas sobre la frecuencia con la que esto sucede, un incidente podría haber costado 20.000.000 de tokens (opens in a new tab).

Prevención de transferencias

El contrato ERC-20 de OpenZeppelin incluye un gancho (hook), _beforeTokenTransfer (opens in a new tab), que se llama antes de que se transfiera un token. Por defecto, este gancho no hace nada, pero podemos colgar nuestra propia funcionalidad en él, como comprobaciones que revierten si hay un problema.

Para usar el gancho, añada esta función después del constructor:

    function _beforeTokenTransfer(address from, address to, uint256 amount)
        internal virtual
        override(ERC20)
    {
        super._beforeTokenTransfer(from, to, amount);
    }

Algunas partes de esta función pueden ser nuevas si no está muy familiarizado con Solidity:

        internal virtual

La palabra clave virtual significa que, al igual que heredamos la funcionalidad de ERC20 y anulamos esta función, otros contratos pueden heredar de nosotros y anular esta función.

        override(ERC20)

Tenemos que especificar explícitamente que estamos anulando (opens in a new tab) la definición del token ERC20 de _beforeTokenTransfer. En general, las definiciones explícitas son mucho mejores, desde el punto de vista de la seguridad, que las implícitas: no puede olvidar que ha hecho algo si está justo frente a usted. Esa es también la razón por la que necesitamos especificar qué _beforeTokenTransfer de la superclase estamos anulando.

        super._beforeTokenTransfer(from, to, amount);

Esta línea llama a la función _beforeTokenTransfer del contrato o contratos de los que heredamos que la tienen. En este caso, eso es solo ERC20, Ownable no tiene este gancho. Aunque actualmente ERC20._beforeTokenTransfer no hace nada, lo llamamos en caso de que se añada funcionalidad en el futuro (y luego decidamos volver a desplegar el contrato, porque los contratos no cambian después del despliegue).

Codificación de los requisitos

Queremos añadir estos requisitos a la función:

  • La dirección to no puede ser igual a address(this), la dirección del propio contrato ERC-20.
  • La dirección to no puede estar vacía, tiene que ser:
    • Una cuenta de propiedad externa (EOA). No podemos comprobar si una dirección es una EOA directamente, pero podemos comprobar el saldo de ETH de una dirección. Las EOA casi siempre tienen un saldo, incluso si ya no se usan: es difícil vaciarlas hasta el último Wei.
    • Un contrato inteligente. Comprobar si una dirección es un contrato inteligente es un poco más difícil. Hay un código de operación que comprueba la longitud del código externo, llamado EXTCODESIZE (opens in a new tab), pero no está disponible directamente en Solidity. Tenemos que usar Yul (opens in a new tab), que es el ensamblador de la EVM, para ello. Hay otros valores que podríamos usar desde Solidity (<address>.code y <address>.codehash (opens in a new tab)), pero cuestan más.

Repasemos el nuevo código línea por línea:

        require(to != address(this), "Can't send tokens to the contract address");

Este es el primer requisito, comprobar que to y this(address) no son lo mismo.

        bool isToContract;
        assembly {
           isToContract := gt(extcodesize(to), 0)
        }

Así es como comprobamos si una dirección es un contrato. No podemos recibir la salida directamente de Yul, así que en su lugar definimos una variable para guardar el resultado (isToContract en este caso). La forma en que funciona Yul es que cada código de operación se considera una función. Así que primero llamamos a EXTCODESIZE (opens in a new tab) para obtener el tamaño del contrato, y luego usamos GT (opens in a new tab) para comprobar que no es cero (estamos tratando con enteros sin signo, así que por supuesto no puede ser negativo). Luego escribimos el resultado en isToContract.

        require(to.balance != 0 || isToContract, "Can't send tokens to an empty address");

Y finalmente, tenemos la comprobación real de direcciones vacías.

Acceso administrativo

A veces es útil tener un administrador que pueda deshacer errores. Para reducir el potencial de abuso, este administrador puede ser una multifirma (opens in a new tab) para que varias personas tengan que estar de acuerdo en una acción. En este artículo tendremos dos características administrativas:

  1. Congelar y descongelar cuentas. Esto puede ser útil, por ejemplo, cuando una cuenta podría estar comprometida.

  2. Limpieza de activos.

    A veces, los estafadores envían tokens fraudulentos al contrato del token real para ganar legitimidad. Por ejemplo, vea aquí (opens in a new tab). El contrato ERC-20 legítimo es 0x4200....0042 (opens in a new tab). La estafa que pretende serlo es 0x234....bbe (opens in a new tab).

    También es posible que la gente envíe tokens ERC-20 legítimos a nuestro contrato por error, lo cual es otra razón para querer tener una forma de sacarlos.

OpenZeppelin proporciona dos mecanismos para habilitar el acceso administrativo:

En aras de la simplicidad, en este artículo usamos Ownable.

Congelación y descongelación de contratos

Congelar y descongelar contratos requiere varios cambios:

  • Un mapeo (mapping) (opens in a new tab) de direcciones a booleanos (opens in a new tab) para llevar un registro de qué direcciones están congeladas. Todos los valores son inicialmente cero, lo que para los valores booleanos se interpreta como falso. Esto es lo que queremos porque, por defecto, las cuentas no están congeladas.

        mapping(address => bool) public frozenAccounts;
    
  • Eventos (opens in a new tab) para informar a cualquier interesado cuando una cuenta se congela o descongela. Técnicamente hablando, los eventos no son necesarios para estas acciones, pero ayuda al código fuera de la cadena a poder escuchar estos eventos y saber qué está sucediendo. Se considera de buena educación que un contrato inteligente los emita cuando sucede algo que podría ser relevante para otra persona.

    Los eventos están indexados, por lo que será posible buscar todas las veces que una cuenta ha sido congelada o descongelada.

      // Cuando las cuentas se congelan o descongelan
      event AccountFrozen(address indexed _addr);
      event AccountThawed(address indexed _addr);
    
  • Funciones para congelar y descongelar cuentas. Estas dos funciones son casi idénticas, así que solo repasaremos la función de congelación.

        function freezeAccount(address addr)
          public
          onlyOwner
    

    Las funciones marcadas como public (opens in a new tab) pueden ser llamadas desde otros contratos inteligentes o directamente por una transacción.

      {
          require(!frozenAccounts[addr], "Account already frozen");
          frozenAccounts[addr] = true;
          emit AccountFrozen(addr);
      }  // freezeAccount
    

    Si la cuenta ya está congelada, revertir. De lo contrario, congélela y emita (emit) un evento.

  • Cambie _beforeTokenTransfer para evitar que se mueva dinero de una cuenta congelada. Tenga en cuenta que el dinero aún se puede transferir a la cuenta congelada.

         require(!frozenAccounts[from], "The account is frozen");
    

Limpieza de activos

Para liberar los tokens ERC-20 que posee este contrato, necesitamos llamar a una función en el contrato del token al que pertenecen, ya sea transfer (opens in a new tab) o approve (opens in a new tab). No tiene sentido desperdiciar gas en este caso en asignaciones (allowances), bien podríamos transferir directamente.

    function cleanupERC20(
        address erc20,
        address dest
    )
        public
        onlyOwner
    {
        IERC20 token = IERC20(erc20);

Esta es la sintaxis para crear un objeto para un contrato cuando recibimos la dirección. Podemos hacer esto porque tenemos la definición de los tokens ERC20 como parte del código fuente (vea la línea 4), y ese archivo incluye la definición de IERC20 (opens in a new tab), la interfaz para un contrato ERC-20 de OpenZeppelin.

        uint balance = token.balanceOf(address(this));
        token.transfer(dest, balance);
    }

Esta es una función de limpieza, por lo que presumiblemente no queremos dejar ningún token. En lugar de obtener el saldo del usuario manualmente, bien podríamos automatizar el proceso.

Conclusión

Esta no es una solución perfecta: no hay una solución perfecta para el problema de "el usuario cometió un error". Sin embargo, el uso de este tipo de comprobaciones puede al menos evitar algunos errores. La capacidad de congelar cuentas, aunque peligrosa, se puede utilizar para limitar el daño de ciertos hackeos al negar al hacker los fondos robados.

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