Skip to content
← Consensus Engineering — Building L1 Consensus on Reth
Reading real consensus code
Lesson 5 of 12·CONTENT18 min45 XP
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)

  • Three traits, one file. crates/consensus/consensus/src/lib.rs defines HeaderValidator (validate_header, validate_header_against_parent, plus a provided validate_header_range), Consensus<B>: HeaderValidator (validate_body_against_header, validate_block_pre_execution, plus a provided validate_block_pre_execution_with_tx_root) and FullConsensus<N>: Consensus (validate_block_post_execution). Every method returns Result<(), ConsensusError>.
  • Method asymmetry. Header and pre-execution checks need no state and no EVM; validate_block_post_execution needs the execution result (gas used, receipts root, logs bloom, requests hash).
  • The state root is not checked by this trait. The engine tree compares it after execution (crates/engine/tree/src/tree/payload_validator.rs), and in staged sync the MerkleStage does (crates/stages/stages/src/stages/merkle.rs).
  • Where it is called in staged sync. The header downloader used by the HeaderStage calls validate_header / validate_header_against_parent; the bodies downloader calls validate_block_pre_execution; the ExecutionStage calls validate_block_post_execution.
  • Custom consensus implementation. Implement the three traits for your chain, wrap the value in a ConsensusBuilder, and pass that builder to ComponentsBuilder::consensus(...). Reth then uses your impl everywhere it would use the Ethereum one.
  • Default Ethereum impl. EthBeaconConsensus (crates/ethereum/consensus) implements all three for Ethereum. It checks no PoS data; fork choice and attestations belong 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 "Tempo runs on Reth" or "Berachain built bera-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. Any chain that plugs its own validity rules into Reth implements it. Tempo's node does, with TempoConsensus in tempoxyz/tempo crates/evm/src/consensus/mod.rs.

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 at reth eb4c15e (2026-04-07), doc comments and provided methods trimmed:

#[auto_impl::auto_impl(&, Arc)]
pub trait FullConsensus<N: NodePrimitives>: Consensus<N::Block> {
    fn validate_block_post_execution(
        &self,
        block: &RecoveredBlock<N::Block>,
        result: &BlockExecutionResult<N::Receipt>,
        receipt_root_bloom: Option<ReceiptRootBloom>,
    ) -> Result<(), ConsensusError>;
}

#[auto_impl::auto_impl(&, Arc)]
pub trait Consensus<B: Block>: HeaderValidator<B::Header> {
    fn validate_body_against_header(
        &self,
        body: &B::Body,
        header: &SealedHeader<B::Header>,
    ) -> Result<(), ConsensusError>;

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

    // provided: validate_block_pre_execution_with_tx_root(block, Option<B256>)
}

There is no associated Error type; everything returns ConsensusError. The trait signatures move between reth versions (later main adds a block_access_list_hash argument to validate_block_post_execution and an is_transient_error method to Consensus), so read the file at the commit you build against.

For engine_newPayload, the module docs at the top of crates/engine/tree/src/tree/payload_validator.rs list the logical phases: validate_header, validate_block_pre_execution and validate_header_against_parent → execute → validate_block_post_execution → the engine computes the state root itself and compares it to the header. "Pre-execution" means "needs no execution result", not "always runs first": at eb4c15e the engine starts executing the payload while it is still being decoded, and the header, pre-execution, parent and post-execution checks all run together in validate_post_execution once execution is done. In staged sync the order is literal: the header and body downloaders check blocks before the ExecutionStage runs.

validate_block_pre_execution — before running txs

Checks that need no state and no EVM. Ethereum's version (validate_block_pre_execution_with_tx_root in crates/consensus/common/src/validation.rs) checks:

  • Ommers hash matches the body
  • Withdrawals root matches (from Shanghai)
  • Blob gas used matches the blob txs (from Cancun)
  • RLP size within MAX_RLP_BLOCK_SIZE (from Osaka)
  • Transactions root matches the body

Reject obviously broken blocks before spending EVM cycles.

validate_body_against_header — does this body belong to this header?

Called when a body fetched from a peer is paired with a header it already has (for example in crates/net/p2p/src/full_block.rs). Ethereum's version checks:

  • Transactions root in header == root of the tx list
  • Withdrawals root == root of the withdrawals list (both present or both absent)
  • Ommers hash == hash of the ommers list

If these don't match, the body doesn't belong to that header, so the peer sent a wrong or malformed body.

validate_block_post_execution — after the EVM ran

Now you have the receipts. Ethereum's version (crates/ethereum/consensus/src/validation.rs) checks:

  • Gas used in header == cumulative gas used of the last receipt
  • Receipts root and logs bloom match the receipts (from Byzantium)
  • Requests hash matches the execution requests (from Prague)

The state root is not in this list. The engine tree compares it after execution (BodyStateRootDiff in payload_validator.rs), and in staged sync the MerkleStage does. Both checks are consensus-critical: if your fork computes a different receipt root or state root, this is where it splits from the network.

3. The HeaderValidator trait — parent

Consensus extends HeaderValidator. The header validator handles:

#[auto_impl::auto_impl(&, Arc)]
pub trait HeaderValidator<H = Header>: Debug + Send + Sync {
    fn validate_header(&self, header: &SealedHeader<H>) -> Result<(), ConsensusError>;

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

    // provided: validate_header_range(&[SealedHeader<H>]) runs both over a sorted range
}

There is no total-difficulty method; that was removed after the Merge.

Header validation is separate from body validation because:

  • Staged sync downloads headers first and bodies later
  • Headers can be validated without bodies
  • Light clients need only headers

Your HeaderValidator impl is where chain-specific header fields are checked. Tempo's TempoConsensus::validate_header first runs the Ethereum checks, then checks Tempo's own header fields (timestamp_millis_part < 1000, shared_gas_limit, general_gas_limit). Tempo checks leader election and BFT signatures in its consensus engine, outside this trait. A chain that puts a proposer signature in the header could check it here.

4. The default impl — Ethereum's

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

  • Post-Merge header shape: difficulty 0, nonce 0, empty ommers hash
  • Extra data size, gas_used <= gas_limit, base fee present after London
  • Fork-specific fields present or absent (withdrawals root, blob gas, requests hash)
  • Gas limit changes by less than parent/1024 (the old Yellow Paper bound, GAS_LIMIT_BOUND_DIVISOR)
  • Base fee follows the EIP-1559 formula
  • Timestamp greater than the parent's
  • After execution: gas used, receipts root, logs bloom, requests hash (the state root is checked by the engine, not here)

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. It delegates to validate_block_post_execution in validation.rs in the same directory. What's it checking specifically? Make your own list of the checks before scrolling.

5. For Tempo or a Hyperliquid-shaped L1 — what would change

A custom L1 with BFT consensus would override:

  • HeaderValidator: chain-specific header fields; if the header carries them, the proposer signature, validator set inclusion and view/round number
  • validate_block_pre_execution: chain-specific body rules (Tempo checks that its system transactions are valid and at the end of the block)
  • validate_block_post_execution: checks on the execution result for chain-specific receipt or request formats
  • Slashing-related fields: if the header or body 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

ComponentsBuilder::consensus takes a ConsensusBuilder, not a consensus value. Here is a minimal version that wraps EthBeaconConsensus. It compiles against reth eb4c15e (crates reth, reth-node-ethereum, reth-ethereum-primitives):

use std::sync::Arc;
use reth::{
    api::{FullNodeTypes, NodeTypes},
    beacon_consensus::EthBeaconConsensus,
    builder::{components::ConsensusBuilder, BuilderContext},
    chainspec::ChainSpec,
    consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator, ReceiptRootBloom},
    primitives::{EthPrimitives, Header, RecoveredBlock, SealedBlock, SealedHeader},
    providers::BlockExecutionResult,
};
use reth_ethereum_primitives::{Block, BlockBody, Receipt};
use reth_node_ethereum::{node::EthereumAddOns, EthereumNode};

#[derive(Debug, Clone)]
pub struct MyConsensus {
    inner: EthBeaconConsensus<ChainSpec>,
}

impl HeaderValidator<Header> for MyConsensus {
    fn validate_header(&self, header: &SealedHeader<Header>) -> Result<(), ConsensusError> {
        // chain-specific header check goes here
        self.inner.validate_header(header)
    }
    fn validate_header_against_parent(
        &self,
        header: &SealedHeader<Header>,
        parent: &SealedHeader<Header>,
    ) -> Result<(), ConsensusError> {
        self.inner.validate_header_against_parent(header, parent)
    }
}

impl Consensus<Block> for MyConsensus {
    fn validate_body_against_header(
        &self,
        body: &BlockBody,
        header: &SealedHeader<Header>,
    ) -> Result<(), ConsensusError> {
        Consensus::<Block>::validate_body_against_header(&self.inner, body, header)
    }
    fn validate_block_pre_execution(&self, block: &SealedBlock<Block>) -> Result<(), ConsensusError> {
        Consensus::<Block>::validate_block_pre_execution(&self.inner, block)
    }
}

impl FullConsensus<EthPrimitives> for MyConsensus {
    fn validate_block_post_execution(
        &self,
        block: &RecoveredBlock<Block>,
        result: &BlockExecutionResult<Receipt>,
        receipt_root_bloom: Option<ReceiptRootBloom>,
    ) -> Result<(), ConsensusError> {
        FullConsensus::<EthPrimitives>::validate_block_post_execution(
            &self.inner,
            block,
            result,
            receipt_root_bloom,
        )
    }
}

#[derive(Debug, Default, Clone, Copy)]
pub struct MyConsensusBuilder;

impl<Node> ConsensusBuilder<Node> for MyConsensusBuilder
where
    Node: FullNodeTypes<Types: NodeTypes<ChainSpec = ChainSpec, Primitives = EthPrimitives>>,
{
    type Consensus = Arc<MyConsensus>;

    async fn build_consensus(self, ctx: &BuilderContext<Node>) -> eyre::Result<Self::Consensus> {
        Ok(Arc::new(MyConsensus { inner: EthBeaconConsensus::new(ctx.chain_spec()) }))
    }
}

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

EthBeaconConsensus implements Consensus<B> for any block type, so the delegating calls name the trait (Consensus::<Block>::...) to tell the compiler which B is meant.

This is the same pattern as a custom pool or payload builder (see examples/custom-node-components). ComponentsBuilder has five slots: pool, payload, network, executor and consensus.

7. Practice

In a fresh terminal:

git clone https://github.com/paradigmxyz/reth
cd reth
git checkout eb4c15e   # the commit this lesson quotes; main has moved on

Then:

  1. Open crates/consensus/consensus/src/lib.rs and read HeaderValidator, Consensus and FullConsensus
  2. Open crates/ethereum/consensus/src/lib.rs and find the three impls for EthBeaconConsensus
  3. Count: which provided methods does EthBeaconConsensus override (validate_block_pre_execution_with_tx_root), and which does it leave as defaults (validate_header_range)?
  4. Find where validate_block_post_execution is called: grep -rn 'validate_block_post_execution(' crates. At eb4c15e there are four call sites outside the consensus crates: the ExecutionStage, the engine tree's payload_validator.rs, the block-validation RPC (crates/rpc/rpc/src/validation.rs) and the re-execute command

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)

  • Three traits: HeaderValidator (header alone / vs parent) → Consensus (body vs header, pre-execution) → FullConsensus (post-execution: gas used, receipts root, logs bloom, requests hash). The state root is checked by the engine / MerkleStage, not by the trait.
  • Header and pre-execution checks are cheap; post-execution needs the execution result. Reth validates blocks; the consensus client picks the head.
  • Custom chains implement the traits, wrap them in a ConsensusBuilder, and pass it to ComponentsBuilder::consensus(...). EthBeaconConsensus is the Ethereum default. Next: Malachite — Rust-native BFT.