Ethereum: de historia a labs
Artículo 44 de 48
92%
completado al llegar aquí
Sistemas distribuidosNota de investigación14 min de lectura
Indexadores: eventos, reorgs e idempotencia
Cómo indexar logs de Ethereum que sobreviven reintentos, backfills y reorgs: cursor con hash, etiquetas safe y finalized, y efectos idempotentes.
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.
ABI, calldata y eventos: el idioma público de los contratos
Cómo se calcula un selector de 4 bytes, cómo se codifican argumentos en palabras de 32 bytes, qué guardan topics y data de un evento y cómo leer un revert.
Blockchain12 min de lectura→JSON-RPC: hablar con Ethereum sin magia
Cómo es una petición JSON-RPC, qué significan latest, safe y finalized, cómo fijar el bloque de una lectura y qué confianza depositas en un proveedor RPC.
Blockchain13 min de lectura→
Tu servicio de depósitos ve un evento Transfer de 500 USDC hacia la dirección de un cliente en el bloque más reciente. Acredita el saldo. Unos segundos después, ese bloque deja de ser parte de la cadena canónica y la transferencia no aparece en el bloque que lo reemplaza. ¿Qué hace ahora tu base de datos?
Esa escena resume el capítulo. Un indexador es el proceso que lee bloques y logs de un nodo y los convierte en tablas consultables: saldos, órdenes, depósitos. Su dificultad no está en decodificar eventos, sino en que la historia reciente de la cadena puede cambiar, y en que tu propio proceso puede caerse y repetir trabajo. Este capítulo enseña a construir esa vista para que tolere las dos cosas.
Flujo visual
Un paso del indexador
Brújula de lectura
Actor, evidencia y estado
Actor
IndexadorTu proceso: lee del nodo, escribe en tu base de datos y puede reiniciarse en cualquier punto.
Evidencia
Log con blockHashUn log solo prueba algo dentro del bloque que lo contiene; si el bloque sale de la cadena, la prueba cae con él.
Estado
Proyección + cursorTablas derivadas y el par (altura, hash) del último bloque aplicado, siempre escritos juntos.
Preguntas guía
Usa estas preguntas mientras lees. Cada una corresponde a una decisión de diseño que tu equipo tendrá que tomar:
- Si el indexador se cae justo después de acreditar un depósito y antes de guardar el cursor, ¿qué pasa al reiniciar?
- ¿Cómo se entera tu proceso de que el bloque que ya procesó dejó de ser canónico?
- ¿En qué etiqueta de bloque (
latest,safeofinalized) muestras un depósito, y en cuál permites retirarlo? - ¿Qué clave usas para que aplicar dos veces el mismo log no duplique un saldo?
- Si necesitas reconstruir la tabla desde cero, ¿de dónde sacas los logs antiguos?
Conexiones dentro de la serie
- Antes: Lending y liquidaciones: cuando el colateral respira mal, donde un liquidador también depende de leer estado reciente.
- Después: Clientes y nodos: diversidad antes que monocultivo, que explica de dónde salen las etiquetas
safeyfinalizeddentro del nodo.
Prerequisitos internos recomendados:
- ABI, calldata y eventos: el idioma público de los contratos, para entender topics y datos de un evento.
- JSON-RPC: hablar con Ethereum sin magia, para los métodos que usa el indexador.
- Finality, slashing e inactivity leak, para el origen de la finalidad.
Traza de ejecución de un log: qué identifica a un evento
Empecemos por el objeto que el indexador consume. Cuando un contrato ejecuta emit Transfer(from, to, value), la EVM agrega una entrada al log de la transacción: una estructura que queda en el recibo (receipt) y que los contratos no pueden leer después. Las aplicaciones la consultan desde fuera, por la interfaz RPC del nodo. [7]
Un log tiene dos partes de datos. Los topics son hasta cuatro palabras de 32 bytes: el primero es el hash Keccak-256 de la firma del evento, y los otros son los parámetros marcados indexed (como máximo tres). El campo data lleva el resto de los argumentos codificados en ABI. Para Transfer(address,address,uint256) de ERC-20, el primer topic es 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. [7]
El nodo devuelve además metadatos de posición: blockNumber, blockHash, transactionHash, transactionIndex, logIndex (posición del log dentro del bloque) y removed. Este último vale true cuando el log se retiró por una reorganización de cadena. [2]
La regla que sale de aquí: un log no tiene identidad fuera de su bloque. El par (blockHash, logIndex) lo identifica de forma única. Si la misma transacción vuelve a entrar en otro bloque, se ejecuta sobre otro estado previo: puede emitir los mismos eventos, otros distintos o revertir. Por eso tu sistema no debe suponer que "el mismo depósito" reaparecerá igual.
Estado de votos: latest, safe y finalized
Una reorganización (reorg) ocurre cuando el nodo cambia de opinión sobre cuál es la punta de la cadena y reemplaza uno o más bloques recientes por otros. En Ethereum con prueba de participación, el tiempo se divide en slots de 12 segundos y epochs de 32 slots; un bloque recién propuesto todavía no tiene votos suficientes para ser irreversible. [4]
La especificación de las APIs de ejecución define cinco etiquetas de bloque. Tres importan aquí. latest es el bloque más reciente de la cadena canónica que ve el cliente y puede salir de ella incluso en condiciones normales. safe es el bloque más reciente a salvo de reorgs bajo mayoría honesta y ciertos supuestos de sincronía. finalized es el bloque más reciente con seguridad criptoeconómica: no se revierte salvo intervención manual coordinada por la comunidad. [1]
¿Quién decide cuál es cada bloque? No el cliente de ejecución. El cliente de consenso se lo comunica en cada llamada engine_forkchoiceUpdated, que trae tres hashes: headBlockHash, safeBlockHash y finalizedBlockHash. [6]
La finalidad viene de Casper FFG: cuando votos que representan al menos dos tercios del ETH en stake enlazan dos checkpoints consecutivos, el anterior queda finalizado. Revertirlo exigiría que un atacante perdiera al menos un tercio del total en stake. [4] Hoy un bloque tarda unos 15 minutos en finalizar. [5]
¿En qué etiqueta actúa cada efecto?
Una consecuencia operativa que suele olvidarse: si la red deja de finalizar, por ejemplo porque más de un tercio del stake está desconectado, finalized deja de avanzar. Tu carril de acreditación definitiva se congela con él. Eso es correcto. Lo incorrecto sería que un operador cambie la política a "esperar 64 bloques" sin decisión explícita del negocio.
Traza de ejecución de un reorg en el cursor
El cursor es la marca de hasta dónde llegó el indexador. Si guarda solo la altura, no puede notar que el bloque 101 que procesó ya no es el bloque 101 canónico. Si guarda altura y hash, cada paso puede comprobar algo simple: el parentHash del bloque siguiente debe ser igual al hash del cursor.
Cuando la comprobación falla, el indexador retrocede. Revierte los efectos del bloque del cursor, baja el cursor una altura y vuelve a comparar. Repite hasta encontrar el ancestro común, el último bloque que comparten la rama vieja y la nueva. Desde ahí avanza por la rama nueva como siempre.
Para pedir los logs de un bloque concreto conviene usar el filtro blockHash de eth_getLogs, que añadió EIP-234 precisamente para que los clientes robustos puedan leer logs de un bloque exacto pese a reorgs. El nodo devuelve los logs de ese bloque, esté o no en la cadena principal, o un error si no conoce el hash; nunca mezcla logs de dos ramas. Que el bloque sea canónico lo decide tu comparación de parentHash, no el filtro. [3]
Un cursor con hash detecta la reorg y retrocede hasta el ancestro común
Cómo leer esta figura
Arriba, una cadena de bloques 99, 100, 101, 102 de la rama vieja. Desde el bloque 100 sale una rama nueva con 101 prima, 102 prima y 103. El cursor del indexador apunta a 102 con su hash. Una flecha roja marca que el parentHash de 103 no coincide con el hash del cursor. Flechas de retroceso revierten 102 y 101 hasta el ancestro común 100, y una flecha verde aplica 101 prima, 102 prima y 103. Debajo, tres bandas muestran que los bloques hasta 99 están finalizados, el 100 es safe y la punta es latest.
El siguiente fragmento es ilustrativo: muestra un paso del bucle con fetch de Node 18 o superior. db representa tu almacenamiento transaccional; sus métodos no son de ninguna librería concreta.
// Ilustrativo: un paso del indexador. db.* es tu propia capa de datos.
const toHex = (n) => '0x' + n.toString(16);
async function rpc(method, params) {
const res = await fetch(process.env.RPC_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const { result, error } = await res.json();
if (error) throw new Error(`${method}: ${error.message}`);
return result;
}
const TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
async function step(db, token) {
const cursor = await db.getCursor(); // { number, hash } del último bloque aplicado
const next = await rpc('eth_getBlockByNumber', [toHex(cursor.number + 1), false]);
if (next === null) return; // todavía no existe esa altura
if (next.parentHash !== cursor.hash) {
await db.rewindOne(); // revierte efectos del bloque del cursor y baja una altura
return; // el próximo paso vuelve a comparar
}
const logs = await rpc('eth_getLogs', [{ blockHash: next.hash, address: token, topics: [TRANSFER] }]);
await db.transaction(async (tx) => {
for (const log of logs) {
await tx.applyOnce({ blockHash: next.hash, logIndex: log.logIndex }, log);
}
await tx.setCursor({ number: Number(next.number), hash: next.hash });
});
}Dos detalles hacen que funcione. Primero, los efectos y el cursor se escriben en la misma transacción de base de datos: o quedan ambos o ninguno. Segundo, rewindOne necesita saber qué efectos produjo cada bloque, así que cada fila derivada guarda el blockHash que la originó.
Invariante de la proyección: idempotencia y backfill
Idempotente significa que aplicar la misma operación dos veces deja el mismo resultado que aplicarla una. Lo necesitas porque el indexador recibe cada log al menos una vez: un reinicio, un reintento de red o un cambio de proveedor RPC repiten trabajo. "Exactamente una vez" no es algo que el nodo te entregue; lo construyes tú con una clave única y una transacción.
El invariante que conviene escribir en el código y en los tests es este: la proyección es igual al resultado de aplicar, en orden, los logs de los bloques canónicos desde el inicio hasta el cursor. Si en algún momento la tabla contiene un efecto cuyo blockHash no está en la cadena canónica, el invariante está roto.
Un backfill es reconstruir la proyección desde un bloque antiguo. Se hace en rangos con fromBlock y toBlock. La especificación exige que, si un límite supera la cabeza actual o fromBlock es mayor que toBlock, el cliente responda con el error -32602 y no recorte el rango por su cuenta. [1] Una práctica razonable es hacer el backfill solo hasta finalized y dejar el tramo reciente al bucle con cursor.
¿De dónde salen los logs antiguos? Desde julio de 2025 todos los clientes de ejecución permiten borrar la historia anterior a The Merge, siguiendo EIP-4444. Un nodo podado no puede servir esos recibos. [8] Si tu contrato es antiguo, decide de antemano qué fuente de historia vas a usar.
Riesgo operativo: depósitos sobre un proveedor de custodia
Muchos equipos no corren su propio indexador para depósitos: usan un proveedor de custodia que avisa por webhook cuando entra un fondo. El razonamiento de este capítulo sigue aplicando. Un webhook también se entrega al menos una vez, y el aviso puede llegar antes de que el bloque sea finalized.
Tres reglas prácticas para ese caso:
- Trata cada aviso como una pista, no como una prueba. Antes de liberar saldo, verifica el recibo y el log en tu propio nodo o en un RPC independiente, con la etiqueta que exige tu política.
- Usa como clave de idempotencia el identificador del evento en la cadena,
(chainId, blockHash, logIndex), no el identificador del webhook. - Guarda el
blockHashjunto al crédito. Si el bloque sale de la cadena canónica, ese crédito debe poder revertirse o, mejor, no haber salido nunca del estado pendiente.
El capítulo de arquitectura de producción retoma esta idea y la convierte en una máquina de estados para depósitos y retiros.
Tres reglas para un indexador
Quédate con tres frases. Un log solo es evidencia dentro de su bloque. El cursor guarda altura y hash, y se escribe en la misma transacción que los efectos. Y cada efecto tiene una etiqueta mínima: lo que no puedes deshacer espera a finalized.
Bibliografía académica
- [1]
Ethereum Execution APIs specification, schema BlockTag and method eth_getLogs.
Definición normativa de latest, safe y finalized y semántica de eth_getLogs.
- [2]
ethereum.org, "JSON-RPC API."
Campos de un log, filtros y parámetro de bloque.
- [3]
M. Zoltu, "EIP-234: Add blockHash to JSON-RPC filter options," Ethereum Improvement Proposals, 2017.
Filtro por blockHash para leer logs de un bloque exacto pese a reorgs.
- [4]
ethereum.org, "Proof-of-stake (PoS)."
Slots, epochs, checkpoints y finalidad.
- [5]
ethereum.org, "Single slot finality."
Tiempo actual hasta finalidad, alrededor de 15 minutos.
- [6]
Ethereum Execution APIs, "Engine API: Paris," engine_forkchoiceUpdatedV1 and ForkchoiceStateV1.
El cliente de consenso entrega head, safe y finalized al cliente de ejecución.
- [7]
Solidity documentation, "Events," in Contracts.
Logs, topics y límite de tres parámetros indexed.
- [8]
M. Garnett, "Partial history expiry announcement," Ethereum Foundation Blog, Jul. 8, 2025.
Clientes de ejecución permiten podar historia previa a The Merge.
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.
ABI, calldata y eventos: el idioma público de los contratos
Function selectors, ABI encoding, calldata, logs, topics y cómo una app conversa con contratos.
Clientes y nodos: diversidad antes que monocultivo
Execution clients, consensus clients, node operators, client diversity, APIs y por qué Ethereum no debe depender de una sola implementación.
Observabilidad Ethereum para dApps
Métricas de RPC, gas, fallos de transacciones, lag de eventos, indexadores y alertas útiles.
Arquitectura production-ready para dApps
Checklist arquitectónico para llevar una dApp a producción: wallet UX, RPC fallback, contratos, indexadores, monitoreo, seguridad e incident response.