Quorum IndexerA reorg-safe EVM event indexer
A reference architecture for indexing EVM contract events into PostgreSQL with chain-reorganization handling, idempotent writes and consensus across RPC providers.
- Discipline
- Blockchain & Web3
- Technologies
- Go · PostgreSQL · EVM · Solidity ABI · Outbox
- Demonstrates
- Reorg handling
- Idempotent writes
- Provider consensus
- Explicit confirmation
The challenge
- 01
Recent blocks can be replaced. A naive indexer stores events that later stop existing on the canonical chain.
- 02
RPC providers disagree, lag or fail. Trusting a single one leaves the system blind without noticing.
- 03
Reprocessing a block range must never duplicate balances, transfers or notifications.
Constraints
- Multiple EVM networks
- Exactly-once effects
- Traceability down to the block
Architecture
Components
- RPC providers
- Three or more independent providers per network.
- Head quorum
- Advances only when a majority agrees on the latest block.
- Block fetcher
- Fetches blocks and logs in batches, with per-provider retries.
- Reorg detector
- Compares parentHash against the last N stored hashes.
- ABI decoder
- Turns raw logs into typed events per contract.
- PostgreSQL
- Upsert by (chain_id, tx_hash, log_index); rollback by block hash.
- Query API
- Queries return their confirmation level explicitly.
- Webhooks
- Notifications only after the configured confirmation depth.
Engineering decisions
D1A block hash on every row
- Choice
- Each event stores block_number and block_hash; a reorg deletes rows by orphaned hashes.
- Rejected alternative
- Storing only the block number.
- Why
- The block number doesn't change in a reorg, the hash does. Without it there's no way to know which rows became invalid.
D2Idempotent natural key
- Choice
- Upsert by (chain_id, tx_hash, log_index).
- Rejected alternative
- IDs generated at insert time.
- Why
- Reprocessing a range becomes a safe operation without duplicates, instead of a risky one.
D3Confirmation as data, not an assumption
- Choice
- The API exposes each event's state (pending, safe, finalized) and webhooks wait for the configured depth.
- Rejected alternative
- Treating every indexed event as final.
- Why
- Consumers have different tolerances. A dashboard accepts recent data; a settlement process doesn't.
D4Quorum across providers
- Choice
- Advancing the chain head requires agreement from a majority of providers.
- Rejected alternative
- One primary provider with a fallback.
- Why
- A fallback only acts when the provider is down. A quorum also catches the provider that answers but is lagging or divergent.
Implementation details
The fetcher keeps the hashes of the last 128 processed blocks in memory and in the database. When a new block arrives, it compares parentHash with the stored hash at the previous height; on mismatch it walks back to the common ancestor.
Rollback and re-application happen in the same transaction, so consumers never observe an intermediate state. Webhooks are emitted through an outbox, only after commit and after the confirmation depth.
Each network runs as an independent worker. A slow or unstable network never delays the others.
// findCommonAncestor walks back from a new block until its parent matches what we stored.
func (ix *Indexer) findCommonAncestor(ctx context.Context, b *types.Header) (uint64, error) {
for height := b.Number.Uint64() - 1; ; height-- {
stored, ok := ix.recent.Hash(height)
if !ok {
return 0, ErrReorgDeeperThanWindow // alert: needs a manual resync
}
canonical, err := ix.quorum.HeaderByNumber(ctx, height)
if err != nil {
return 0, err
}
if canonical.Hash() == stored {
return height, nil // rows above this height are orphaned
}
}
}Outcomes
Not applicable. As an internal demonstration there are no verified production metrics to report, and we don't publish estimates dressed up as results.
Facing a similar problem?
Tell us the context. We discuss architecture before any commercial proposal.
Next studyTessera RiskA pre-trade risk gateway