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

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

El cursor guarda altura y hash. Cada paso verifica que el bloque nuevo cuelga del anterior antes de aplicar sus logs.
  1. Leer el bloque siguienteeth_getBlockByNumber con la altura del cursor más uno.
  2. Comparar parentHashSi no coincide con el hash del cursor, hubo reorg: retroceder.
  3. Pedir logs por blockHasheth_getLogs con blockHash ata los logs a ese bloque exacto.
  4. Aplicar efectos una vezClave idempotente (blockHash, logIndex) en la misma transacción que el cursor.
  5. Promover por etiquetaPendiente en latest, liberado cuando el bloque es finalized.

Brújula de lectura

Actor, evidencia y estado

Antes de avanzar, ubica quién actúa, qué prueba queda y qué parte del sistema cambia.

Actor

Indexador

Tu proceso: lee del nodo, escribe en tu base de datos y puede reiniciarse en cualquier punto.

Evidencia

Log con blockHash

Un 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 + cursor

Tablas 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, safe o finalized) 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

Prerequisitos internos recomendados:

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.

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.

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.

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.

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.

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

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. Hoy un bloque tarda unos 15 minutos en finalizar.

Política de confirmación

¿En qué etiqueta actúa cada efecto?

Precaución

latest

Mostrar un depósito como pendiente en la interfaz.

Ideal para
Feedback rápido al usuario, sin mover valor.
Ojo con
Puede desaparecer en una reorg normal; la UI debe poder retirarlo.

safe

Habilitar acciones reversibles o de bajo valor.

Ideal para
Estados intermedios que tu sistema sabe deshacer.
Ojo con
Depende de supuestos de mayoría honesta y sincronía.
Recomendado

finalized

Liberar saldo retirable o disparar efectos fuera de la cadena.

Ideal para
Todo lo que no puedes deshacer: retiros, pagos, conciliación contable.
Ojo con
Si la finalidad se detiene, este carril se congela: alerta y no lo reemplaces por N bloques en silencio.
Riesgo

N bloques

Esperar un número fijo de bloques sobre el log.

Ideal para
Redes o proveedores sin etiquetas safe y finalized.
Ojo con
Es una heurística probabilística; no equivale a finalidad.
La etiqueta es una decisión de negocio por tipo de efecto, no una constante del indexador.

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.

Figura 1

Un cursor con hash detecta la reorg y retrocede hasta el ancestro común

Fuentes:

[1][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.

Visual original de TrautsLab. El indexador aplicó 101 y 102 de la rama vieja. El bloque 103 nuevo no cuelga de 102, así que revierte 102 y 101 hasta el ancestro 100 y aplica la rama nueva. Las bandas indican hasta dónde llegan finalized, safe y 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.

javascript
// 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. 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. 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 blockHash junto 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.

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [1]

    Ethereum Execution APIs specification, schema BlockTag and method eth_getLogs.

    specID: ETH-EXECUTION-APISAbrir fuente (abre en otra pestaña)

    Definición normativa de latest, safe y finalized y semántica de eth_getLogs.

  2. [2]

    ethereum.org, "JSON-RPC API."

    official docsID: ETH-JSON-RPC-DOCSAbrir fuente (abre en otra pestaña)

    Campos de un log, filtros y parámetro de bloque.

  3. [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. [4]

    ethereum.org, "Proof-of-stake (PoS)."

    official docsID: ETH-POS-DOCSAbrir fuente (abre en otra pestaña)

    Slots, epochs, checkpoints y finalidad.

  5. [5]

    ethereum.org, "Single slot finality."

    official docsID: ETH-SSF-ROADMAPAbrir fuente (abre en otra pestaña)

    Tiempo actual hasta finalidad, alrededor de 15 minutos.

  6. [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. [7]

    Solidity documentation, "Events," in Contracts.

    official docsID: SOLIDITY-EVENTSAbrir fuente (abre en otra pestaña)

    Logs, topics y límite de tres parámetros indexed.

  8. [8]

    M. Garnett, "Partial history expiry announcement," Ethereum Foundation Blog, Jul. 8, 2025.

    official blogID: ETH-PARTIAL-HISTORY-EXPIRY-2025Abrir fuente (abre en otra pestaña)

    Clientes de ejecución permiten podar historia previa a The Merge.

Fuentes primarias, papers seminales y especificaciones técnicas citadas en esta investigación.

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.

Sigue leyendo

Ideas conectadas con este artículo

Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.

Los comentarios se cargan al final de la lectura para no meter peso antes de tiempo.