FABRKNT
Inside Reth — Sync, Extensions, and the SDK
The Reth Stack — Sync, Extensions, and the SDK
Lesson 8 of 17·CONTENT10 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
8 / 17

Lesson 7 — Reading the minimal ExEx, line by line

Question

Read the canonical minimal ExEx from reth-exex-examples. ~40 lines of Rust; shows the three-variant notification handling.

Principle (minimum model)

  • ExExNotification enum. Three variants matched in a match block. Each variant has a different action: Committed (apply forward), ChainReorged (undo old + apply new), Reverted (undo last N).
  • async fn install_exex(ctx: ExExContext) -> Self. Constructor; loads state from disk; returns the ExEx struct.
  • async fn run(&mut self, mut notifications: Receiver<ExExNotification>). Event loop. while let Some(n) = notifications.recv().await then match.
  • Block iteration. Committed(chain) gives a Chain of new blocks; iterate with chain.blocks_iter().
  • State diffs. block.state_diff() returns the post-execution state diff; index accounts that changed.
  • Backfill state. Reth's ExExContext::backfill(...) lets you replay from a known block. Used at startup.
  • Type-safety. Reth's Chain type ensures you can't mix Committed with Reorged at the type level.

Worked example + steps

Reading the minimal ExEx, line by line

A working ExEx is 40 lines of Rust. That's it — no fork of Reth, no separate process, just a function you hand to the node builder and Reth runs it inside the same binary. Below is the entire main.rs of paradigmxyz/reth-exex-examples/minimal. By the end of this lesson, every line maps back to a build-up step from the previous one.

use futures::{Future, TryStreamExt};
use reth_exex::{ExExContext, ExExEvent, ExExNotification};
use reth_node_api::FullNodeComponents;
use reth_node_ethereum::EthereumNode;
use reth_tracing::tracing::info;

async fn exex_init<Node: FullNodeComponents>(
    ctx: ExExContext<Node>,
) -> eyre::Result<impl Future<Output = eyre::Result<()>>> {
    Ok(exex(ctx))
}

async fn exex<Node: FullNodeComponents>(mut ctx: ExExContext<Node>) -> eyre::Result<()> {
    while let Some(notification) = ctx.notifications.try_next().await? {
        match &notification {
            ExExNotification::ChainCommitted { new } => {
                info!(committed_chain = ?new.range(), "Received commit");
            }
            ExExNotification::ChainReorged { old, new } => {
                info!(from_chain = ?old.range(), to_chain = ?new.range(), "Received reorg");
            }
            ExExNotification::ChainReverted { old } => {
                info!(reverted_chain = ?old.range(), "Received revert");
            }
        };

        if let Some(committed_chain) = notification.committed_chain() {
            ctx.events.send(ExExEvent::FinishedHeight(committed_chain.tip().num_hash()))?;
        }
    }

    Ok(())
}

fn main() -> eyre::Result<()> {
    reth::cli::Cli::parse_args().run(|builder, _| async move {
        let handle = builder
            .node(EthereumNode::default())
            .install_exex("Minimal", exex_init)
            .launch_with_debug_capabilities()
            .await?;

        handle.wait_for_node_exit().await
    })
}

Now we walk it.

Walk it, line by line

exex_init — the init/run split (Step 5)

async fn exex_init<Node: FullNodeComponents>(
    ctx: ExExContext<Node>,
) -> eyre::Result<impl Future<Output = eyre::Result<()>>> {
    Ok(exex(ctx))
}

exex_init is called once at node startup. Reth passes you ExExContext — a struct bundling notifications (the incoming chain-event stream), events (your channel back to Reth's pruner), and a handle to the node's components. You return a future for Reth to poll forever.

This minimal ExEx does no synchronous setup — it just hands ctx straight to exex. Real ExExes that need File::open(...) or Database::connect(...) would do that work inside exex_init, before returning the future.

🔍 Find in repo. Open the tracking-state example. What does its exex_init do that minimal doesn't?

exex — the long-running future

async fn exex<Node: FullNodeComponents>(mut ctx: ExExContext<Node>) -> eyre::Result<()> {
    while let Some(notification) = ctx.notifications.try_next().await? {
        // ...
    }
    Ok(())
}

The main loop. ctx.notifications is an async channel (a typed queue Reth pushes chain events into); try_next() awaits the next one without blocking the thread — when none is available, the runtime parks the task and runs other ExExes or Reth itself. Cooperative concurrency, no blocking.

When the channel closes (node shutdown), try_next() returns Ok(None), the while let exits, and the function returns Ok(()). Clean termination.

The three-arm match (Step 2)

match &notification {
    ExExNotification::ChainCommitted { new } => { /* ... */ }
    ExExNotification::ChainReorged { old, new } => { /* ... */ }
    ExExNotification::ChainReverted { old } => { /* ... */ }
};

This is the load-bearing decision. All three arms must be present in any non-toy ExEx, because:

  • Missing ChainReorged → your derived state contains the old chain's data forever; the new canonical chain's data is missing because you never saw a ChainCommitted for it.
  • Missing ChainReverted → after a reorg-trigger but before Reth picks a new tip, your state is one chain ahead of canonical with no way to roll back.

The minimal ExEx logs each variant; that's instructive but not useful. Real ExExes update derived state — and getting all three arms right is what separates a working indexer from a phantom-data bug.

Because the indexer needs to undo old's state changes before applying new's. If you only got new, you'd have no way to roll back the old chain's effect on your derived state — and you'd silently double-count or skip transactions.

committed_chain() and FinishedHeight (Step 3)

if let Some(committed_chain) = notification.committed_chain() {
    ctx.events.send(ExExEvent::FinishedHeight(committed_chain.tip().num_hash()))?;
}

Two methods to know:

  • notification.committed_chain() — returns Some(Chain) for ChainCommitted and ChainReorged (the new chain), None for ChainReverted. It's the "what's the canonical state after this notification" accessor.
  • ctx.events.send(ExExEvent::FinishedHeight(...)) — tells Reth's pruner "I've processed up to this block; you can prune below this hash."

Send FinishedHeight after every commit-shaped notification. Forget this and your node accumulates archive data forever (Step 3's disk-bloat scenario from the previous lesson).

🔍 Verify. Open the source of notification.committed_chain() in reth-exex. Confirm the three-cases behavior we just described.

main: wiring the ExEx into a node

fn main() -> eyre::Result<()> {
    reth::cli::Cli::parse_args().run(|builder, _| async move {
        let handle = builder
            .node(EthereumNode::default())
            .install_exex("Minimal", exex_init)
            .launch_with_debug_capabilities()
            .await?;

        handle.wait_for_node_exit().await
    })
}

This is "ordinary Reth node, plus one extension." The install_exex("Minimal", exex_init) is the only ExEx-specific line. Stack multiple install_exex calls to compose extensions.

What real ExExes do

The same repo has more substantial examples:

ExampleWhat it does
backfillReplays historical blocks through your handler at startup
in_memory_stateMaintains a custom indexed state derived from each block
tracking-statePersists ExEx-internal state to a separate DB (so restarts are cheap)
rollupImplements a minimal rollup using only ExEx hooks

🔍 Open rollup. Read until you find where it commits state changes. A rollup as an ExEx — sit with that for a moment. That's the architectural unlock: you don't need to fork Reth to build a rollup; you can build one as an extension.

Recall before the quiz

Without scrolling:

  1. What does exex_init do that exex (the future) cannot?
  2. Why must a non-toy ExEx handle all three notification variants?
  3. What does notification.committed_chain() return for each of the three variants?
  4. What does a "rollup as an ExEx" rely on for finality and data availability?

The next lesson is a quiz. Engage with these recalls now if any answer is shaky.

Summary (3 lines)

  • Minimal ExEx ~40 lines. Three-variant match (Committed / ChainReorged / Reverted) + ExExContext + async run loop.
  • Iterate Chain.blocks_iter(); read block.state_diff() for changes.
  • Backfill via ExExContext::backfill. Type-safety via Chain. Next: quiz.