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.
Short answers (checked against reth eb4c15e):
- Head and datadir.
ctx.headis the node head at launch (aBlockNumHash).ctx.config.datadir()is the resolved datadir; put your own state under it, e.g.ctx.config.datadir().data_dir().join("my-exex"). Reth's own ExEx WAL lives at<datadir>/exex/wal. - Resume / replay without a resync. Persist the last block you fully processed. In
exex_init, callctx.set_notifications_with_head(ExExHead::new(saved)). Reth re-executessaved+1..=ctx.headfrom its own DB, emitting a revert first ifsavedis no longer canonical, then continues with live notifications. FinishedHeight.ctx.send_finished_height(tip.num_hash())means "I'll never need blocks ≤ this again". It is inclusive and may go down: after a pure revert (ChainReverted), report the block before the reverted range (old.fork_block()), as the snippet below does. The minimum across all ExExes gates pruning and WAL cleanup, and nothing is pruned until every ExEx has sent one.
use futures::{Future, TryStreamExt};
use reth_exex::{ExExContext, ExExHead};
use reth_node_api::FullNodeComponents;
async fn exex_init<Node: FullNodeComponents>(
mut ctx: ExExContext<Node>,
) -> eyre::Result<impl Future<Output = eyre::Result<()>>> {
// Your own state, next to Reth's: e.g. ~/.local/share/reth/mainnet/my-exex
let dir = ctx.config.datadir().data_dir().join("my-exex");
std::fs::create_dir_all(&dir)?;
// Last block you fully processed, loaded from your own store in `dir` (None on first run).
let saved: Option<ExExHead> = None;
if let Some(head) = saved {
// Reth re-executes head+1..=ctx.head from its own DB, then streams live blocks.
ctx.set_notifications_with_head(head);
}
Ok(async move {
while let Some(n) = ctx.notifications.try_next().await? {
if let Some(old) = n.reverted_chain() {
// undo old.blocks_iter() in your state
if n.committed_chain().is_none() {
// ChainReverted only: your last good block is the one before `old`.
// Persist ExExHead::new(parent) to `dir` and report the lower height.
let parent = old.fork_block();
let _ = ExExHead::new(parent);
ctx.send_finished_height(parent)?;
}
}
if let Some(new) = n.committed_chain() {
// apply new.blocks_iter() / new.execution_outcome(), then persist
// ExExHead::new(new.tip().num_hash()) to `dir`
let _ = ExExHead::new(new.tip().num_hash());
ctx.send_finished_height(new.tip().num_hash())?;
}
}
Ok(())
})
}
Principle (minimum model)
ExExNotificationenum. Three variants matched in amatchblock, each carryingArc<Chain>:ChainCommitted { new }(apply forward),ChainReorged { old, new }(undo old + apply new),ChainReverted { old }(undo old).builder.install_exex("id", exex_init). Registers an async init fnexex_init(ctx: ExExContext<Node>) -> eyre::Result<impl Future<Output = eyre::Result<()>>>. Do setup there (open DB, load saved head) and return the long-running future.- The event loop.
ctx.notificationsis aStreamofeyre::Result<ExExNotification>:while let Some(n) = ctx.notifications.try_next().await? { match n { … } }(futures::TryStreamExt). - Block iteration.
ChainCommitted { new }gives anArc<Chain>; iterate withnew.blocks_iter()(receipts vianew.execution_outcome()). - State changes.
chain.execution_outcome()returns theExecutionOutcome(bundle state + receipts);chain.execution_outcome_at_block(n)for one block. - Resume/replay. Persist your last processed block as an
ExExHead; on start callctx.set_notifications_with_head(head). Reth re-executeshead+1..=node headfrom its own DB (no resync), then streams live. For arbitrary ranges:BackfillJobFactory::new(ctx.evm_config().clone(), ctx.provider().clone()).backfill(a..=b). - Exhaustiveness.
ExExNotificationis an enum; amatchwithout a wildcard won't compile until all three variants are handled.
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 head (node head at launch), config (node config, including the datadir), notifications (the incoming chain-event stream), events (your channel back to the ExEx manager), 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. An Err from exex_init fails node launch, so setup problems surface immediately.
🔍 Find in repo. Open
rollup's init (the closure passed toinstall_exexin itsmain). What does it do 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 stream (Reth pushes chain events into it); 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.
An ExEx must run forever. Reth spawns it as a critical task: if the future returns — Ok(()) or Err — Reth treats it as a crashed critical task and stops the node. On normal shutdown the task is cancelled; the trailing Ok(()) is only there to satisfy the type.
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→ when Reth unwinds blocks (pipeline unwind, or on restart when your saved head is no longer canonical), your state keeps blocks that no longer exist.
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(Arc<Chain>)forChainCommittedandChainReorged(the new chain),NoneforChainReverted. It's the "what's the canonical state after this notification" accessor. Its mirror,reverted_chain(), returnsoldforChainReorgedandChainReverted.ctx.events.send(ExExEvent::FinishedHeight(...))(same asctx.send_finished_height(...)) — tells the ExEx manager "I've processed up to and including this block"; the lowest height across all ExExes gates the pruner and ExEx-WAL cleanup.
Send FinishedHeight after every commit-shaped notification. Forget this and (a) the pruner stops pruning (if configured) and (b) the ExEx WAL in <datadir>/exex/wal is never cleared; Reth logs a warning.
🔍 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 — each gets its own notifications and FinishedHeight, but any one exiting or crashing stops the node, and the slowest one gates pruning.
What real ExExes do
The same repo has more substantial examples:
| Example | What it does |
|---|---|
backfill | Exposes an RPC (backfill_start) that re-executes a requested block range with BackfillJobFactory |
in-memory-state | Tracks the plain state in memory, applying and reverting each chain's ExecutionOutcome |
op-bridge | Decodes OP Stack bridge deposit/withdrawal events from L1 into SQLite |
rollup | A minimal rollup that derives its state from L1 contract events, stored in SQLite |
🔍 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. Its finality and data availability come from the host chain the Reth node follows (Holesky in the example); Reth only delivers that chain's canonical notifications.
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 (ChainCommitted / ChainReorged / ChainReverted, each with
Arc<Chain>) + ExExContext +try_next()loop that must never return. - Iterate
chain.blocks_iter(); readchain.execution_outcome()for state changes; sendFinishedHeightso pruning and WAL cleanup can proceed. - Resume with
ctx.set_notifications_with_head(ExExHead); ad-hoc ranges withBackfillJobFactory. Next: quiz.