FABRKNT
Consensus Engineering — Building L1 Consensus on Reth
Reading real consensus code
Lesson 5 of 12·CONTENT18 min45 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
Consensus Engineering — Building L1 Consensus on Reth
Lesson role
CONTENT
Sequence
5 / 12

Lesson 5 — Reading Reth's Consensus trait

Question

Reth's Consensus trait is the abstraction over any consensus protocol. Ethereum PoS uses one implementation; Optimism uses another; custom L1s plug in their own. Read the trait + HeaderValidator + understand the integration points.

Principle (minimum model)

  • Consensus trait location. crates/consensus/consensus/src/lib.rs. ~6 methods: validate_header + validate_block + validate_block_post_execution + validate_state + others.
  • HeaderValidator companion. validate_header(header) -> Result<()> — checks parent hash, timestamp, gas limit, etc. Implementations differ per chain (Ethereum / Optimism / custom).
  • Method asymmetry. validate_header is cheap (called per header during sync); validate_block_post_execution is expensive (called after execution, validates state root etc).
  • Stage integration. Reth's Staged Sync calls Consensus::validate_header in the HeaderStage; validate_block in the BodyStage; validate_state in the ExecutionStage.
  • Custom consensus implementation. Implement the trait for your protocol; register via NodeBuilder::consensus(...). Reth uses your impl everywhere it would use the Ethereum one.
  • Default Ethereum impl. EthBeaconConsensus implements the trait for Ethereum PoS — uses the engine API's notion of finality, delegates fork choice to the consensus layer client.
  • Why this design. Decouples consensus from execution. Reth's execution engine doesn't care what consensus you run; the trait is the interface.

Worked example + steps

Reading Reth's Consensus trait

Open Reth's source. Search for "PoS." You will find very little, because Reth doesn't implement PoS or BFT at all — that work lives in the consensus client (Lighthouse, Prysm, or your own engine). So when people say "Hyperliquid runs on Reth" or "Berachain forks Reth for PoL," what consensus surface are they actually touching?

The answer is one trait: Consensus. It's the integration point where any consensus engine plugs into Reth's execution pipeline. This is the trait Hyperliquid's node, Tempo's node, and every Reth-based chain implements.

1. Where consensus lives in Reth

Reth's architecture splits concerns:

LayerResponsibilityComponent
ExecutionRun transactions, produce post-staterevm + executor
StoragePersist blocks, state, receiptsMDBX (Reth's embedded key-value store)
NetworkReceive blocks from peersdevp2p (Ethereum's P2P transport)
ConsensusValidate block correctness per chain rulesConsensus trait

Note: Reth's Consensus trait doesn't pick the chain head. That's the consensus client's job (Lighthouse, Prysm, or custom). Reth's job is to validate blocks it receives — were they built per the rules?

The split: head selection (CL) vs block validation (Reth as EL).

2. The trait — actual source

crates/consensus/consensus/src/lib.rs:

#[auto_impl::auto_impl(&, Arc)]
pub trait Consensus<B: Block>: HeaderValidator<B::Header> {
    type Error;

    fn validate_body_against_header(
        &self,
        body: &B::Body,
        header: &SealedHeader<B::Header>,
    ) -> Result<(), Self::Error>;

    fn validate_block_pre_execution(
        &self,
        block: &SealedBlock<B>,
    ) -> Result<(), Self::Error>;
}

pub trait FullConsensus<N: NodePrimitives>: Consensus<N::Block> {
    fn validate_block_post_execution(
        &self,
        block: &RecoveredBlock<N::Block>,
        result: &BlockExecutionResult<N::Receipt>,
    ) -> Result<(), ConsensusError>;
}

Three methods, three phases of validation. Read them in the order they fire (which is not the order they're declared in the trait above — the pre/structural/post sequence below is the runtime execution order):

validate_block_pre_execution — before running txs

Cheap checks that don't require executing the EVM:

  • Transaction root matches header
  • Receipt root matches header (after applying receipts)
  • Block hash is well-formed
  • Block size within limits
  • Timestamp not too far in future

Fast. Sub-millisecond. Reject obviously broken blocks before wasting EVM cycles.

validate_body_against_header — structural consistency

Body and header must match each other:

  • Transactions root in header == merkleized tx list
  • Withdrawals root == merkleized withdrawals list
  • Ommers hash == hash of uncles list (legacy)

This is cryptographic binding. If body and header don't match, the block is malformed — someone tampered after signing.

validate_block_post_execution — after EVM ran

Now you know the gas used, the post-state, the receipts. Check:

  • Gas used in header == gas computed during execution
  • State root in header == computed post-state root
  • Receipts root == merkleized receipts
  • Logs bloom == aggregated bloom

This is the consensus-critical check. If your fork miscomputes state root, this is where it diverges from mainnet.

3. The HeaderValidator trait — parent

Consensus extends HeaderValidator. The header validator handles:

pub trait HeaderValidator<H>: Send + Sync + Debug {
    fn validate_header(&self, header: &SealedHeader<H>) -> Result<(), ConsensusError>;

    fn validate_header_against_parent(
        &self,
        header: &SealedHeader<H>,
        parent: &SealedHeader<H>,
    ) -> Result<(), ConsensusError>;

    fn validate_header_with_total_difficulty(
        &self,
        header: &H,
        total_difficulty: U256,
    ) -> Result<(), ConsensusError>;
}

Header validation is separate from body validation because:

  • Headers arrive first (during fast sync)
  • Headers can be validated without bodies
  • Light clients need only headers

For Tempo or Hyperliquid: your HeaderValidator impl is where you check things like "is this proposer the elected leader for this slot?" That's your BFT-specific check.

4. The default impl — Ethereum's

For Ethereum mainnet, Reth provides EthBeaconConsensus (in crates/ethereum/consensus). It validates the Engine API contract:

  • Block has the right shape
  • State root matches after execution
  • Gas limit is within ±1/1024 of parent (the EIP-1559 elastic bound)
  • BaseFee follows EIP-1559 formula
  • Timestamps increase

It doesn't do PoS validation — that's the CL's job. Reth trusts the CL has already validated the proposer signature, slot eligibility, attestations, etc.

🔍 Find in repo. Open crates/ethereum/consensus/src/lib.rs and find EthBeaconConsensus::validate_block_post_execution. What's it checking specifically? Make your own list of the checks before scrolling.

5. For Tempo / Hyperliquid — what would change

A custom L1 with BFT consensus would override:

  • HeaderValidator: verify proposer signature, validator set inclusion, view/round number
  • validate_block_post_execution: include consensus-specific post-state checks (e.g., orderbook state for HyperEVM)
  • Slashing-related fields: if header includes slashing evidence, validate it cryptographically

The structure stays the same. You're not rewriting the trait — you're providing a different impl that wires into Reth's NodeBuilder.

6. The NodeBuilder slot

From Inside Reth (you've seen this):

let node = NodeBuilder::new()
    .with_types::<CustomNode>()
    .with_components(
        CustomComponents::default()
            .consensus(MyCustomConsensus::new(validator_set))
    )
    .launch()
    .await?;

MyCustomConsensus implements Consensus<N::Block>. The NodeBuilder wires it into block validation. Done.

This is the same pattern as custom payload builder, custom EVM config, etc. — consensus is one component among six.

7. Practice

In a fresh terminal:

git clone https://github.com/paradigmxyz/reth
cd reth

Then:

  1. Open crates/consensus/consensus/src/lib.rs and read the full Consensus trait
  2. Open crates/ethereum/consensus/src/lib.rs and find an impl
  3. Count: how many methods does EthBeaconConsensus override vs use defaults?
  4. Find the one place validate_block_post_execution is called from (search for the method name)

Final check: in one sentence, what's the boundary between Reth's job (Consensus impl) and a consensus client's job? If your answer doesn't reference "head selection vs block validation," re-read §1.

Summary (3 lines)

  • Consensus trait = ~6 methods (validate_header / validate_block / validate_state etc); HeaderValidator companion.
  • Stage-integrated: HeaderStage calls validate_header; ExecutionStage calls validate_state. Asymmetric costs (cheap header, expensive state).
  • Custom L1s implement the trait + register via NodeBuilder::consensus(...). EthBeaconConsensus is the Ethereum default. Next: Malachite — Rust-native BFT.