ERC-20 con mecanismos de seguridad
Introducción
Una de las cosas más positivas de Ethereum es que no hay una autoridad central que pueda modificar o deshacer sus transacciones. Y, sin embargo, una de las grandes trabas de Ethereum es que no hay una autoridad central con el poder de deshacer los errores del usuario o las transacciones ilícitas. En este artículo, descubrirá algunos de los errores comunes que los usuarios cometen con los tókenes ERC-20, al igual que cómo crear contratos ERC-20 que ayuden a los usuarios a evitar esos errores, o le otorguen algo de poder a una autoridad central (por ejemplo, para congelar cuentas).
Observe que aunque utilizaremos el contrato del token ERC-20 OpenZeppelin(opens in a new tab), este artículo no lo explica en gran detalle. Puede encontrar aquí esta información.
Si quieres ver el código fuente completo:
- Abre el IDE Remix(opens in a new tab).
- Haga click en el ícono github de clonar ().
- Cone el repositorio de GitHub
https://github.com/qbzzt/20220815-erc20-safety-rails
. - Abre contratos > erc20-safety-rails.sol.
Creando un contrato ERC-20
Antes de agregar la funcionalidad del riel de seguridad, necesitamos un contrato ERC-20. En este artículo usaremos el Asistente de Contratos de OpenZeppelin(opens in a new tab). Ábrelo en otra ventana del navegador y sigue estas instrucciones:
Selecciona ERC-20.
Ingresa estos ajustes:
Parámetro Valor Nombre SafetyRailsToken Símbolo SAFE Premint 1.000 Características Ninguno Control de acceso Ownable Upgradability Ninguno Desplácese hasta arriba y haga click en Open in Remix (Abrir en Remix, para Remix) o en Download (Descargar) para utilizar un entorno diferente. Doy por sentado que está usando Remix, si usa algo diferente, realice únicamente los cambios apropiados.
Ahora tenemos un contrato ERC-20 totalmente funcional. Puedes expandir
.deps
>npm
para ver el código importado.Compile, despliegue y familiarícese con el contrato para ver si funciona como un contrato ERC-20. Si necesitas aprender cómo utilizar Remix, usa este tutorial(opens in a new tab).
Errores comunes
Los errores
Los usuarios algunas veces envían tokens a la dirección incorrecta. Como no podemos leer sus mentes para saber lo que hacían, hay dos tipos de error que suceden mucho y son fácilmente detectables:
Envianr los tókenes a la dirección propia del contrato. Por ejemplo, el token OP de Optimism(opens in a new tab) gestionado para acumular más de 120.000(opens in a new tab) tókenes OP en menos de dos meses. Esto representa una cantidad significativa de poder que, supuestamente, las personas perdieron.
Enviar los tókenes a una dirección vacía, que no corresponde a una cuenta de propiedad externa o a un contrato inteligente. Como no tenemos las estadísticas de la frecuencia con la que esto sucede, un incidente podría haber costado 20.000.000 tókenes(opens in a new tab).
Evitar transferencias
El contrato ERC-20 de OpenZeppelin incluye un gancho _beforeTokenTransfer
(opens in a new tab), que se invoca antes de transferir un token. Por defecto, este gancho no hace nada, pero podemos dotarle de nuestra propia funcionalidad, como los chequeos que revierten si hay algún problema.
Para usar este gancho, añada esta función antes de la constructora:
1 function _beforeTokenTransfer(address from, address to, uint256 amount)2 internal virtual3 override(ERC20)4 {5 super._beforeTokenTransfer(from, to, amount);6 }Copiar
Algunas partes de esta función pueden resultarle nuevas si no está muy familiarizado con Solidity:
1 internal virtualCopiar
La palabra clave virtual
significa que como hemos heredado funcionalidades de ERC-20
y anulado esta función, otros contratos pueden heredarla de nosotros y anular esta función.
1 override(ERC20)Copiar
Debemos especificar de manera explícita que estamos anulando(opens in a new tab) la definición del token ERC20 de _beforeTokenTransfer
. Por lo general, las definiciones explícitas son mucho mejores, desde una perspectiva de seguridad, que las implícitas. No podemos olvidar que hemos hecho algo si lo tenemos a la vista. Esta es también la razon por la que necesitamos especificar qué _beforeTokenTransfer
de la superclase estamos anulando.
1 super._beforeTokenTransfer(from, to, amount);Copiar
Esta línea llama la función de _beforeTokenTransfer
del contrato o los contratos heredados que la tienen. En este caso, eso es solo ERC20
, Ownable
no tiene este gancho. Aunque actualmente ERC20._beforeTokenTransfer
no hace nada, lo invocamos en caso de que se le añada alguna funcionalidad en el futuro (y así decidimos implementar nuevamente el contrato, porque los contratos no cambian una vez implementados).
Codificar los requisitos
Queremos añadir estos requisitos a la función:
- La dirección
to
no puede ser igual aaddress(this)
, la dirección propia del contrato ERC-20. - La dirección
to
no puede estar vacía, esta debe ser:- Unas cuentas de propiedad externa (EOA). No podemos revisar si una dirección es una EOA directamente, pero podemos revisar el saldo de ETH de una dirección. Las EOAs casi siempre tienen un balance, incluso si ya no se encuentran en uso - es difícil vaciarlas hasta el último wei.
- Un contrato inteligente. Probar si una dirección es un contrato inteligente es un poco complicado. Hay un código de operación que revisa la longitud externa del código, llamado
EXTCODESIZE
(opens in a new tab), pero no está disponible directamente en Solidity. Debemos usar Yul(opens in a new tab), que es un ensamblaje de EVM, para tal fin. Hay otros valores que podemos 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:
1 require(to != address(this), "Can't send tokens to the contract address");Copiar
Este es el primer requisito, revisa que to
y this(address)
no sean lo mismo.
1 bool isToContract;2 assembly {3 isToContract := gt(extcodesize(to), 0)4 }Copiar
Así es como revisamos si una dirección es un contrato. No podemos recibir salidas directamente de Yul, en vez de esto, definimos una variable para almacenar el resultado (isToContract
en este caso). Según el funcionamiento de Yul, cada código de operación se considera una función. Por tanto, primero invocamos EXTCODESIZE
(opens in a new tab) para obtener el tamaño del contrato y después GT
(opens in a new tab) para revisar que no sea cero (estamos trabajando con números enteros sin firmar, por lo que no puede ser negativo). Luego escribimos el resultado en isToContract
.
1 require(to.balance != 0 || isToContract, "Can't send tokens to an empty address");Copiar
Finalmente, tenemos la revisión verdadera para direcciones vacías.
Acceso administrativo
Algunas veces es útil tener un administrador que puede deshacer los errores. Para reducir el potencial de abuso, este administrador puede ser una multifirma(opens in a new tab), por lo que varias personas deben estar de acuerdo con una acción. En este artículo tenemos dos características administrativas:
Congelar y descongelar cuentas. Esto puede ser útil, por ejemplo, cuando una cuenta puede verse afectada.
Limpieza de activos.
Los fraudes algunas veces envían tókenes fraudulentos al contrato de un token real para obtener la legitimidad. Por ejemplo, consulte aquí(opens in a new tab). El contrato ERC-20 legítimo es 0x4200....0042(opens in a new tab). El fraude que pretende ser legítimo es 0x234....bbe(opens in a new tab).
También puede que las personas envíen tókenes ERC-20 legítimos a nuestro contrato por error, lo cual es otra razón para querer tener una manera de eliminarlos.
OpenZeppelin proporciona dos mecanismos para activar el acceso administrativo:
- Los contratos
Ownable
(opens in a new tab) tienen un único dueño. Las funciones que tiene el modificador(opens in a new tab)onlyOwner
sólo las puede activar el propietario. Los dueños pueden transferir la propiedad a otra persona o renunciar a esta completamente. Los derechos de todas las otras cuentas son generalmente idénticos. - Los contratos
AccessControl
(opens in a new tab) tienen control de acceso basado en roles (RBAC)(opens in a new tab).
Para simplificar la explicación, en este artículo utilizaremos Ownable
.
Congelar y descongelar contratos
Congelar y descongelar contratos requiere varios cambios:
El mapeo(opens in a new tab) de direcciones a booleanos(opens in a new tab) para hacer un seguimiento de las direcciones que están congeladas. Todos los valores son inicialmente cero, el cual interpretan como falso los booleanos. Esto es precisamente lo que queremos; ya que, por defecto, las cuentas no están congeladas.
1 mapping(address => bool) public frozenAccounts;CopiarEventos(opens in a new tab) para informar a cualquier interesado cuando una cuenta se congela o descongela. Desde un punto de vista técnico, no se requieren eventos para estas acciones, aunque le ayudan al código fuera de la cadena a ser capaz de escuchar estos eventos y saber lo que está ocurriendo. Se considera una buena práctica en contratos inteligentes, emitirlos cuando sucede algo que puede ser relevante para alguien más.
Los eventos están indexados, por tanto, es posible buscar totas las veces que una cuenta se ha congelado o descongelado.
1 // When accounts are frozen or unfrozen2 event AccountFrozen(address indexed _addr);3 event AccountThawed(address indexed _addr);CopiarFunciones para el congelamiento y descongelamiento de cuentas. Al ser estas dos funciones son prácticamente idénticas, solo hablaremos de la función para congelar.
1 function freezeAccount(address addr)2 public3 onlyOwnerCopiarLas funciones marcadas como
públicas
(opens in a new tab) pueden activarse desde otros contratos inteligentes o directamente mediante una transacción.1 {2 require(!frozenAccounts[addr], "Account already frozen");3 frozenAccounts[addr] = true;4 emit AccountFrozen(addr);5 } // freezeAccountCopiarSi la cuenta ya está congelada, revierte. De lo contrario, congélela y
emit
un evento.Cambie
_beforeTokenTransfer
para evitar que el dinero pase desde una cuenta congelada. Tenga en cuenta que el dinero todavía puede transferirse a la cuenta congelada.1 require(!frozenAccounts[from], "The account is frozen");Copiar
Limpieza de activos
Para publicar tókenes ERC-20 retenidos por este contrato, necesitamos activar una función en el contrato del token al que pertenece, siendo transfer
(opens in a new tab) o approve
(opens in a new tab). En este caso no tiene sentido el gasto de gas en asignaciones, también podemos transferir directamente.
1 function cleanupERC20(2 address erc20,3 address dest4 )5 public6 onlyOwner7 {8 IERC20 token = IERC20(erc20);Copiar
Esta es la sintaxis necesaria para crear un objeto para un contrato cuando recibimos la dirección. Podemos hacer esto porque tenemos la definición para tokens ERC20 como parte del código fuente (ver la línea 4) y ese archivo incluye la definición para IERC20(opens in a new tab), la interfaz para un contrato ERC20 de OpenZeppelin.
1 uint balance = token.balanceOf(address(this));2 token.transfer(dest, balance);3 }Copiar
Se trata de una función de limpieza, por lo que supuestamente no queremos dejar ningún token. En lugar de obtener el saldo del usuario manualmente, también podríamos automatizar el proceso.
Conclusión
Esta no es una solución perfecta, ya que no existe una solución perfecta para un problema ocurrido cuando un usuario hace un fallo. Sin embargo, usar este tipo de comprobaciones puede al menos prevenir algunos errores. La capacidad de congelar cuentas, a pesar de ser peligrosa, puede utilizarse para limitar el daño de ciertos actos de piratería, negando al hacker los fondos robados.
Última edición: @omahs(opens in a new tab), 17 de febrero de 2024