Panel de control de QA para monitorear el estado del Smart Contract El artículo anterior recorrió una implementación de extremo a extremo: un contrato de token mínimo, reconocimiento de estado fuera de cadenaPanel de control de QA para monitorear el estado del Smart Contract El artículo anterior recorrió una implementación de extremo a extremo: un contrato de token mínimo, reconocimiento de estado fuera de cadena

Estado de Cuenta de Ethereum: Pipeline de QA para un Token Mínimo

2026/04/09 13:48
Lectura de 9 min
Si tienes comentarios o inquietudes sobre este contenido, comunícate con nosotros mediante crypto.news@mexc.com
Panel de QA monitoreando el estado del Smart Contract 

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 superior 

Con qué empezamos

Antes de agregar algo nuevo, el proyecto ya tenía:

  • 21 pruebas unitarias de Foundry que cubren cada transición de estado (éxito, reversión en entrada ilegal, emisión de eventos)
  • 3 pruebas invariantes a través de un `TokenHandler` que ejecuta secuencias aleatorias de `mint`/`transfer`/`burn` en 10 actores (128k llamadas cada uno)
  • Pruebas Fuzz verificando `sum(balances) == totalSupply` para cantidades aleatorias
  • Pruebas de dominio TypeScript (Vitest) reflejando la máquina de estado en cadena
  • CI: compilar, probar, lint (Prettier + solhint)

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.

Fase 1: Análisis estático y cobertura del Smart Contract

Slither

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.

Cobertura

./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 

Instantáneas de gas

./scripts/run-qa.sh gas

Costos de gas de referencia para las tres operaciones:

Gas en términos de operaciones

En 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.

Fase 2: Pruebas de mutación y verificación formal

Pruebas de mutación (Gambit)

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.

Verificación formal (Halmos)

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 verificadas

Una 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ólicas 

El archivo de prueba es `contracts/test/Token.halmos.t.sol`.

Fase 3: Pruebas de propiedades entre capas

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.

Pruebas basadas en propiedades con fast-check

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:

  • `Balance`: conmutatividad, asociatividad, identidad, inverso, consistencia de comparación
  • `Token`: invariante `sum(balances) == totalSupply` bajo secuencias de operación aleatorias (200 ejecuciones, 50 ops cada una)
  • `Token`: `totalSupply` no negativo después de secuencias aleatorias
  • `mint` siempre tiene éxito para entradas válidas
  • `transfer` preserva `totalSupply`

El error que fast-check encontró

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.

Obstáculos encontrados en el camino

Ejecutar herramientas de QA en un proyecto existente nunca es solo "instalar y ejecutar". Algunas cosas se rompieron antes de funcionar:

  • 0% de cobertura porque `foundry.toml` no tenía ruta de prueba: La primera ejecución de `forge coverage` devolvió 0% en todos los ámbitos. Resulta que `foundry.toml` no especificaba `test = "contracts/test"` o `script = "contracts/script"`, por lo que Forge no estaba descubriendo ninguna prueba. El comando de cobertura tuvo éxito silenciosamente, simplemente no tenía nada que cubrir. Esta fue la falla más engañosa: una ejecución verde sin salida útil.
  • Importación de `InvariantTest` eliminada en forge-std v1.14.0: `Invariant.t.sol` importaba `InvariantTest` desde `forge-std`, que se eliminó en una versión reciente. La compilación falló con un error opaco de "símbolo no encontrado". La solución fue eliminar la importación: `Test` solo es suficiente para las pruebas invariantes de Foundry ahora.
  • `uint256(token.totalSupply())` vs `Balance.unwrap()`: Las pruebas usaban una conversión explícita para extraer el `uint256` subyacente del tipo `Balance` definido por el usuario. Se compiló, pero es el idioma incorrecto: `Balance.unwrap(token.totalSupply())` es para lo que está diseñado el sistema UDVT. Aplicado en `Token.t.sol`, `Invariant.t.sol` y `DeploySepolia.s.sol`.

Diseño del pipeline

Todo se ejecuta a través de dos scripts:

  • `scripts/setup-qa-tools.sh`: instala Slither, Halmos, Gambit (idempotente)
  • `scripts/run-qa.sh`: ejecuta verificaciones, guarda resultados con marca de tiempo en `qa-results/`

./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.

Resumen

Cadena de herramientas de QA de Blockchain: qué captura cada capa, desde el análisis estático hasta las pruebas de propiedades entre capas 

Cinco capas de QA, cada una capturando una clase diferente de problema.

Explicación de capas

Gambit y fast-check dieron los resultados más prácticos en esta ronda.

Pipeline de CI

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 Audit 

Pipeline de GitHub Actions: Build & Lint controla todas las etapas posteriores.

Explicación de etapas

Referencias

  • Fuente de Ethereum Account State: [github.com/egpivo/ethereum-account-state](https://github.com/egpivo/ethereum-account-state)
  • Publicación anterior: Ethereum Account State
  • Slither: github.com/crytic/slither
  • Gambit: github.com/Certora/gambit
  • Halmos: github.com/a16z/halmos
  • fast-check: github.com/dubzzz/fast-check
  • Foundry: getfoundry.sh

Notas

  • Esta publicación está adaptada de mi publicación original del blog.

Ethereum 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.

Aviso legal: Los artículos republicados en este sitio provienen de plataformas públicas y se ofrecen únicamente con fines informativos. No reflejan necesariamente la opinión de MEXC. Todos los derechos pertenecen a los autores originales. Si consideras que algún contenido infringe derechos de terceros, comunícate a la dirección crypto.news@mexc.com para solicitar su eliminación. MEXC no garantiza la exactitud, la integridad ni la actualidad del contenido y no se responsabiliza por acciones tomadas en función de la información proporcionada. El contenido no constituye asesoría financiera, legal ni profesional, ni debe interpretarse como recomendación o respaldo por parte de MEXC.

$30,000 en PRL + 15,000 USDT

$30,000 en PRL + 15,000 USDT$30,000 en PRL + 15,000 USDT

¡Deposita y opera PRL para mejorar tus premios!