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 a testnet. Use Hoodi (or Sepolia). Holesky reached end of life on 30 Sep 2025 and was shut down after Fusaka (EF blog, 2025-09-01), so don't start new work there. Hoodi launched in March 2025, so its history is short and the initial sync is quick. Like any post-merge node, Reth also needs a consensus client to follow the chain. For a drill, the debug flag --debug.rpc-consensus-url can feed it blocks from an RPC endpoint instead:
cargo run -- node --chain hoodi --debug.rpc-consensus-url <HOODI_RPC_URL>
For a fresh sync, you'll see ChainCommitted for every block. ChainReorged and ChainReverted are rarer. Under proof of stake there is no hashpower race: a reorg happens when a block arrives too late for attesters and the next proposer builds on its parent instead. These reorgs are usually one block deep, and on a healthy chain, testnet or mainnet, you may see none for hours. Watch for them, but don't depend on them. Section 2 of the Testing Stage and ExEx lesson shows how to inject one deterministically with send_notification_chain_reorged.
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");
}
On a testnet, usually far fewer transactions per block than on mainnet, sometimes 0. Mainnet blocks typically carry a few hundred. 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
Live reorgs are occasional and shallow on any healthy PoS chain. Run for a few hours, and also replay a reorg through the test harness so the ChainReorged arm is exercised for certain.
🔍 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.