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)
Consensustrait location.crates/consensus/consensus/src/lib.rs. ~6 methods:validate_header+validate_block+validate_block_post_execution+validate_state+ others.HeaderValidatorcompanion.validate_header(header) -> Result<()>— checks parent hash, timestamp, gas limit, etc. Implementations differ per chain (Ethereum / Optimism / custom).- Method asymmetry.
validate_headeris cheap (called per header during sync);validate_block_post_executionis expensive (called after execution, validates state root etc). - Stage integration. Reth's Staged Sync calls
Consensus::validate_headerin the HeaderStage;validate_blockin the BodyStage;validate_statein 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.
EthBeaconConsensusimplements 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:
| 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:
#[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.rsand findEthBeaconConsensus::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 numbervalidate_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:
- Open
crates/consensus/consensus/src/lib.rsand read the fullConsensustrait - Open
crates/ethereum/consensus/src/lib.rsand find an impl - Count: how many methods does
EthBeaconConsensusoverride vs use defaults? - Find the one place
validate_block_post_executionis called from (search for the method name)
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)
Consensustrait = ~6 methods (validate_header / validate_block / validate_state etc);HeaderValidatorcompanion.- Stage-integrated: HeaderStage calls
validate_header; ExecutionStage callsvalidate_state. Asymmetric costs (cheap header, expensive state). - Custom L1s implement the trait + register via
NodeBuilder::consensus(...).EthBeaconConsensusis the Ethereum default. Next: Malachite — Rust-native BFT.