Lesson 8 — NodeBuilder consensus slot — wiring custom consensus
Question
Reth's NodeBuilder has an explicit consensus slot for plugging in your custom Consensus impl. Wire your minimal BFT in. The integration point is one line: NodeBuilder::consensus(custom_consensus).
Principle (minimum model)
- NodeBuilder pattern.
NodeBuilder::new().pool(...).consensus(...).network(...).executor(...)— every major slot is overridable. - Consensus slot integration.
NodeBuilder::consensus(your_impl)— registers yourConsensustrait impl; Reth uses it everywhere. Consensusimpl skeleton. ~80 lines for a minimal impl: validate_header (parent hash + timestamp + gas limit), validate_block (txs + state root + receipts root), validate_state (post-execution state root).- Validator-set source. Your impl reads from somewhere — staking contract / chainspec / on-chain registry. Cache for performance.
- Header / block / state validation are separate. Different stages call different methods; cost difference matters for sync throughput.
- Integration test. Spin up a Reth node with your
Consensusimpl + execute a few blocks + assert no validation errors. - Production examples. OP-Reth (uses
OptimismConsensus), bera-reth (usesBeraConsensuswith PoL), Tempo (custom). Same NodeBuilder pattern; different Consensus impl.
Worked example + steps
NodeBuilder consensus slot — wiring custom consensus
You have a custom Consensus impl (you wrote it last lesson). You have Malachite or your own engine driving the votes. How do those two pieces actually become a running node? Answer: one builder, one impl, one chained method call on NodeBuilder — the exact same shape as plugging in a custom mempool or custom EVM.
This lesson walks the call sites. By the end you should be able to sketch the wiring for a new L1 on a whiteboard.
1. The consensus component in Reth's component model
From Inside Reth — six NodeBuilder components:
| Component | Trait | Default |
|---|---|---|
| pool | PoolBuilder | Ethereum pool |
| network | NetworkBuilder | devp2p |
| executor | ExecutorBuilder | Ethereum executor |
| consensus | ConsensusBuilder | EthBeaconConsensus |
| payload | PayloadBuilder | Ethereum payload builder |
| add_ons | AddOns | None |
The ConsensusBuilder slot is where you plug in custom block validation. It produces a FullConsensus impl that NodeBuilder will call during block processing.
2. The trait
pub trait ConsensusBuilder<Node: FullNodeTypes>: Send {
type Consensus: FullConsensus<Node::Primitives>;
fn build_consensus(
self,
ctx: &BuilderContext<Node>,
) -> impl Future<Output = eyre::Result<Self::Consensus>> + Send;
}
One method: build_consensus. It receives the builder context (chainspec, db, etc.) and returns your consensus impl.
This is exactly the same shape as PoolBuilder, PayloadBuilder, etc. The pattern is fractal: each component has a Builder trait that produces the component during launch().
3. The wire-up code
use reth_node_builder::{NodeBuilder, NodeHandle};
use reth_chainspec::ChainSpec;
// Your custom consensus impl
pub struct TempoConsensus {
validator_set: TempoValidatorSet,
chain_spec: Arc<ChainSpec>,
}
impl<B: Block> Consensus<B> for TempoConsensus {
type Error = ConsensusError;
fn validate_block_pre_execution(&self, block: &SealedBlock<B>) -> Result<(), Self::Error> {
// Tempo-specific pre-execution checks:
// - is proposer in validator set?
// - is signature valid?
// - is round number correct?
todo!()
}
// ... other methods
}
// Your custom builder
pub struct TempoConsensusBuilder {
validator_set: TempoValidatorSet,
}
impl<Node: FullNodeTypes> ConsensusBuilder<Node> for TempoConsensusBuilder
where
Node::Primitives: NodePrimitives,
{
type Consensus = TempoConsensus;
async fn build_consensus(
self,
ctx: &BuilderContext<Node>,
) -> eyre::Result<Self::Consensus> {
Ok(TempoConsensus {
validator_set: self.validator_set,
chain_spec: ctx.chain_spec(),
})
}
}
// Wire it into the node
async fn main() -> eyre::Result<()> {
let validator_set = TempoValidatorSet::load_from_chainspec(&chain_spec)?;
let consensus_builder = TempoConsensusBuilder { validator_set };
let handle = NodeBuilder::new(config)
.with_types::<TempoNode>()
.with_components(
TempoComponents::default()
.consensus(consensus_builder)
)
.launch()
.await?;
handle.wait_for_shutdown().await?;
Ok(())
}
That's it. The consensus customization is one builder, one impl, one wiring call. Same shape as everything else in Reth's SDK.
The voting happens in the consensus client (Malachite, CometBFT, or your custom engine). Reth's Consensus trait validates blocks after they've been agreed upon — it doesn't run the voting. The 2f+1 check is upstream of Reth.
4. The consensus client side — Engine API caller
Reth (EL) exposes Engine API. Your consensus client (CL) calls it:
// In your consensus client (Malachite-driven, custom, or other)
// After Malachite's Driver decides on a block:
async fn on_decide(block: TempoBlock, engine_api: EngineApiClient) -> Result<()> {
// Tell Reth: "this is the new head, validate it"
let payload_status = engine_api
.new_payload_v4(block.to_execution_payload())
.await?;
if payload_status.status == PayloadStatus::Valid {
// Tell Reth: "this is finalized, you can prune up to it"
engine_api
.fork_choice_updated_v4(ForkchoiceState {
head_block_hash: block.hash(),
safe_block_hash: block.hash(),
finalized_block_hash: block.hash(),
}, None)
.await?;
}
Ok(())
}
The CL drives Reth via Engine API. Reth validates locally using your TempoConsensus impl. The two communicate over JSON-RPC, just like Lighthouse ↔ Reth in standard Ethereum.
5. The complete picture
sequenceDiagram
participant Network as P2P Network
participant CL as Tempo Consensus<br/>(Malachite)
participant Driver as Malachite Driver
participant EL as Reth<br/>(EL, TempoConsensus)
Network->>CL: Validator votes / proposals
CL->>Driver: Process input
Driver->>Driver: Vote keeper / RSM
Driver->>CL: Decide(block)
CL->>EL: engine_newPayloadV4(block)
EL->>EL: TempoConsensus::validate_block_pre_execution
EL->>EL: Execute via revm
EL->>EL: TempoConsensus::validate_block_post_execution
EL->>CL: PayloadStatus(VALID)
CL->>EL: engine_forkchoiceUpdatedV4(finalized)
Network->>CL: Broadcast confirmation
Two processes, one chain. Reth handles execution + storage + EVM. Malachite handles voting + ordering. Engine API connects them.
🔍 Find in repo. Open Reth's
crates/rpc/rpc-engine-apiand findengine_newPayloadV4. Trace what it does on receiving a payload. What's the order of operations?
6. The error path — when validation fails
If TempoConsensus::validate_block_pre_execution returns an error, Reth:
- Rejects the block (returns
PayloadStatus::Invalid) - Tells the CL the block is invalid
- CL must view-change, find a different proposer
Block validation errors trigger consensus liveness recovery. This is critical — your validation must be deterministic (same answer every time) or different validators will disagree and split-brain.
7. Production considerations
Your Consensus impl runs on the hot path:
- Every block must validate in milliseconds
- Allocate carefully (no per-block heap churn)
- Cache validator set lookups
- Use BLS or threshold signature verification, not naive ECDSA per-validator
For Tempo at ~30 validators, BLS verification is ~5ms. For Hyperliquid at ~20, even faster. Headroom matters.
8. Practice
Sketch (no need to compile):
TempoValidatorSet— what fields does it need? (start with: addresses, voting weights, BLS pub keys)TempoConsensus::validate_header— what 3 checks must it do?- The startup sequence — how does
TempoNodeload the validator set from the chainspec?
Final check: in one sentence, who validates votes (2f+1 detection) — Reth, your consensus client, or both? If your answer doesn't draw the EL/CL line clearly, re-read §3.
Summary (3 lines)
- NodeBuilder consensus slot =
NodeBuilder::consensus(your_impl). Custom Consensus impl is registered; Reth uses it everywhere. - ~80 lines for a minimal Consensus impl (validate_header / validate_block / validate_state). Validator set from staking contract / chainspec / registry.
- Production examples: OP-Reth / bera-reth / Tempo. Same NodeBuilder pattern; different Consensus impl per chain. Next: build a minimal single-leader BFT.