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
Prerequisitos recomendados
Si alguno de estos conceptos todavía está medio nebuloso, empieza por aquí. Te ahorra tropiezos y un par de cejas fruncidas.
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 [1]. 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
Brújula de lectura
Actor, evidencia y estado
Actor
Owner y spenderEl dueño autoriza; el spender ejecuta cuando quiera, dentro del tope.
Evidencia
Eventos Transfer y ApprovalLos indexadores reconstruyen saldos y permisos a partir de ellos.
Estado
Dos mappingsSaldos por cuenta y permisos por pareja owner/spender.
Preguntas guía
- ¿Por qué un contrato no puede "tomar" tus tokens sin un
approveprevio? - ¿Qué pasa si cambias un allowance de 100 a 50 mientras el spender está atento?
- ¿Por qué un integrador no puede asumir que
transferrevierte 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:
// 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 [2].
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 [1]. 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 [1].
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 [3]. 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.
La carrera de approve: cambiar un permiso de 100 a 50
Fuentes:
[1]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.
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 [1]. 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 [3].
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ó [3].
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:
// 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 [4]. 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 [3].
Cómo conceder permisos a un spender
Conexiones dentro de la serie
- Antes: Storage layout: donde Solidity cobra la factura explica dónde viven
balancesyallowances. - Base técnica: ABI, calldata y eventos: el idioma público de los contratos muestra cómo se codifican
TransferyApprovalen los logs. - Después: ERC-721 y ERC-1155: propiedad, metadata y safe transfers aplica la misma idea de permisos a tokens únicos.
- Más adelante: Approvals, permit y phishing de firmas profundiza en el riesgo de permisos desde la cartera.
Bibliografía académica
- [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]
OpenZeppelin, "ERC-20," OpenZeppelin Contracts 5.x Documentation.
decimals solo para presentación, aritmética entera, valor por defecto 18.
- [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]
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.
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.
Series relacionadas
HotStuff: de cero a estado del arte
Ruta progresiva para entender consenso BFT moderno: quórums, sincronía parcial, locks, taxonomía one/two/three-chain, QC/highQC, multipipeline, view-change, responsiveness, AggQC, Fast-HotStuff, sync y frontera DAG-BFT.
Substrate: del runtime al consenso propio
Serie de 20 capítulos, de neófito a experto, sobre Substrate (Polkadot SDK): runtime en Wasm, FRAME, extrinsics, estado, pesos, actualizaciones sin fork, Aura, BABE, GRANDPA, sincronización, parachains, XCM, Sassafras, BEEFY y cómo conectar un consenso BFT propio a un nodo.
Ruta práctica: activos digitales en la operación
Nueve módulos, de menor a mayor dependencia, para decidir cómo usar custodia, wallets, llaves, firmas multifirma y MPC, AML/KYT y stablecoins con Fireblocks, BitGo, Kaleido, Circle y Tether.
Labs para convertir lectura en práctica
Prueba de trabajo y reajuste de dificultad
Laboratorio determinista para medir el costo de la prueba de trabajo y ver cómo el reajuste por altura de bloque estabiliza el tiempo entre bloques.
Consensus benchmark
Banco reproducible para comparar consenso BFT y documentar los trade-offs de HotStuff-2.
Ethereum local: Anvil, wallet y primera transacción
Laboratorio base para levantar una red local, crear cuentas, firmar transacciones y observar receipts sin gastar ETH real.
RPC y Cast: leer Ethereum con lupa
Ejercicios con JSON-RPC y Cast para consultar bloques, nonces, gas, receipts, logs y errores reproducibles.
Sigue leyendo
Ideas conectadas con este artículo
Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.
Proxies y upgrades: cambiar el motor con el auto andando
Delegatecall, transparent proxies, UUPS, storage collisions y gobernanza de upgrades.
Testing serio: unit, fork, fuzz e invariantes
Cómo probar contratos con unit tests, fork tests, fuzzing e invariants antes de confiar en un deploy.
Reentrancy: la llamada externa que muerde de vuelta
Cómo funciona reentrancy, por qué The DAO sigue siendo lección viva y cómo defender con CEI, pull payments y guards.
Access control: owner, roles, multisig y timelocks
Autorización en smart contracts: Ownable, roles, multisig, timelocks y separación entre poder técnico y gobernanza.