FABRKNT
Inside Reth — Sync, Extensions, and the SDK
The Reth Stack — Sync, Extensions, and the SDK
Lesson 10 of 17·CONTENT12 min25 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Inside Reth — Sync, Extensions, and the SDK
Lesson role
CONTENT
Sequence
10 / 17

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 &notification {
        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:

  1. Note the from_chain and to_chain ranges.
  2. Spot-check a high-tx address in tx_count before the reorg log line — record its count.
  3. After the reorg log line — record again.
  4. 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:

  1. What's the rationale for handling ChainReorged with both old and new in the same notification?
  2. What invariant does the three-arm pattern preserve in your derived state?
  3. If you forget FinishedHeight, what specifically grows on disk over time?
  4. 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.