La publicación anterior recorrió una implementación de extremo a extremo: un contrato de token mínimo, reconstrucción de estado fuera de cadena y un frontend de React, desde `mint()` hasta MetaMask. Esta publicación continúa donde se quedó: ¿cómo se hace QA de algo así?
No soy ingeniero de blockchain (todavía), pero los patrones de QA se adaptan bien entre dominios, y tomar prestado lo que ya funciona en otros lugares es como aprendo más rápido.
El contrato solo hace tres cosas: `mint`, `transfer` y `burn`, pero eso es suficiente para practicar la cadena de herramientas completa de QA: análisis estático, pruebas de mutación, perfilado de gas, verificación formal.
El código está en `egpivo/ethereum-account-state`.
Pirámide de QA de Blockchain: desde el análisis estático en la base hasta la verificación formal en la parte superiorAntes de agregar algo nuevo, el proyecto ya tenía:
Todas las pruebas pasaron. La cobertura se veía bien. Entonces, ¿por qué molestarse con más?
Porque "todas las pruebas pasan" no significa "todos los errores se capturan". Una cobertura del 100% de líneas aún puede perder un error real si ninguna afirmación verifica lo correcto.
Slither (Trail of Bits) detecta problemas invisibles para las pruebas: reentrada, valores de retorno no verificados, desajustes de interfaz.
./scripts/run-qa.sh slither
Resultado: 1 hallazgo medio: `erc20-interface`: `transfer()` no devuelve `bool`.
Esto es esperado. El contrato intencionalmente no es un ERC20 completo: es una máquina de estados educativa. Pero el hallazgo no es académico:
Si alguien luego importa este token en un protocolo que espera ERC20, el desajuste de interfaz fallaría silenciosamente. Slither lo marca ahora para que la decisión sea consciente.
./scripts/run-qa.sh coverage Resultado de cobertura.
Una función no cubierta: `BalanceLib.gt()`. Volveremos a esto.
Salida de cobertura de forge: 24 pruebas pasadas, tabla de cobertura de Token.sol./scripts/run-qa.sh gas
Costos de gas de referencia para las tres operaciones:
Gas en términos de operacionesEn ejecuciones posteriores, `forge snapshot — diff` compara con la referencia. Una regresión del 20% en gas en `transfer()` es un costo real para cada usuario: detectarlo antes de la fusión es barato.
Aquí es donde las cosas se pusieron interesantes. Gambit (Certora) genera mutantes: copias de `Token.sol` con pequeños errores deliberados (`+=` a `-=`, `>=` a `>`, condiciones negadas). El pipeline ejecuta el conjunto completo de pruebas contra cada mutante. Si un mutante sobrevive (todas las pruebas aún pasan), esa es una brecha de prueba concreta.
./scripts/run-qa.sh mutation
Resultado: Puntuación de mutación del 97.0% — 32 eliminados, 1 sobrevivió de 33 mutantes.
El registro de salida de Gambit muestra cada mutante y qué cambió. Algunos ejemplos:
Mutante generado #7: BinaryOpMutation — Token.sol:168
totalSupply = totalSupply.add(amountBalance) → totalSupply = totalSupply.sub(amountBalance)
ELIMINADO por test_Mint_Success
Mutante generado #19: RelationalOpMutation — Token.sol:196
if (!fromBalance.gte(amountBalance)) → if (fromBalance.gte(amountBalance))
ELIMINADO por test_Transfer_Success
Mutante generado #28: SwapArgumentsMutation — Token.sol:81
return Balance.unwrap(a) > Balance.unwrap(b) → return Balance.unwrap(b) > Balance.unwrap(a)
SOBREVIVIÓ ← ninguna prueba lo capturó
Pruebas de mutación de Gambit: 32 eliminados, 1 sobrevivió, puntuación de mutación 97.0%
El mutante sobreviviente intercambió `a > b` a `b > a` en `BalanceLib.gt()`. Ninguna prueba lo capturó porque `gt()` es código muerto. Nunca se llama en ningún lugar de `Token.sol`.
La cobertura marcó 91.67% de funciones pero no pudo explicar la brecha. Las pruebas de mutación lo hicieron: `gt()` es código muerto, nada lo llama y nadie notaría si estuviera mal.
El código muerto o desprotegido en Smart Contracts tiene precedentes reales.
La función no estaba destinada a ser llamable, pero nadie probó esa suposición. Nuestro `gt()` es inofensivo en comparación, pero el patrón es el mismo: el código que existe pero nunca se ejecuta es código que nadie está vigilando.
Halmos (a16z) razona sobre todas las entradas posibles simbólicamente. Donde las pruebas fuzz muestrean valores aleatorios y esperan alcanzar casos extremos, Halmos prueba propiedades exhaustivamente.
./scripts/run-qa.sh halmos
Resultado: 9/9 pruebas simbólicas pasan — todas las propiedades probadas para todas las entradas.
Propiedades verificadas:
Propiedades verificadasUna nota práctica: Halmos 0.3.3 no admite `vm.expectRevert()`, por lo que no pude escribir pruebas de reversión de la manera normal de Foundry. La solución es un patrón try/catch: si la llamada tiene éxito cuando debería revertir, `assert(false)` falla la prueba:
function check_mint_reverts_on_zero_address(uint256 amount) public {
vm.assume(amount > 0);
try token.mint(address(0), amount) {
assert(false); // no debería llegar aquí
} catch {
// reversión esperada - Halmos prueba que esta ruta siempre se toma
}
}
No es lo más bonito, pero funciona: Halmos aún prueba la propiedad para todas las entradas. Este es el tipo de cosa que solo descubres al ejecutar realmente la herramienta.
Para el contexto de por qué importa la verificación formal:
La vulnerabilidad estaba en el código, revisable por cualquiera, pero ninguna herramienta o prueba la capturó antes del despliegue. Los probadores simbólicos como Halmos existen precisamente para cerrar esa brecha: no muestrean; agotan el espacio de entrada.
Salida de Halmos: 9 pruebas pasadas, 0 fallidas, resultados de pruebas simbólicasEl archivo de prueba es `contracts/test/Token.halmos.t.sol`.
La arquitectura de la primera publicación tiene una capa de dominio TypeScript que refleja la máquina de estado en cadena. Esta fase prueba si las dos realmente están de acuerdo.
Agregué pruebas de propiedades fast-check para la capa de dominio TypeScript, reflejando lo que hace el fuzzer de Foundry para Solidity:
npm test - tests/unit/property.test.ts
Resultado: 9/9 pruebas de propiedades pasan después de corregir un error real.
Propiedades probadas:
fast-check encontró un error real de consistencia entre capas en `Token.ts` `transfer()`. El contraejemplo reducido fue inmediatamente claro:
La propiedad falló después de 3 pruebas
Reducido 2 veces
Contraejemplo: transfer(from=0xaaa…, to=0xaaa…, amount=1n)
→ from == to (auto-transferencia)
→ verifyInvariant() devolvió false
La auto-transferencia (`from == to`) rompió el invariante `sum(balances) == totalSupply`. `toBalance` se leyó antes de que se actualizara `fromBalance`, entonces cuando `from == to`, el valor obsoleto sobrescribió la deducción:
// Antes (con error)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← obsoleto cuando from == to
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← sobrescribe la resta
Corrección: leer `toBalance` después de escribir `fromBalance`, coincidiendo con la semántica de almacenamiento de Solidity:
// Después (corregido)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← ahora lee el valor actualizado
this.accounts.set(to.getValue(), toBalance.add(amount));
El contrato de Solidity no se vio afectado: vuelve a leer el almacenamiento después de cada escritura. Pero el espejo TypeScript tenía una dependencia de orden sutil que ninguna prueba unitaria existente cubría.
Los desajustes entre capas a mayor escala han sido catastróficos.
Nuestro error de auto-transferencia no habría hecho perder dinero a nadie, pero el modo de falla es estructuralmente el mismo: dos capas que se supone que están de acuerdo, no lo están.
Ejecutar herramientas de QA en un proyecto existente nunca es solo "instalar y ejecutar". Algunas cosas se rompieron antes de funcionar:
Todo se ejecuta a través de dos scripts:
./scripts/run-qa.sh slither gas # solo análisis estático + gas
./scripts/run-qa.sh mutation # solo pruebas de mutación
./scripts/run-qa.sh all # todo
No todas las verificaciones son rápidas. Slither y cobertura se ejecutan en cada commit. Las pruebas de mutación y Halmos son más lentas, más adecuadas para ejecuciones semanales o previas al lanzamiento.
Cinco capas de QA, cada una capturando una clase diferente de problema.
Explicación de capasGambit y fast-check dieron los resultados más prácticos en esta ronda.
Las verificaciones de QA ahora están conectadas a GitHub Actions como un pipeline de seis etapas:
Pipeline de CI: Build & Lint se ramifica a las etapas Test, Coverage, Gas, Slither y AuditPipeline de GitHub Actions: Build & Lint controla todas las etapas posteriores.
Explicación de etapasEthereum Account State: QA Pipeline for a Minimal Token fue publicado originalmente en Coinmonks en Medium, donde las personas continúan la conversación destacando y respondiendo a esta historia.


