Ethereum: de historia a labs

Artículo 16 de 48

33%

completado al llegar aquí

Sistemas distribuidosNota de investigación12 min de lectura

ERC-20: simple por fuera, tramposo por dentro

La interfaz ERC-20 paso a paso: balances, approve y transferFrom, la carrera al cambiar un allowance, tokens que no devuelven bool y permisos que nunca caducan.

Ruta Ethereum: 0/48

Ana quiere cambiar 40 unidades de un token por otro en un exchange descentralizado. Su cartera le pide dos firmas: primero "aprobar" al contrato del exchange y después hacer el intercambio. ¿Por qué dos pasos? ¿Y qué queda autorizado cuando el intercambio ya terminó? Las respuestas están en una interfaz de seis funciones y dos eventos, propuesta en 2015 y todavía en el centro de casi todo lo que se mueve en Ethereum.

ERC-20 es un estándar de interfaz para tokens fungibles, es decir, unidades intercambiables entre sí, como monedas. No define cómo se crean los tokens ni quién los controla. Define cómo se consultan saldos, cómo se transfieren y cómo se autoriza a un tercero a moverlos . Que miles de contratos compartan esa forma es lo que permite a una cartera o a un exchange tratar cualquier token igual.

Flujo de autorización

Approve y transferFrom: dos transacciones, dos estados

El primer paso cambia un permiso; el segundo usa ese permiso para cambiar saldos.
  1. approve(spender, valor)Ana fija cuánto puede mover el spender en su nombre. Emite Approval.
  2. allowance[Ana][spender]Nuevo estado: un permiso que persiste hasta que se gaste o se cambie.
  3. transferFrom(Ana, destino, x)El spender mueve x tokens. El allowance baja en x y emite Transfer.
  4. balanceOf actualizadoAna tiene x menos, el destino x más; el permiso restante sigue vivo.

Brújula de lectura

Actor, evidencia y estado

Antes de avanzar, ubica quién actúa, qué prueba queda y qué parte del sistema cambia.

Actor

Owner y spender

El dueño autoriza; el spender ejecuta cuando quiera, dentro del tope.

Evidencia

Eventos Transfer y Approval

Los indexadores reconstruyen saldos y permisos a partir de ellos.

Estado

Dos mappings

Saldos por cuenta y permisos por pareja owner/spender.

Preguntas guía

  • ¿Por qué un contrato no puede "tomar" tus tokens sin un approve previo?
  • ¿Qué pasa si cambias un allowance de 100 a 50 mientras el spender está atento?
  • ¿Por qué un integrador no puede asumir que transfer revierte cuando falla?
  • ¿Cuánto tiempo vive un permiso que firmaste hace un año?

Seis funciones, dos eventos y dos mapas

La parte obligatoria del estándar cabe en pocas líneas. Esta versión ilustrativa usa sintaxis moderna de Solidity; el EIP original la escribe para Solidity 0.4.17 con los mismos nombres y tipos:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
interface IERC20 {
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
 
    function totalSupply() external view returns (uint256);
    function balanceOf(address owner) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
    function approve(address spender, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
}

name, symbol y decimals son opcionales, y el EIP dice que otros contratos no deben esperar que existan. decimals merece un comentario: el contrato trabaja solo con enteros. Si decimals vale 18, un saldo de 1500000000000000000 se muestra como 1,5. La documentación de OpenZeppelin lo resume así: decimals es solo para mostrar, toda la aritmética interna es entera .

Por dentro, casi toda implementación guarda dos mapas: balances[cuenta] y allowances[owner][spender]. Con lo visto en el capítulo de storage layout puedes calcular el slot de cualquier saldo y leerlo con cast storage. El estándar exige emitir Transfer en cada transferencia, incluidas las de valor cero, y recomienda emitirlo con from igual a la dirección cero al crear tokens . Así un indexador puede reconstruir saldos sin leer storage.

Traza de ejecución: approve y transferFrom entre Ana y un router

Volvamos al intercambio. Un contrato no puede sacar tokens de la cuenta de Ana por su cuenta: transfer siempre mueve fondos de msg.sender, y cuando el exchange llama al token, msg.sender es el exchange. Para mover fondos ajenos existe transferFrom, que el estándar describe como un flujo de retiro: el token comprueba que el dueño autorizó deliberadamente a quien llama .

Primero Ana llama approve(router, 1000). El token escribe allowance[Ana][router] = 1000 y emite Approval. Después Ana llama al router, y el router llama transferFrom(Ana, pool, 40). El token comprueba el saldo y el permiso, resta 40 de ambos y emite Transfer. Al final, Ana tiene 40 tokens menos y el router conserva permiso para mover 960 más.

Ese permiso restante es estado, igual que un saldo. Sobrevive al cierre de la pestaña, a la sesión de la cartera y al recuerdo de Ana. Muchas interfaces piden directamente el valor máximo, type(uint256).max. En la implementación de OpenZeppelin ese valor no se descuenta en transferFrom y la documentación lo describe como semánticamente equivalente a una aprobación infinita . Es cómodo, pero convierte un permiso puntual en uno permanente.

Vector de ataque: la carrera al cambiar un allowance

approve sobrescribe el permiso; no lo suma ni lo resta. Supón que Ana había aprobado 100 a un spender y ahora quiere bajarlo a 50. Envía approve(R, 50). Mientras esa transacción espera en el mempool, el spender la ve, envía transferFrom por 100 con más prioridad y consigue que entre antes. Cuando el nuevo approve se incluye, el permiso vuelve a 50, y el spender lo gasta también. Movió 150, no 50 ni 100.

Figura 1

La carrera de approve: cambiar un permiso de 100 a 50

Fuentes:

[1]
La carrera de approve: cambiar un permiso de 100 a 50
Cómo leer esta figura

Diagrama de carriles con Ana arriba y el spender R abajo, más dos filas de estado: allowance y cantidad que R movió. Paso 1: Ana incluye approve(R,100); allowance 100, movido 0. Paso 2: Ana envía approve(R,50), que queda pendiente; allowance 100, movido 0. Paso 3: R ve la transacción pendiente en el mempool y su transferFrom(100) entra antes; allowance 0, movido 100. Paso 4: se incluye approve(R,50); allowance 50, movido 100. Paso 5: R ejecuta transferFrom(50); allowance 0, movido 150. Una nota final dice que Ana quería bajar el tope a 50 y R movió 150.

Visual original de TrautsLab. Como approve sobrescribe el valor, un spender que adelanta su transferFrom puede gastar el permiso viejo y también el nuevo.

El propio EIP-20 enlaza esta clase de ataque y pide a las interfaces de usuario poner el permiso a 0 antes de fijar otro valor para el mismo spender, sin que el contrato lo imponga, para no romper compatibilidad . Algunos tokens sí lo imponen y revierten si pasas de un valor distinto de cero a otro. OpenZeppelin cita USDT como ejemplo y ofrece SafeERC20.forceApprove, que reintenta pasando antes por cero .

Tokens que no cumplen lo que el integrador supone

El estándar dice que quien llama debe manejar un false como resultado y nunca suponer que no ocurre. En la práctica conviven tres comportamientos: tokens que revierten al fallar, tokens que devuelven false y tokens antiguos que no devuelven nada. Un contrato que hace token.transfer(...) sin mirar el resultado puede creer que cobró cuando no cobró. SafeERC20 envuelve las llamadas: revierte si el token devuelve false y acepta como éxito una llamada sin retorno que no revirtió .

Otro supuesto frágil es que llega exactamente lo que se pidió. Hay tokens que cobran una comisión en cada transferencia, y hay tokens cuyo saldo cambia sin transferencias. Un depósito robusto mide lo que recibió en lugar de confiar en el argumento. Este contrato es ilustrativo y compila con OpenZeppelin Contracts 5.x:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
 
contract Deposito {
    using SafeERC20 for IERC20;
 
    IERC20 public immutable token;
    mapping(address => uint256) public credited;
 
    constructor(IERC20 token_) {
        token = token_;
    }
 
    function deposit(uint256 amount) external {
        uint256 before = token.balanceOf(address(this));
        token.safeTransferFrom(msg.sender, address(this), amount);
        uint256 received = token.balanceOf(address(this)) - before;
        credited[msg.sender] += received; // acredita lo que llegó, no lo pedido
    }
}

Fíjate en que deposit llama al token antes de escribir credited. Con un token estándar no pasa nada, pero si el token ejecuta código del receptor o de terceros durante la transferencia, este orden abre la puerta a la reentrada que vimos en el capítulo de Solidity. Integrar tokens arbitrarios exige decidir qué tokens se aceptan, no solo cómo se llaman.

Riesgo operativo: el permiso sobrevive a la sesión

El riesgo más común para usuarios no está en el código del token sino en el spender. Si el contrato aprobado se compromete, o cambia de lógica detrás de un proxy, puede usar todos los permisos vivos sin pedir nada nuevo. Por eso una revisión seria sigue tres estados a la vez: el saldo del dueño, el allowance de cada pareja owner/spender y el código que controla al spender en ese momento.

EIP-2612 añade permit, que cambia el allowance a partir de una firma fuera de la cadena en vez de una transacción de msg.sender . Ahorra una transacción, pero desplaza el riesgo: una firma que parece inofensiva puede conceder un permiso. OpenZeppelin además advierte que, como cualquiera puede enviar un permit firmado, puede ser adelantado por otro, y recomienda tolerar ese fallo en el código que lo usa .

Riesgo operativo

Cómo conceder permisos a un spender

Recomendado

Permiso exacto por operación

Se aprueba solo lo que se va a gastar y el allowance vuelve a cero.

Ideal para
Usuarios con montos grandes y spenders nuevos.
Ojo con
Más transacciones y más gas por operación.
Riesgo

Permiso infinito

Un approve con el máximo y ninguna firma adicional después.

Ideal para
Contratos muy auditados que el usuario usa a diario.
Ojo con
Todo el saldo queda expuesto si el spender falla o cambia.
Precaución

Permit con firma

Una firma EIP-712 fija el allowance; el spender la envía junto con la operación.

Ideal para
Flujos de una sola transacción con tokens que lo soportan.
Ojo con
Las firmas también son permisos; el phishing se mueve a la pantalla de firma.
Cada opción cambia cuánto valor queda expuesto y por cuánto tiempo.

Conexiones dentro de la serie

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [1]

    F. Vogelsteller and V. Buterin, "ERC-20: Token Standard," Ethereum Improvement Proposals, no. 20, Nov. 2015.

    Firmas de totalSupply, balanceOf, transfer, transferFrom, approve, allowance; eventos Transfer y Approval; manejo de false y nota sobre cambiar allowances.

  2. [2]

    OpenZeppelin, "ERC-20," OpenZeppelin Contracts 5.x Documentation.

    decimals solo para presentación, aritmética entera, valor por defecto 18.

  3. [3]

    OpenZeppelin, "ERC 20 API Reference," OpenZeppelin Contracts 5.x Documentation.

    Allowance máximo como aprobación infinita, SafeERC20 para tokens que devuelven false o nada, forceApprove para tokens tipo USDT, permit y frontrunning.

  4. [4]

    M. Lundfall, "ERC-2612: Permit Extension for EIP-20 Signed Approvals," Ethereum Improvement Proposals, no. 2612, 2020.

    Función permit que modifica allowances con una firma estructurada EIP-712.

Fuentes primarias, papers seminales y especificaciones técnicas citadas en esta investigación.

Rutas vecinas

Si este tema te abrió hambre, sigue por aquí.

Series y labs que comparten área o dependen de esta ruta. Pocas opciones, para no convertir la curiosidad en menú infinito.

Sigue leyendo

Ideas conectadas con este artículo

Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.

Los comentarios se cargan al final de la lectura para no meter peso antes de tiempo.