Ethereum: de historia a labs
Artículo 20 de 48
42%
completado al llegar aquí
Sistemas distribuidosNota de investigación15 min de lectura
Testing serio: unit, fork, fuzz e invariantes
Unit, fuzz, invariant y fork tests en Foundry: qué evidencia da cada uno, cómo escribir handlers y ghost variables, y un bug real que solo encuentra el fuzzer.
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.
Foundry, Anvil y Cast: taller local para Ethereum
Forge, Anvil y Cast paso a paso: compilar, probar con fuzzing, desplegar en una cadena local 31337 y leer storage por RPC sin gastar gas real.
Blockchain14 min de lectura→Solidity sin magia: contratos como máquinas de estado
Solidity leído como máquina de estado: storage, msg.sender, visibilidad, modifiers, errores, eventos, reverts atómicos y el patrón checks-effects-interactions.
Blockchain13 min de lectura→
Un contrato con 100 % de cobertura de líneas puede perder dinero igual. La cobertura dice qué líneas se ejecutaron, no qué combinaciones de llamadas, montos y usuarios se probaron. En Ethereum eso importa más que en un backend tradicional: el código desplegado no se parchea en caliente, y cualquiera puede llamar cualquier función pública en cualquier orden.
Este capítulo trata los tests como evidencia. Cada nivel, unit, fuzz, invariante y fork, responde una pregunta distinta. Los ejemplos usan Foundry, la herramienta del capítulo de taller local, y todos se ejecutaron con Foundry 1.5.1 y Solidity 0.8.26 antes de publicarse.
Brújula de lectura
Actor, evidencia y estado
Actor
Fuzzer y handlerForge genera inputs y secuencias; el handler decide qué llamadas son válidas.
Evidencia
Contraejemplo reducidoUn test que falla deja la secuencia mínima de llamadas que rompe la propiedad.
Estado
Storage del contrato bajo pruebaBalances y totales que la invariante compara después de cada llamada.
Preguntas guía
- ¿Qué propiedad de tu contrato debe ser cierta después de cualquier secuencia de llamadas?
- ¿Qué diferencia hay entre probar un monto elegido a mano y probar miles de montos aleatorios?
- ¿Por qué un fuzzer de invariantes necesita un handler en lugar de llamar al contrato directamente?
- Si un test de invariante pasa con miles de reverts, ¿qué te está ocultando?
- ¿Cuándo hace falta un fork de mainnet y qué lo vuelve reproducible?
Cuatro niveles de evidencia: unit, fuzz, invariante y fork
Un unit test fija un escenario elegido a mano: Alice deposita 1 ether, retira 0,4 y le queda 0,6. Es rápido y legible, pero solo prueba los valores que el autor imaginó. En Forge, un test es una función que empieza con test dentro de un contrato que hereda de Test, y setUp() se ejecuta antes de cada test. [1]
Un fuzz test es un test con parámetros. Forge genera valores aleatorios para esos parámetros y ejecuta la función muchas veces, 256 por defecto. Si alguna corrida falla, informa el input y la semilla, que se puede fijar con --fuzz-seed o en foundry.toml para reproducir el fallo. [1]
Un test de invariante va un paso más allá: genera secuencias aleatorias de llamadas y comprueba una propiedad después de cada una. [2] Un fork test ejecuta los tests contra el estado real de una red, leído por RPC, sin enviar transacciones a esa red. [4]
Qué pregunta responde cada nivel
Traza de un fuzz test: inputs aleatorios contra una propiedad
Usaremos un banco mínimo: cada usuario deposita ether, puede retirarlo y el contrato lleva totalDeposits. Es deliberadamente simple; el objetivo es la técnica de prueba, no el contrato.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Bank {
mapping(address => uint256) public balanceOf;
uint256 public totalDeposits;
error InsufficientBalance();
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
totalDeposits += msg.value;
}
function withdraw(uint256 amount) external {
if (balanceOf[msg.sender] < amount) revert InsufficientBalance();
balanceOf[msg.sender] -= amount;
totalDeposits -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "ETH transfer failed");
}
}El primer test es unit; el segundo es la misma idea convertida en propiedad. Fíjate en bound: transforma cualquier uint256 en un valor dentro de un rango. La alternativa, vm.assume, descarta inputs que no cumplen una condición; la documentación la recomienda solo para exclusiones puntuales, porque descartar rangos amplios desperdicia corridas. [5]
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Bank} from "../src/Bank.sol";
contract BankUnitTest is Test {
Bank bank;
address alice = makeAddr("alice");
function setUp() public {
bank = new Bank();
vm.deal(alice, 10 ether);
}
function test_DepositThenWithdraw() public {
vm.startPrank(alice);
bank.deposit{value: 1 ether}();
bank.withdraw(0.4 ether);
vm.stopPrank();
assertEq(bank.balanceOf(alice), 0.6 ether);
}
function test_RevertWhen_WithdrawMoreThanBalance() public {
vm.prank(alice);
vm.expectRevert(Bank.InsufficientBalance.selector);
bank.withdraw(1);
}
function testFuzz_WithdrawNeverExceedsDeposit(uint256 dep, uint256 wd) public {
dep = bound(dep, 1, 10 ether);
wd = bound(wd, 0, dep);
vm.startPrank(alice);
bank.deposit{value: dep}();
bank.withdraw(wd);
vm.stopPrank();
assertEq(bank.balanceOf(alice), dep - wd);
assertEq(address(bank).balance, bank.totalDeposits());
}
}vm.prank hace que la siguiente llamada salga desde otra dirección, y vm.expectRevert exige que la próxima llamada revierta con el error indicado. [6] El camino de error es parte del comportamiento: un retiro mayor al saldo debe fallar, y el test lo deja escrito. Con forge test -vvv, Forge muestra la traza de llamadas de los tests que fallan. [1]
Invariantes con handler: secuencias, ghost variables y shrinking
El fuzz test anterior siempre empieza desde setUp: un depósito, un retiro, fin. Los bugs de contabilidad suelen aparecer después de varias operaciones de varios usuarios. Para eso, Forge ofrece funciones invariant_*: elige llamadas al azar sobre los contratos objetivo y evalúa la invariante después de cada llamada. [2]
Si el fuzzer llama al banco directamente, casi todo revierte: retira sin haber depositado o envía ether desde cuentas sin saldo. Por eso se escribe un handler, un contrato de prueba que envuelve al banco, elige un actor de una lista, acota los montos con bound y registra ghost variables, contadores que existen solo en el test, como el total depositado y retirado. Con targetContract(address(handler)), el fuzzer solo llama al handler. [2]
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Bank} from "../src/Bank.sol";
contract BankHandler is Test {
Bank public bank;
address[] public actors;
uint256 public ghost_deposited;
uint256 public ghost_withdrawn;
constructor(Bank _bank) {
bank = _bank;
for (uint256 i = 0; i < 3; i++) {
actors.push(makeAddr(string(abi.encodePacked("actor", vm.toString(i)))));
vm.deal(actors[i], 100 ether);
}
}
function deposit(uint256 actorSeed, uint256 amount) external {
address actor = actors[bound(actorSeed, 0, actors.length - 1)];
amount = bound(amount, 0, actor.balance);
vm.prank(actor);
bank.deposit{value: amount}();
ghost_deposited += amount;
}
function withdraw(uint256 actorSeed, uint256 amount) external {
address actor = actors[bound(actorSeed, 0, actors.length - 1)];
amount = bound(amount, 0, bank.balanceOf(actor));
vm.prank(actor);
bank.withdraw(amount);
ghost_withdrawn += amount;
}
function actorCount() external view returns (uint256) {
return actors.length;
}
}
contract BankInvariantTest is Test {
Bank bank;
BankHandler handler;
function setUp() public {
bank = new Bank();
handler = new BankHandler(bank);
targetContract(address(handler));
}
function invariant_Solvent() public view {
assertGe(address(bank).balance, bank.totalDeposits());
}
function invariant_Conservation() public view {
assertEq(bank.totalDeposits(), handler.ghost_deposited() - handler.ghost_withdrawn());
}
function invariant_SumOfBalances() public view {
uint256 sum;
for (uint256 i = 0; i < handler.actorCount(); i++) {
sum += bank.balanceOf(handler.actors(i));
}
assertEq(sum, bank.totalDeposits());
}
}Tres invariantes, tres preguntas distintas. Solvencia: el contrato tiene al menos el ether que debe. Conservación: el total registrado es lo que entró menos lo que salió. Suma de saldos: los saldos individuales suman el total. Las dos últimas parecen redundantes; el caso siguiente muestra por qué no lo son.
En la referencia de configuración actual, [invariant] usa por defecto 256 corridas (runs) de 500 llamadas cada una (depth), y fail_on_revert es falso. [3] Nuestra corrida local lo confirmó: cada invariante reportó 256 corridas y 128 000 llamadas.
Caso de estudio de invariante: el bug que el unit test no ve
Agreguemos una función transfer con un error clásico. Lee los dos saldos al principio y los escribe al final. Si el destino es el propio remitente, la segunda escritura pisa a la primera y el saldo crece en amount:
// Fragmento ilustrativo con un bug deliberado: no copiar a producción.
function transfer(address to, uint256 amount) external {
uint256 fromBal = balanceOf[msg.sender];
uint256 toBal = balanceOf[to];
if (fromBal < amount) revert InsufficientBalance();
balanceOf[msg.sender] = fromBal - amount;
balanceOf[to] = toBal + amount; // si to == msg.sender, gana amount
}Un unit test de Alice a Bob pasa. Ahora sumamos transfer al handler, con remitente y destino elegidos entre los mismos tres actores, y ejecutamos forge test. En nuestra corrida, invariant_SumOfBalances falló y Foundry redujo una secuencia de 33 llamadas a 2: un depósito y una transferencia de un actor a sí mismo. Esa reducción se llama shrinking: el fuzzer elimina y simplifica llamadas mientras el fallo se mantenga. [2]
Una campaña de invariantes y su contraejemplo reducido
Cómo leer esta figura
Diagrama en dos filas. Fila superior, titulada una corrida: una caja setUp seguida de llamadas deposit, withdraw, transfer, puntos suspensivos y una llamada k marcada en rojo. Bajo cada llamada hay una caja verde que dice invariante ok, salvo bajo la llamada k, donde una caja roja dice invariante falla. Una nota indica hasta depth llamadas por corrida y runs corridas por test. Fila inferior, titulada shrinking: una caja secuencia original de 33 llamadas apunta, con la etiqueta quitar y simplificar, a una caja roja secuencia mínima de 2 llamadas: deposit de a y luego transfer de a hacia a. Una nota explica que la transferencia a sí mismo infla el saldo de a.
Lo interesante es lo que no falló. invariant_Solvent y invariant_Conservation pasaron, con miles de retiros revertidos. ¿Por qué? Cuando un actor con saldo inflado intenta retirar más de lo depositado en total, totalDeposits -= amount hace underflow, y desde Solidity 0.8.0 la aritmética está chequeada por defecto: la operación revierte en lugar de dar la vuelta. [7] El dinero no salió, pero la contabilidad ya estaba rota y bloqueaba retiros legítimos.
La lección tiene dos partes. Primero, una invariante solo detecta lo que mira: hacía falta la suma de saldos. Segundo, los reverts son señal. Con fail_on_revert = false, el valor por defecto, una campaña puede pasar mientras una parte creciente de las llamadas falla. Revisa la tabla de llamadas y reverts por selector que Forge imprime, y explica cada revert.
Fork tests: probar contra el estado real sin tocar mainnet
Tu contrato rara vez vive solo. Si integra un token existente o un oráculo, un mock escrito por ti prueba tu suposición, no el contrato real. Un fork test lee por RPC el estado de una red real y ejecuta los tests localmente sobre esa copia, sin enviar transacciones. [4]
# Fork reproducible: siempre el mismo bloque
forge test --fork-url "$MAINNET_RPC_URL" --fork-block-number 18000000 -vvv
# Repetir un fallo de fuzzing con la misma semilla
forge test --match-test testFuzz_WithdrawNeverExceedsDeposit --fuzz-seed 0x1234Sin --fork-block-number, el fork usa el bloque más reciente y el mismo test puede dar resultados distintos mañana. Con el bloque fijo, el estado de partida es el mismo para todo el equipo. Dentro del fork, vm.prank permite impersonar cualquier dirección, por ejemplo un holder grande de un token, para preparar escenarios realistas. [4] La semilla tiene un límite: la documentación aclara que por sí sola no garantiza los mismos inputs si cambian la versión de Foundry, el código, la configuración o el número de workers. [1]
Riesgo operacional: lo que una suite verde no garantiza
Una suite en verde es evidencia acotada. Para que sea útil en un pipeline de CI, el equipo debería poder responder estas preguntas por escrito:
Antes de confiar en un deploy
0 de 6 revisados
El último punto conecta con el capítulo anterior: una invariante de layout o de balances vale doble si se verifica también a través de un upgrade simulado. Y conecta con el siguiente: la reentrada es justamente un tipo de bug que aparece en secuencias de llamadas, no en una sola función aislada.
Conexiones dentro de la serie
- Antes: Foundry, Anvil y Cast: taller local para Ethereum instala las herramientas que usa este capítulo.
- Antes: Solidity sin magia: contratos como máquinas de estado presenta el contrato como estado más transiciones, la base de cualquier invariante.
- Antes: Proxies y upgrades: cambiar el motor con el auto andando, cuya invariante de layout se puede automatizar con estos tests.
- Después: Reentrancy: la llamada externa que muerde de vuelta, un bug de secuencia que los tests de invariante ayudan a encontrar.
- Relacionado: ERC-4626: vaults, shares y contabilidad peligrosa, un buen candidato para escribir invariantes de conversión entre shares y activos.
Bibliografía académica
- [1]
Foundry Contributors, "Testing," Foundry Book, Forge guide.
Convenciones de tests, aislamiento de llamadas, niveles de verbosidad, fuzzing con 256 corridas por defecto, semillas, vm.assume y bound.
- [2]
Foundry Contributors, "Invariant Testing," Foundry Book, Guides.
targetContract, patrón handler, ghost variables, afterInvariant, configuración runs/depth/fail_on_revert y shrinking.
- [3]
Foundry Contributors, "Testing," Foundry Book, Config reference.
Valores por defecto de [fuzz] y [invariant]: runs, depth, fail_on_revert, timeout y call_override.
- [4]
Foundry Contributors, "Fork Testing," Foundry Book, Guides.
--fork-url, --fork-block-number para reproducibilidad, configuración en foundry.toml e impersonación con vm.prank.
- [5]
Foundry Contributors, "assume," Foundry Book, Cheatcodes reference.
vm.assume descarta el input actual; para rangos amplios recomienda bound.
- [6]
Foundry Contributors, "expectRevert," Foundry Book, Cheatcodes reference.
expectRevert afirma que la siguiente llamada revierte con los datos esperados.
- [7]
Solidity Team, "Checked or Unchecked Arithmetic," Solidity Documentation, Expressions and Control Structures.
Desde Solidity 0.8.0, overflow y underflow revierten por defecto salvo dentro de bloques unchecked.
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.
Foundry, Anvil y Cast: taller local para Ethereum
Tooling local para escribir, compilar, probar, desplegar, llamar y romper contratos sin pagar gas real.
ERC-20: simple por fuera, tramposo por dentro
Token fungible, balances, transfers, approvals, allowances y riesgos comunes del estándar ERC-20.
Proxies y upgrades: cambiar el motor con el auto andando
Delegatecall, transparent proxies, UUPS, storage collisions y gobernanza de upgrades.
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.