Um indexador de eventos parece simples: ler blocos em ordem, decodificar logs e gravar em um banco. O problema é que os blocos mais recentes de uma blockchain ainda podem ser substituídos. Quando isso acontece, ocorre uma reorganização (reorg), e um indexador ingênuo continua exibindo transferências, saldos ou eventos que não existem mais na cadeia canônica.
Por que reorganizações acontecem
Em redes como a Ethereum, nós diferentes podem ver, por um curto período, versões diferentes da ponta da cadeia. Quando a rede converge, a versão perdedora é descartada e seus blocos ficam órfãos. As transações desses blocos geralmente voltam ao mempool e podem ser incluídas em outro bloco, com outro número, outra posição e até outro resultado.
Desde a transição para prova de participação, a Ethereum tem um conceito explícito de finalidade. Um slot dura 12 segundos e uma época tem 32 slots; em condições normais, um bloco é finalizado depois de cerca de duas épocas, aproximadamente 13 minutos. Antes disso, reorganizações curtas na ponta da cadeia são possíveis. Redes de camada 2 têm regras próprias: a confirmação do sequenciador é rápida, mas a finalidade depende da publicação e da finalização dos dados na camada 1.
Use os níveis de confirmação que o próprio nó oferece
A API JSON-RPC aceita, além de números de bloco, os marcadores latest, safe e finalized. Eles são a forma mais direta de saber até onde é seguro confiar:
latest: o bloco mais recente que o nó conhece. Pode ser reorganizado.safe: um bloco com baixa probabilidade de reorganização em condições normais de rede.finalized: um bloco finalizado pelo protocolo. Revertê-lo exigiria uma falha grave de consenso.
Uma estratégia simples é indexar até finalized e nunca lidar com reorganizações. O custo é a latência: os dados chegam com minutos de atraso. Muitos produtos precisam de dados mais recentes, então o indexador precisa acompanhar a ponta da cadeia e saber desfazer o que gravou.
Guarde o hash de cada bloco
O número do bloco não muda em uma reorganização. O hash muda. Por isso, todo registro derivado de um bloco deve guardar o hash do bloco de origem, e o indexador deve manter os hashes dos blocos recentes que processou.
CREATE TABLE indexed_blocks (
chain_id integer NOT NULL,
number bigint NOT NULL,
hash bytea NOT NULL,
parent_hash bytea NOT NULL,
PRIMARY KEY (chain_id, number)
);
CREATE TABLE token_transfers (
chain_id integer NOT NULL,
block_number bigint NOT NULL,
block_hash bytea NOT NULL,
tx_hash bytea NOT NULL,
log_index integer NOT NULL,
sender bytea NOT NULL,
recipient bytea NOT NULL,
amount numeric(78,0) NOT NULL, -- uint256 cabe em 78 dígitos decimais
PRIMARY KEY (chain_id, tx_hash, log_index)
);O tipo numeric(78,0) merece nota: valores uint256 não cabem em bigint, e convertê-los para ponto flutuante perde precisão.
Detecte a reorganização pelo parentHash
Ao receber o bloco N, compare o parentHash dele com o hash que você gravou para N - 1. Se forem iguais, a cadeia continua. Se forem diferentes, houve reorganização, e você precisa recuar até encontrar o ancestral comum: o maior bloco cujo hash armazenado coincide com o hash canônico atual.
async function findCommonAncestor(chainId: number, head: Block): Promise<bigint> {
let height = head.number - 1n;
while (true) {
const stored = await db.blockHash(chainId, height);
if (!stored) throw new Error(`Reorg deeper than the stored window at ${height}`);
const canonical = await rpc.getBlockByNumber(height);
if (canonical.hash === stored) return height;
height -= 1n;
}
}Mantenha uma janela de hashes maior que a reorganização mais profunda que você considera plausível. Se a busca sair da janela, pare e alerte: é melhor interromper a indexação do que gravar dados inconsistentes.
Desfaça e reaplique na mesma transação
Encontrado o ancestral, apague tudo o que foi derivado de blocos acima dele e reprocesse a partir dali. Faça as duas coisas na mesma transação do banco, para que nenhum consumidor veja o estado intermediário.
BEGIN;
DELETE FROM token_transfers WHERE chain_id = $1 AND block_number > $2;
DELETE FROM indexed_blocks WHERE chain_id = $1 AND number > $2;
-- reprocessa os blocos canônicos a partir de $2 + 1 e insere os novos registros
COMMIT;Repare que o rollback é feito por número de bloco, não por transação. Depois de uma reorganização, a mesma transação pode reaparecer em outro bloco com outro log_index; apagar pelo bloco garante que não sobra nenhuma versão antiga.
Saldos derivados e efeitos externos
Duas armadilhas aparecem depois que o básico funciona:
- Agregados. Se você mantém saldos somando transferências, o rollback também precisa desfazer essas somas. Muitas vezes é mais simples recalcular os agregados afetados a partir dos registros brutos.
- Efeitos fora do banco. Webhooks, e-mails e chamadas a outros sistemas não podem ser desfeitos com
ROLLBACK. Dispare efeitos externos apenas para blocos com a profundidade de confirmação adequada, usando uma outbox para garantir que cada efeito seja enviado uma única vez.
Exponha a confirmação para quem consome
Diferentes consumidores toleram riscos diferentes. Um painel pode exibir dados de latest; um processo de liquidação deve esperar finalized. Em vez de escolher um único nível para todos, exponha o estado de cada registro na API e deixe o consumidor decidir.
{ "tx_hash": "0x9f…", "amount": "2500000000000000000", "block_number": 21480211, "confirmation": "safe" }Resumo
Reorganizações não são exceções raras a ignorar: são parte do funcionamento normal de uma blockchain. Guardar o hash de cada bloco, detectar divergências pelo parentHash, desfazer e reaplicar atomicamente e expor o nível de confirmação transformam um indexador frágil em uma fonte de dados confiável.
Leituras relacionadas
- Engenharia blockchain · 4 min de leitura
- IA aplicada · 3 min de leitura
- Fintech · 3 min de leitura