Un indexador EVM correcto bloque por bloque obtiene un bloque con eth_getBlockByNumber(blockParameter, true) para que las transacciones lleguen dentro del bloque, luego obtiene los recibos correspondientes para el mismo parámetro de bloque, preferiblemente en una sola llamada con eth_getBlockReceipts. Los recibos se devuelven en orden de índice de transacción, por lo que la clave de unión es el índice y el transactionHash es una verificación cruzada; los logs dentro de un recibo pertenecen a esa transacción, y su logIndex es global al bloque, por lo que ordenar las filas por (blockNumber, transactionIndex, logIndex) hace que la salida sea estable. El procesador debe recorrer un rango numérico fijo, persistir el último número de bloque completamente confirmado en la misma transacción que sus filas, y tratar las confirmaciones como una entrada de configuración explícita. Este artículo ofrece un bucle Node.js ejecutable, una tabla de resultados para completar con los conteos de tu propio endpoint, y una lista de verificación de solución de problemas para lecturas cortas, filas duplicadas, orden incorrecto, recibos faltantes, escrituras por reorganización y fallos por límite de velocidad.
Qué debe ensamblar un procesador de bloques desde la superficie JSON-RPC
Un indexador EVM bloque por bloque no es un raspador de logs; es un pipeline de reconciliación que produce un conjunto de filas internamente consistente por bloque. La superficie JSON-RPC estándar te da tres vistas relacionadas: el bloque con sus transacciones, los recibos de esas transacciones y los logs incrustados en cada recibo. La especificación JSON-RPC de Ethereum execution-apis define eth_getBlockByNumber, eth_getBlockReceipts, eth_getTransactionReceipt y eth_getLogs como los métodos autoritativos para estas vistas, y la página de la API JSON-RPC de ethereum.org documenta la misma superficie para desarrolladores de aplicaciones.
El trabajo consiste en unir esas vistas sin omitir, duplicar ni desordenar un bloque. Eso significa obtener un bloque con eth_getBlockByNumber(blockParameter, true) para que los objetos de transacción vengan dentro del bloque, y luego obtener los recibos correspondientes para el MISMO parámetro de bloque. Si tu proveedor lo admite, eth_getBlockReceipts devuelve todos los recibos de un bloque en una sola llamada; de lo contrario, recurre a eth_getTransactionReceipt por transacción. La clave de unión es el índice de transacción, con transactionHash como verificación cruzada, nunca una marca de tiempo ni un orden asumido. Para las definiciones canónicas de los métodos, consulta la especificación JSON-RPC de Ethereum execution-apis y la referencia de la API JSON-RPC de Ethereum.
- Vista de bloque: eth_getBlockByNumber(blockParameter, true) devuelve transacciones como objetos, no como hashes.
- Vista de recibos: eth_getBlockReceipts(blockParameter) devuelve recibos en orden de índice de transacción; eth_getTransactionReceipt es el respaldo por transacción.
- Vista de logs: los logs dentro de un recibo pertenecen a esa transacción, y su logIndex es global al bloque.
- Orden estable: ordena las filas por (blockNumber, transactionIndex, logIndex).
Por qué el parámetro de bloque es una decisión de corrección, no de estilo
Indexar 'latest' es un objetivo móvil: el bloque que obtienes puede cambiar entre la llamada al bloque y la llamada al recibo, y una reorganización puede reescribirlo mientras escribes filas. Un procesador debe recorrer un rango numérico fijo, persistir el último número de bloque completamente confirmado en la misma transacción que sus filas, y tratar las confirmaciones o la profundidad de finalidad como una entrada de configuración explícita en lugar de una suposición. Esta es la diferencia entre un pipeline que puede demostrar que nunca omitió un bloque y uno que solo espera no haberlo hecho.
El parámetro de bloque también interactúa con el comportamiento del proveedor. Los requisitos de archivo, los límites de velocidad y los topes en escaneos amplios de eth_getLogs están documentados / varían según el proveedor, por lo que tu configuración debe exponerlos como entradas. Si necesitas detectar un nodo que está detrás de la punta de la cadena antes de iniciar un rango, consulta Detectar un nodo RPC detrás de la punta de la cadena. Para la selección de endpoints y las ventajas y desventajas de los proveedores, la guía de nodos RPC de Ethereum es una referencia útil.
El contrato de unión: receipts.length, transactionHash y logIndex global al bloque
La función de unión es donde la mayoría de los indexadores se rompen silenciosamente. Debe afirmar que receipts.length === block.transactions.length y que cada receipt.transactionHash coincide con el hash de la transacción correspondiente del bloque. Si cualquiera de las afirmaciones falla, el bloque no es seguro de confirmar; el procesador debe reintentar la obtención o detener el rango en lugar de escribir filas parciales. Este es el mecanismo que evita que un nodo retrasado empareje un bloque de una vista con recibos de otra.
Los recibos se devuelven en orden de índice de transacción, por lo que el índice es la clave de unión. Los logs dentro de un recibo pertenecen a esa transacción, pero su logIndex es global al bloque, lo que significa que ordenar los logs por recibo es incorrecto. Ordenar las filas por (blockNumber, transactionIndex, logIndex) es lo que hace que la salida sea estable entre reinicios y reobtenciones. Para el método de recibos masivos en sí, consulta Obtener todos los recibos de un bloque con eth_getBlockReceipts; para la mecánica de filtrado de logs, consulta Filtrar logs de eventos con eth_getLogs y topics.
- Afirma receipts.length === block.transactions.length antes de cualquier escritura.
- Afirma receipt.transactionHash === block.transactions[i].hash para cada i.
- Usa transactionIndex como clave de unión; usa transactionHash como verificación cruzada.
- Ordena la salida por (blockNumber, transactionIndex, logIndex), no por el orden local de logs del recibo.
Un bucle de procesador Node.js ejecutable con cursor atómico y verificación de continuidad
El bucle a continuación obtiene un bloque y sus recibos, los une con afirmaciones, persiste las filas y el cursor en una sola transacción, y verifica la continuidad de parentHash. Usa un cliente JSON-RPC genérico y una transacción SQL genérica; adapta el controlador a tu base de datos. Las propiedades clave son: el paso de obtención devuelve {block, receipts, rows}; el paso de unión afirma conteos y hashes; el paso de persistencia escribe filas y el cursor de forma atómica; y la verificación de continuidad desencadena una reindexación acotada cuando parentHash no coincide con el hash del bloque anterior.
Ejecuta esto contra un rango numérico fijo, no 'latest'. El cursor es el último número de bloque completamente confirmado, y se escribe en la misma transacción que las filas, por lo que un fallo no puede dejar el cursor por delante de los datos. Si la verificación de continuidad falla, reindexa una ventana acotada (por ejemplo, los últimos N bloques) en lugar de toda la cadena.
// Node.js 18+ (ESM). Generic JSON-RPC + SQL transaction. Adapt driver to your DB.
import { JsonRpcProvider } from 'ethers'; // or any JSON-RPC client
const provider = new JsonRpcProvider(process.env.RPC_URL);
const CONFIRMATIONS = Number(process.env.CONFIRMATIONS ?? 12);
const REORG_WINDOW = Number(process.env.REORG_WINDOW ?? 64);
async function fetchBlockAndReceipts(blockNumber) {
const block = await provider.send('eth_getBlockByNumber', [
'0x' + blockNumber.toString(16), true
]);
if (!block) throw new Error(`missing block ${blockNumber}`);
let receipts;
try {
receipts = await provider.send('eth_getBlockReceipts', [
'0x' + blockNumber.toString(16)
]);
} catch (e) {
// Fallback: per-transaction receipts when bulk method is unavailable.
receipts = await Promise.all(
block.transactions.map((tx) =>
provider.send('eth_getTransactionReceipt', [tx.hash])
)
);
}
return { block, receipts };
}
function joinBlock(block, receipts) {
if (receipts.length !== block.transactions.length) {
throw new Error(
`receipt count mismatch: ${receipts.length} vs ${block.transactions.length}`
);
}
const rows = [];
for (let i = 0; i < block.transactions.length; i++) {
const tx = block.transactions[i];
const rc = receipts[i];
if (rc.transactionHash.toLowerCase() !== tx.hash.toLowerCase()) {
throw new Error(`hash mismatch at index ${i}`);
}
rows.push({
blockNumber: parseInt(block.number, 16),
transactionIndex: i,
transactionHash: tx.hash,
from: tx.from,
to: tx.to,
status: rc.status,
gasUsed: rc.gasUsed,
logs: rc.logs.map((log) => ({
logIndex: parseInt(log.logIndex, 16),
address: log.address,
topics: log.topics,
data: log.data
}))
});
}
// Stable ordering: block-global logIndex, then transactionIndex.
rows.sort((a, b) => a.transactionIndex - b.transactionIndex);
for (const row of rows) row.logs.sort((a, b) => a.logIndex - b.logIndex);
return rows;
}
async function persistBlock(db, block, rows, cursor) {
await db.query('BEGIN');
try {
for (const row of rows) {
await db.query(
'INSERT INTO tx_rows (block_number, tx_index, tx_hash, payload) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING',
[row.blockNumber, row.transactionIndex, row.transactionHash, row]
);
}
await db.query(
'INSERT INTO cursor (id, last_block) VALUES (1,$1) ON CONFLICT (id) DO UPDATE SET last_block = EXCLUDED.last_block',
[cursor]
);
await db.query('COMMIT');
} catch (e) {
await db.query('ROLLBACK');
throw e;
}
}
async function runRange(db, fromBlock, toBlock) {
let cursor = fromBlock - 1;
let prevHash = null;
for (let n = fromBlock; n <= toBlock; n++) {
const { block, receipts } = await fetchBlockAndReceipts(n);
if (prevHash && block.parentHash.toLowerCase() !== prevHash.toLowerCase()) {
// Bounded re-index: step back and re-process the reorg window.
const rewind = Math.max(fromBlock, n - REORG_WINDOW);
console.warn(`reorg detected at ${n}; rewinding to ${rewind}`);
n = rewind - 1;
prevHash = null;
continue;
}
const rows = joinBlock(block, receipts);
await persistBlock(db, block, rows, n);
cursor = n;
prevHash = block.hash;
}
return cursor;
}
// Metrics assertion: expected blocks for an interval must match committed blocks.
function assertInterval(expected, committed) {
if (expected !== committed) {
throw new Error(`interval mismatch: expected ${expected}, committed ${committed}`);
}
}Detección de reorganizaciones y reindexación acotada con blockHash y parentHash
blockHash y parentHash son lo que permite a un procesador detectar una reorganización y reindexar el rango afectado. Cuando obtienes el bloque N, su parentHash debe ser igual al hash del bloque N-1 que ya confirmaste. Si no lo es, la cadena se ha reorganizado y tus filas confirmadas para el rango afectado pueden estar obsoletas. La respuesta correcta es una reindexación acotada: retrocede una ventana configurada, vuelve a obtener esos bloques y sobrescribe o versiona las filas afectadas.
Las escrituras por reorganización que mutan filas ya confirmadas sin una versión o un marcador de reindexación son un fallo común. Versiona las filas por blockHash o márcalas con una bandera de reindexación para que los consumidores posteriores puedan distinguir datos canónicos de huérfanos. El tamaño de la ventana es una entrada de configuración, no una constante; debe reflejar la profundidad de reorganización observada en la cadena y tu configuración de confirmaciones.
- Compara block.parentHash con el hash del bloque previamente confirmado en cada iteración.
- En caso de discrepancia, retrocede una ventana acotada y reprocesa; no continúes hacia adelante.
- Versiona las filas por blockHash o marca las filas reindexadas para que los consumidores puedan filtrar huérfanos.
- Mantén la ventana de reorganización y las confirmaciones como entradas de configuración explícitas.
Fallos comunes y cómo se manifiesta cada uno en el pipeline
Las lecturas cortas omiten silenciosamente un bloque cuando una solicitud falla a mitad del rango y el bucle avanza de todos modos. El síntoma es un hueco en los números de bloque confirmados sin ningún error. Las filas duplicadas al reiniciar ocurren cuando el cursor se escribió antes que los datos; el síntoma son claves (blockNumber, transactionIndex) repetidas. El orden incorrecto ocurre cuando los logs se ordenaron por recibo en lugar de globalmente por bloque; el síntoma son valores de logIndex que no son monótonos en todo el bloque.
Los recibos faltantes ocurren cuando el bloque se obtuvo de un nodo retrasado mientras que los recibos vinieron de otro; el síntoma es una discrepancia en el conteo de recibos o una discrepancia en transactionHash. Las escrituras por reorganización que mutan filas ya confirmadas sin una versión o un marcador de reindexación se manifiestan como consumidores posteriores que ven transacciones que luego desaparecen. Los fallos por límite de velocidad o tiempo de espera en escaneos amplios de eth_getLogs se manifiestan como errores intermitentes 429 o de tiempo de espera; el patrón de mitigación es reducir el rango, agregar retroceso exponencial y usar un proveedor con límites documentados. Para los límites de rango de BNB y patrones de confiabilidad, consulta el centro de aprendizaje de OnFinality y las páginas de confiabilidad enlazadas allí.
- Lectura corta: hueco en los números de bloque confirmados, sin error generado.
- Filas duplicadas: cursor escrito antes que los datos; claves primarias repetidas al reiniciar.
- Orden incorrecto: logIndex no monótono en todo el bloque.
- Recibos faltantes: discrepancia en el conteo de recibos o en transactionHash.
- Escrituras por reorganización: filas confirmadas mutan sin una versión o marcador de reindexación.
- Límites de velocidad: 429 intermitentes o tiempo de espera en escaneos amplios de eth_getLogs.
Tabla de resultados: mide los conteos de bloques, transacciones y recibos de tu propio endpoint
No confíes en los números destacados de un proveedor; mide tu propio endpoint contra un rango de bloques fijo. Completa la tabla a continuación con los conteos que observes para un rango que controles, luego compara las filas confirmadas con los conteos esperados. Este es el método verificado por el lector: los números son tuyos, no nuestros. Si tu endpoint devuelve menos recibos que transacciones para algún bloque, detente e investiga antes de confirmar.
Usa el mismo rango para cada fila para que la comparación sea significativa. Si cambias de proveedor o de región, vuelve a ejecutar la tabla; los topes del proveedor, los requisitos de archivo y los límites de velocidad están documentados / varían según el proveedor.
- Rango de bloques: [inicio, fin] — rango numérico fijo, no 'latest'.
- Bloques esperados: fin - inicio + 1.
- Bloques observados confirmados: conteo de tu tabla de cursor.
- Transacciones esperadas: suma de block.transactions.length en el rango.
- Recibos observados: suma de receipts.length en el rango.
- Conteo de discrepancias: bloques donde receipts.length !== transactions.length.
- Eventos de reorganización: conteo de fallos de continuidad de parentHash.
- Errores por límite de velocidad: conteo de respuestas 429 o de tiempo de espera.
Lista de verificación de solución de problemas para un indexador bloque por bloque
Recorre esta lista de verificación cuando tu pipeline reporte una discrepancia o un hueco. Cada elemento se asigna a un modo de fallo específico y a una solución específica. Mantén la lista en tu runbook para que un ingeniero de guardia pueda seguirla sin volver a derivar el mecanismo.
Si necesitas simular una llamada contra un estado histórico mientras depuras, eth_call con anulaciones de estado es una técnica complementaria útil. Para la selección de endpoints y las ventajas y desventajas de precios, consulta Precios de RPC y las páginas del servicio de API.
- Verifica que el parámetro de bloque sea un número fijo, no 'latest'.
- Afirma receipts.length === block.transactions.length antes de escribir.
- Afirma que cada receipt.transactionHash coincida con el hash de la transacción del bloque.
- Confirma que el cursor se escriba en la misma transacción que las filas.
- Verifica la continuidad de parentHash con el hash del bloque previamente confirmado.
- Confirma que los logs estén ordenados por logIndex global al bloque, no por recibo.
- Confirma que el bloque y los recibos provengan del mismo nodo y del mismo parámetro de bloque.
- Busca errores 429 o de tiempo de espera en escaneos amplios de eth_getLogs y reduce el rango.
- Vuelve a ejecutar la tabla de resultados después de cualquier cambio de proveedor o región.
Limitaciones, suposiciones y compensaciones
Este diseño asume un endpoint JSON-RPC que devuelve vistas consistentes de bloque y recibos para el mismo parámetro de bloque. No asume que eth_getBlockReceipts esté disponible; el respaldo a eth_getTransactionReceipt por transacción es parte del contrato. Asume que tu base de datos admite transacciones atómicas; si no lo hace, necesitas un patrón de escritura idempotente equivalente con una columna de versión.
Las compensaciones son reales. Obtener recibos por transacción es más lento y consume más solicitudes que el método masivo. Una ventana de reorganización más grande cuesta más trabajo de reindexación pero reduce la probabilidad de servir filas obsoletas. Las confirmaciones agregan latencia pero reducen la exposición a reorganizaciones. Los topes del proveedor, los requisitos de archivo y los límites de velocidad están documentados / varían según el proveedor, por lo que tu configuración debe exponerlos como entradas en lugar de codificarlos de forma fija.
- Asume vistas consistentes de bloque y recibos para el mismo parámetro de bloque.
- Asume transacciones de base de datos atómicas o un patrón de escritura idempotente equivalente.
- El respaldo de recibos por transacción es más lento y consume más solicitudes que los recibos masivos.
- Ventanas de reorganización más grandes cuestan más trabajo de reindexación pero reducen el riesgo de filas obsoletas.
- Las confirmaciones agregan latencia pero reducen la exposición a reorganizaciones.
- Los topes del proveedor, los requisitos de archivo y los límites de velocidad están documentados / varían según el proveedor.
Próximos pasos: de un bucle funcional a un indexador de producción
Una vez que el bucle sea correcto para un rango fijo, los siguientes pasos son operativos: agrega métricas para bloques confirmados por intervalo, expón el cursor y la ventana de reorganización como configuración, y ejecuta la tabla de resultados contra cada endpoint que planees usar. Para endpoints específicos de red, consulta Ethereum en OnFinality. Para precios y alcance del servicio, consulta Precios de RPC y el servicio de API.
Si estás eligiendo un endpoint, la guía de nodos RPC de Ethereum cubre las ventajas y desventajas de los proveedores. Si necesitas detectar un nodo que está detrás de la punta de la cadena antes de iniciar un rango, consulta Detectar un nodo RPC detrás de la punta de la cadena. Para el método de recibos masivos y la mecánica de filtrado de logs, consulta Obtener todos los recibos de un bloque con eth_getBlockReceipts y Filtrar logs de eventos con eth_getLogs y topics.
- Agrega métricas para bloques confirmados por intervalo y alerta en caso de discrepancia.
- Expón el cursor, las confirmaciones y la ventana de reorganización como configuración.
- Ejecuta la tabla de resultados contra cada endpoint que planees usar.
- Vuelve a ejecutar la tabla después de cualquier cambio de proveedor o región.
- Mantén la lista de verificación de solución de problemas en tu runbook.