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.rsdefinesHeaderValidator(validate_header,validate_header_against_parent, plus a providedvalidate_header_range),Consensus<B>: HeaderValidator(validate_body_against_header,validate_block_pre_execution, plus a providedvalidate_block_pre_execution_with_tx_root) andFullConsensus<N>: Consensus(validate_block_post_execution). Every method returnsResult<(), ConsensusError>. - Method asymmetry. Header and pre-execution checks need no state and no EVM;
validate_block_post_executionneeds 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 callsvalidate_block_pre_execution; the ExecutionStage callsvalidate_block_post_execution. - Custom consensus implementation. Implement the three traits for your chain, wrap the value in a
ConsensusBuilder, and pass that builder toComponentsBuilder::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:
| Layer | Responsibility | Component |
|---|---|---|
| Execution | Run transactions, produce post-state | revm + executor |
| Storage | Persist blocks, state, receipts | MDBX (Reth's embedded key-value store) |
| Network | Receive blocks from peers | devp2p (Ethereum's P2P transport) |
| Consensus | Validate block correctness per chain rules | Consensus 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.rsand findEthBeaconConsensus::validate_block_post_execution. It delegates tovalidate_block_post_executioninvalidation.rsin 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 numbervalidate_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:
- Open
crates/consensus/consensus/src/lib.rsand readHeaderValidator,ConsensusandFullConsensus - Open
crates/ethereum/consensus/src/lib.rsand find the three impls forEthBeaconConsensus - Count: which provided methods does
EthBeaconConsensusoverride (validate_block_pre_execution_with_tx_root), and which does it leave as defaults (validate_header_range)? - Find where
validate_block_post_executionis called:grep -rn 'validate_block_post_execution(' crates. Ateb4c15ethere are four call sites outside the consensus crates: the ExecutionStage, the engine tree'spayload_validator.rs, the block-validation RPC (crates/rpc/rpc/src/validation.rs) and there-executecommand
Final check: in one sentence, what's the boundary between Reth's job (
Consensusimpl) 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 toComponentsBuilder::consensus(...).EthBeaconConsensusis the Ethereum default. Next: Malachite — Rust-native BFT.