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)
ExExNotificationenum. Three variants matched in amatchblock. 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().awaitthen match.- Block iteration.
Committed(chain)gives aChainof new blocks; iterate withchain.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
Chaintype 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 ¬ification {
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-stateexample. What does itsexex_initdo thatminimaldoesn'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 ¬ification {
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 aChainCommittedfor 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()— returnsSome(Chain)forChainCommittedandChainReorged(the new chain),NoneforChainReverted. 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()inreth-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:
| Example | What it does |
|---|---|
backfill | Replays historical blocks through your handler at startup |
in_memory_state | Maintains a custom indexed state derived from each block |
tracking-state | Persists ExEx-internal state to a separate DB (so restarts are cheap) |
rollup | Implements 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:
- What does
exex_initdo thatexex(the future) cannot? - Why must a non-toy ExEx handle all three notification variants?
- What does
notification.committed_chain()return for each of the three variants? - 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.