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

Lesson 10 — Building the node-builder API step by step

Question

Reth SDK's NodeBuilder is how custom L1s are built. OP-Reth / bera-reth / Tempo all use it. Build the API from scratch.

Principle (minimum model)

  • Step 0 — naive. EthereumNode::new(config).run(). Hardcoded Ethereum.
  • Step 1 — generic over types. NodeBuilder<Types>::new(). Types parameter holds chain-specific types (TxEnvelope etc).
  • Step 2 — component slots. .consensus(...).network(...).pool(...).executor(...).validator(...).rpc(...). Six slots; each is a trait impl that can be swapped.
  • Step 3 — add_ons. .add_ons(your_custom_layer). Lets you stack additional behaviour.
  • Step 4 — launch(). Triggers component setup; returns a Handle.
  • Step 5 — Handle::await_run().await. Run the node; can .shutdown() gracefully.
  • Why a builder. Each component is independent; the builder makes the wiring explicit; type-checked at compile time.

Worked example + steps

Building the node-builder API step by step

ExEx extends an existing Ethereum node. The Reth SDK lets you build your own App-chain in Rust by composing components. This is where "purpose-built EVM L1" stops being a thesis and starts being a binary you can compile.

But the API has fluent calls — with_types, with_components, with_add_ons, launch — and each one decides something architecturally different. This lesson builds it up from "the dumbest thing that works," so you can see why each method earns its keep.

By the end you'll have built every piece of:

fn main() {
    Cli::parse_args()
        .run(async move |builder, _| {
            let handle = builder
                .with_types::<EthereumNode>()
                .with_components(
                    EthereumNode::components().pool(CustomPoolBuilder::default())
                )
                .with_add_ons(EthereumAddOns::default())
                .launch()
                .await?;
            handle.wait_for_node_exit().await
        })
        .unwrap();
}

📂 Open paradigmxyz/reth/examples/custom-node-components/src/main.rs in another tab. That's the file we're building toward.

Step 0 — The naive App-chain: fork all of Reth

Without thinking, you'd ship a custom L1 by:

git clone https://github.com/paradigmxyz/reth my-chain
cd my-chain
# edit crates/everything to taste
cargo build

Fork all 200K+ lines of Reth, edit whatever you need, ship the result.

The three:

  1. Upstream divergence. Paradigm ships Reth updates every week. Your fork has no way to pull them in cleanly — every release becomes a rebase nightmare. Six months in, you can't safely upgrade.
  2. Surface area you don't want. You forked because you wanted to change one subsystem. Now you own everything — bugs in code you never read, security patches you have to track, modules you'll never modify but must still build.
  3. Review cost. A reviewer can't tell which 50 lines you actually changed vs the 200K you didn't. Audits, security firms, regulators — all have to treat your fork as a brand-new client.

The fix: don't fork. Compose. Override only the subsystems you actually want to change; depend on Reth as a library for everything else.

Step 1 — Identify the swap points

What subsystems would you actually want to customize for a real chain?

The candidates that show up across real production chains:

SubsystemWhat you swapProduction example
Pooladmission rules, priority lanesTempo runs a payments-priority lane that beats gas-price ordering
Networkpeer policy, private subnetsprivate gossip subnet so validators preferentially gossip to each other
Executorcustom opcodes, precompiles, gas tableHyperliquid adds HyperBFT-specific precompiles
ConsensusPoS → HyperBFT, PoA, TendermintHyperliquid's HyperBFT, Berachain's Polaris
Payloadblock builderMEV-aware, app-specific ordering (Tempo's payments-first ordering)
Add-onscustom JSON-RPC namespaces, ExEx hookstidx's tidx_* namespace, censorship-monitoring ExEx

These are exactly the override points the SDK exposes. Everything else (sync orchestrator, MDBX schema, header download, sender recovery, hashing stages) you take from Reth as-is.

Step 2 — First sketch: pass overrides as a struct

Naive composition API:

struct NodeConfig<P, N, E, C, Pl> {
    pool: P,
    network: N,
    executor: E,
    consensus: C,
    payload: Pl,
}

fn main() {
    let cfg = NodeConfig {
        pool: CustomPool::default(),
        network: DefaultNetwork::default(),
        executor: DefaultExecutor::default(),
        consensus: DefaultConsensus::default(),
        payload: DefaultPayload::default(),
    };
    Reth::run(cfg);
}

Pass a struct of overrides. Works, but clunky.

The two:

  1. You spell out every field every time — even the ones you didn't customize. The struct enforces all-or-nothing.
  2. Type inference falls apart fast. Each component has its own generic parameters. Specifying them all as a single struct creates a tangled type signature you can't reason about. Concretely:
// You only want to swap the pool, but the compiler demands every parameter
let cfg: NodeConfig<
    CustomPool,
    DefaultNetwork<EthereumPrimitives, DefaultDiscovery>,
    DefaultExecutor<EthereumChainSpec, EthereumPrimitives>,
    DefaultConsensus<EthereumChainSpec>,
    DefaultPayload<EthereumPayloadBuilder, EthereumPrimitives>,
> = NodeConfig { /* ... */ };

You end up spelling Reth-internal concrete types on every line, and every library bump that touches one of those types forces you to rewrite the file. In practice no user code goes near this.

Builder pattern fixes both. Each method takes one override and returns a new builder type — Rust's type system tracks the changes, defaults stay implicit.

Step 3 — Builder pattern: fluent chained calls

let handle = builder
    .with_pool(CustomPool::default())
    // skip the rest — defaults
    .launch()
    .await?;

Each with_* call returns a new builder type. Defaults stay invisible. You only spell out what you actually overrode.

But "one method per component" doesn't capture all of Reth's customization surface. There's a higher-level grouping: types (block/tx/header layouts), components (the runtime subsystems above), and add-ons (RPC, ExEx). The real SDK splits the builder along those three axes.

Step 4 — .with_types::<EthereumNode>()

.with_types::<EthereumNode>()

This picks the type bundle — chain spec, primitives (block, tx, header types), engine API.

Why is this its own step? Because the types are load-bearing for everything else. If you change the tx structure, every component (pool, executor, payload, network) has to use the new tx type. So the SDK forces you to commit to a type bundle first — before configuring components.

EthereumNode ships defaults (Ethereum block + tx + header). You can also pass OpNode (Optimism types) or your own NodeTypes impl.

The components would have to be specified before the types they operate on are known. Either every component-builder would need to be generic over not-yet-chosen types (compiler-hostile), or you'd commit to types implicitly via .with_components's arguments (silent and confusing). with_types first lets the rest of the chain be type-aware.

Step 5 — .with_components(...): where customization lives

.with_components(EthereumNode::components().pool(CustomPoolBuilder::default()))

The trick: take the base set of components (EthereumNode::components()) and override individual builders by chaining .pool(...), .network(...), .executor(...) etc.

Same override-only-what-you-need pattern from Step 3, applied to the runtime subsystems. Defaults stay invisible:

.with_components(
    EthereumNode::components()
        .pool(CustomPoolBuilder::default())   // overridden
        // .network is default
        // .executor is default
        // .consensus is default
        // .payload is default
)

You wrote one custom builder. The rest came from Reth.

Roughly:

trait PoolBuilder<Node>: Send {
    type Pool;
    fn build_pool(self, ctx: &BuilderContext<Node>)
        -> impl Future<Output = eyre::Result<Self::Pool>>;
}

One method: given a context (which contains chain spec, primitives, etc.), build the pool. Components are built lazily, not passed pre-constructed, because they need the context that earlier builder steps assembled. (We'll meet this shape across all 6 builders in the next lesson.)

Step 6 — .with_add_ons(...) and .launch()

.with_add_ons(EthereumAddOns::default())
.launch()

Add-ons are the things that aren't load-bearing for the runtime: RPC namespaces, engine API extensions, ExEx installations. EthereumAddOns::default() gives you the standard Ethereum RPC; chain .install_exex(...) calls here for the ExEx pattern from earlier lessons.

.launch() is where the real work happens: opens MDBX, starts the P2P stack, spawns Tokio tasks for sync stages, wires up the RPC server. Returns a NodeHandle that drives the whole thing.

What you've built

let handle = builder
    .with_types::<EthereumNode>()              // (Step 4) the type bundle
    .with_components(                          // (Step 5) runtime subsystems
        EthereumNode::components().pool(CustomPoolBuilder::default())
    )
    .with_add_ons(EthereumAddOns::default())   // (Step 6) RPC + ExEx
    .launch()                                  // (Step 6) boot everything
    .await?;

Every step earned its keep:

  • with_types first (Step 4) — types are load-bearing for everything else
  • with_components with chained overrides (Step 5) — override what you change, default the rest
  • with_add_ons (Step 6) — non-load-bearing extensions
  • launch (Step 6) — the boot

The next lesson walks the 6 components — what each does, what swapping it lets you ship, and which production chains swap which.

Recall before moving on

Without scrolling:

  1. Why doesn't "fork all of Reth" scale to a chain you maintain for years?
  2. Why does .with_types::<EthereumNode>() come first in the chain?
  3. What's the pattern for overriding one component while keeping the others as Reth defaults?
  4. What does .launch() actually start?

If any answer is shaky, scroll back. The next lesson tours the 6 components and what real chains do with them.

🧭 Where you are now in the stack: you've built the node's assembly / DI layer — typed builder of with_typeswith_componentswith_add_onslaunch, where you swap one component while defaults stay implicit. Rust's answer to the same problem Kubernetes operators and Spring containers solve: how to compose 6 subsystems without 6-of-everything boilerplate. Next lesson tours those 6 components.

Summary (3 lines)

  • 6-step buildup: naive → generic Types → 6 component slots → add_ons → launch → Handle.await_run.
  • Each component is a trait impl; type-checked wiring; custom L1s replace components selectively.
  • OP-Reth / bera-reth / Tempo all use this. Next: the 6 components in detail.