Lesson 9 — Drill: build a reorg-safe indexer
Question
Build a minimal reorg-safe transfer indexer. Subscribes via ExEx; on each Committed, index Transfer events. Handles reorgs by undoing old block effects.
Principle (minimum model)
- Indexer struct.
HashMap<Address, U256>(account → balance) + a Tokio-based ExEx event loop. - Committed branch. For each block, scan for Transfer events; update balances.
- ChainReorged branch. Undo balances for the old chain; apply for the new. Reuse the same logic.
- Reverted branch. Undo last N blocks; reset balances to the pre-block state.
- Idempotency. If the same block is replayed, balances must end up the same. Critical for correctness.
- Test with anvil-like setup. Spin up Reth + the ExEx; mine some blocks; cause a fake reorg via the test harness; assert balances are correct.
- ~200 lines of Rust. Smaller than a real production indexer (no DB persistence) but covers the reorg pattern.
Worked example + steps
Drill: build a reorg-safe indexer
Reading is rehearsal. Doing is memory. This drill takes you from "I've read about ExEx" to "I have written one and watched it survive a reorg correctly."
Setup
git clone https://github.com/paradigmxyz/reth-exex-examples
cd reth-exex-examples/minimal
cargo build
If the build fails, fix that before proceeding.
Drill 1 — Run the minimal ExEx against a node
You need an existing Reth node, or run with --chain holesky for a small testnet (faster initial sync, more frequent reorgs):
cargo run -- node --chain holesky
For a fresh sync, you'll see ChainCommitted for every block. ChainReorged and ChainReverted are rarer — they require an actual chain disagreement, which holesky generates more often than mainnet (lower hashpower → more contested forks).
Drill 2 — Add a transaction counter
Modify the ChainCommitted arm to print transaction count per block:
ExExNotification::ChainCommitted { new } => {
let total: usize = new.blocks().values()
.map(|b| b.body.transactions.len())
.sum();
info!(committed_chain = ?new.range(), tx_count = total, "Received commit");
}
Holesky: low — usually 5–20 tx per block, sometimes 0. Mainnet: 100–300, depending on block fullness. You're now reading real chain data at zero latency.
Drill 3 — Add a reorg-safe HashMap
Track how many transactions each address sent. Survive reorgs correctly — that's the whole point.
use std::collections::HashMap;
use alloy_primitives::Address;
let mut tx_count: HashMap<Address, u64> = HashMap::new();
while let Some(notification) = ctx.notifications.try_next().await? {
match ¬ification {
ExExNotification::ChainCommitted { new } => {
for (_, block) in new.blocks() {
for tx in block.body.transactions() {
*tx_count.entry(tx.signer()).or_insert(0) += 1;
}
}
}
ExExNotification::ChainReorged { old, new } => {
// Undo old, then apply new — order matters
for (_, block) in old.blocks() {
for tx in block.body.transactions() {
*tx_count.entry(tx.signer()).or_insert(0) -= 1;
}
}
for (_, block) in new.blocks() {
for tx in block.body.transactions() {
*tx_count.entry(tx.signer()).or_insert(0) += 1;
}
}
}
ExExNotification::ChainReverted { old } => {
// Undo old, no replacement yet
for (_, block) in old.blocks() {
for tx in block.body.transactions() {
*tx_count.entry(tx.signer()).or_insert(0) -= 1;
}
}
}
};
if let Some(committed_chain) = notification.committed_chain() {
ctx.events.send(ExExEvent::FinishedHeight(committed_chain.tip().num_hash()))?;
}
}
(Adjust the API names to your local reth's current shape — block.body.transactions() vs .transactions, tx.signer() vs tx.recover_signer(). The point is the structure, not the exact identifier.)
For every Address, the count equals (txs sent on the canonical chain) − (txs sent on segments that were committed-then-reverted). The reorg arm is the trick: it undoes old and applies new in a single notification, atomically.
If you forget the -= in ChainReverted, your counts grow forever. If you forget the -= in ChainReorged, your counts represent the union of old and new chains, not just canonical.
Drill 4 — Verify reorg handling
Holesky generates reorgs occasionally. Run for a few hours.
🔍 Find the reorg. Search your logs for "Received reorg". When one appears:
- Note the
from_chainandto_chainranges.- Spot-check a high-tx address in
tx_countbefore the reorg log line — record its count.- After the reorg log line — record again.
- Manually verify: did the count change consistently with the difference between the old and new chain segments at that address?
If yes — your indexer is reorg-safe. You've written the same kind of code production-grade indexers (e.g., goldsky, the graph) ship.
It matters in exactly one case: when old and new share a common prefix that the runtime helpfully omits from both — but if the implementation does include any shared blocks in both, applying new first would double-count them, then old's undo would zero them out. The convention is undo old first, then apply new, which mirrors the chronological order Reth itself processes the reorg.
End-of-lesson recall
Without scrolling, in your own words:
- What's the rationale for handling
ChainReorgedwith botholdandnewin the same notification? - What invariant does the three-arm pattern preserve in your derived state?
- If you forget
FinishedHeight, what specifically grows on disk over time? - What's the one architectural reason an ExEx beats a separate RPC-polling indexer?
After this drill, you've shipped a reorg-safe node-speed indexer. The same tool now lets you build MEV bots, live risk engines, and rollups.
Summary (3 lines)
- Drill: reorg-safe transfer indexer. Committed = forward; ChainReorged = undo old + apply new; Reverted = undo last N.
- Idempotency is the core invariant. ~200 lines. Tests via Reth + injected reorg.
- Pattern extends to any chain indexer. Next: SDK buildup.