Skip to content
← Build OpenHL — from `cargo init` to a single-validator devnet
Live Reth
Topic: Reth ChainSpec →
Lesson 14 of 16·CONTENT55 min100 XP
Course
Build OpenHL — from `cargo init` to a single-validator devnet
Lesson role
CONTENT
Sequence
14 / 16

Lesson 13 — validate_payload runs Reth's EthBeaconConsensus

Question

validate_payload calls Reth's EthBeaconConsensus to validate proposed payloads against the chain spec. Real Ethereum consensus rules running inside openhl. The integration lights up.

Principle (minimum model)

  • EthBeaconConsensus. Reth's implementation of the HeaderValidator / Consensus / FullConsensus traits for post-Merge Ethereum. This lesson uses only its header-vs-parent check.
  • bridge.validate_payload(block). Looks up the header we built, fetches the parent's sealed header from the live provider, and calls EthBeaconConsensus::validate_header_against_parent. Ok(()) → PayloadStatus::Valid; any Err → PayloadStatus::Invalid.
  • What is actually checked. Block number = parent + 1, timestamp > parent, gas limit within the 1/1024 bound, EIP-1559 base fee (plus EIP-4844 blob fields if Cancun is active; the dev chain spec here stops at Shanghai). Not the state root, receipts root or transactions root: there is no block body or execution yet.
  • Test scenario. Build a block on the live genesis, validate_payload it → Valid; validate a block whose hash the bridge never built → Invalid.
  • Why this matters. The builder and the validator must agree. Once Reth's real header validator runs, build_payload has to produce headers Reth accepts.
  • Cheap, synchronous work. validate_payload is an async fn on the ConsensusBridge trait, but at this stage it does only a provider lookup and four header comparisons under a std::sync::Mutex; there is no state-root recomputation.

Worked example + steps

Lesson 13 — validate_payload runs Reth's EthBeaconConsensus

Goal

Concepts you'll grasp in this lesson:

  • The builder and validator share one source of truth — ChainSpec::next_block_base_fee is the same helper both the builder uses to set base fee and EthBeaconConsensus uses to verify it. No duplicated math; no risk of drift across hardforks. This is the pattern to copy any time you have a build/validate pair in consensus-critical code.
  • The validator forces the builder to be honest — once the validator runs, the builder can no longer cut corners. Block number, gas_limit (1/1024 drift bound), EIP-1559 base fee and strictly increasing timestamps are now mechanically checked. (Zero difficulty is a post-Merge rule too, but it lives in validate_header, which this lesson does not call.)
  • Validator-rejection is normal, not a crash — a validator answering "no, this is malformed" maps to PayloadStatus::Invalid, not an Err. Mapping the error to a status keeps the engine running so it can pick the next proposal. Only DB errors escalate to BridgeError::Internal.
  • Trait bounds widen incrementally — Lesson 12 needed BlockNumReader; Lesson 13 needs BlockNumReader + HeaderProvider. Each lesson exposes a new capability surface. Trait bounds are spec: they document exactly what Reth surface the bridge requires.
  • SealedHeader caches the hash — wrapping Header + precomputed B256 avoids re-Keccak-hashing 500 bytes on every .hash() call. Matters at validator-throughput rates; here it's microseconds, but the pattern is correct.

Verification:

cargo test -p openhl-evm live_bridge_builds_on_real_genesis --release

…still passes — but now the test asserts three outcomes (added validate_payload checks for happy + invalid block):

test live_node::tests::live_bridge_builds_on_real_genesis ... ok

bridge.validate_payload(block) for the block we just built returns PayloadStatus::Valid because Reth's real validator (EthBeaconConsensus::validate_header_against_parent) approved it. bridge.validate_payload(block_with_unknown_hash) returns PayloadStatus::Invalid because we have no header to validate.

Specific changes:

  • 3 new workspace deps + 4 new evm production deps (reth-consensus, reth-ethereum-consensus, reth-primitives-traits, and reth-chainspec promoted from dev-deps).
  • crates/evm/src/live_node.rs — ~141 lines changed. New struct fields chain_spec: Arc<ChainSpec> and validator: EthBeaconConsensus<ChainSpec>. build_payload now produces production-shape headers (parent-derived gas_limit, next_block_base_fee, difficulty: U256::ZERO, snapped timestamp). validate_payload is rewritten to call EthBeaconConsensus::validate_header_against_parent.
  • The shape of the file doesn't change — same struct, same ConsensusBridge impl. What changes is what validate_payload does.

Recap

After Lesson 12 your crates/evm/src/live_node.rs has:

pub struct LiveRethEvmBridge<P> {
    provider: P,
    state: Mutex<State>,
}

build_payload reads parent number from live provider but synthesises a header with mostly default fields. validate_payload is a stub: Ok(PayloadStatus::Valid). The integration test only exercises build/fetch on the happy/negative path — never validation.

cargo test passes 36 tests workspace-wide (37 in the reference, which also has runner.rs's 5 but no openhl-types tests). The bridge agrees with itself, but it hasn't been forced to agree with Reth's idea of a valid block yet.

Plan

Seven things:

  1. Add 3 workspace deps: reth-consensus (trait HeaderValidator), reth-ethereum-consensus (concrete EthBeaconConsensus), reth-primitives-traits (SealedHeader).
  2. Update crates/evm/Cargo.toml — promote reth-chainspec from dev-dep to production dep, add 3 new production deps.
  3. Add 2 new fields to LiveRethEvmBridge: chain_spec: Arc<ChainSpec> and validator: EthBeaconConsensus<ChainSpec>. Update new() to take the chain spec.
  4. Widen the trait bound on P — now also HeaderProvider<Header = Header> (for fetching parent's full sealed header).
  5. Upgrade build_payload — pull parent's full SealedHeader, compute next_block_base_fee, copy gas_limit, zero difficulty, enforce timestamp monotonicity.
  6. Rewrite validate_payload — find our header in pending/chain, fetch parent sealed from provider, run validator.validate_header_against_parent.
  7. Add 2 new assertions to the test — Valid on our just-built block, Invalid on an unknown hash.

This lesson teaches the producer-consumer self-consistency pattern. When you have a builder and a validator for the same artifact, they must use the same rules. If build_payload uses one base-fee formula and validate_payload uses another, every block fails validation. The way you ensure this is to derive both from the same source — here, the ChainSpec. ChainSpec::next_block_base_fee() is what builds, and inside EthBeaconConsensus::validate_against_parent_eip1559_base_fee the same helper is what checks. Sharing the source-of-truth is what makes the system self-consistent.

Drawing how the build side and validate side of LiveRethEvmBridge share the ChainSpec in a single picture makes it immediately obvious why blocks we build won't be rejected by our own validator (i.e. self-consistent):

                       ┌──────────────────────────────────────────┐
                       │   Shared source of truth                   │
                       │   Arc<ChainSpec>                          │
                       │   ├─ chainId = 2600                       │
                       │   ├─ hardforks (London / Shanghai / …)    │
                       │   ├─ genesis (base_fee_per_gas, gas_limit)│
                       │   └─ EIP-1559 parameters (elasticity etc.)│
                       └────────────────────┬─────────────────────┘
                                            │
                ┌───────────────────────────┼───────────────────────────┐
                ▼                           │                           ▼
   chain_spec.next_block_base_fee(...)      │       EthBeaconConsensus<ChainSpec>
   chain_spec.genesis.gas_limit             │       .validate_header_against_parent(...)
   …                                        │           ├─ base_fee_per_gas check
                ▼                           │           ├─ gas_limit drift check (±1/1024)
   ┌───────────────────────────┐            │           ├─ timestamp monotonicity check
   │  build_payload(parent)    │            │           └─ number == parent + 1
   │   ├─ pull parent_header   │ ─[Block]──►│
   │   ├─ compute base_fee     │            │       ┌───────────────────────────┐
   │   ├─ copy gas_limit       │            ▼       │  validate_payload(block)  │
   │   ├─ difficulty = ZERO    │     ──────────────►│   look up the header in   │
   │   └─ enforce timestamp    │                    │   pending/chain, fetch    │
   │      monotonicity         │                    │   the parent sealed       │
   └───────────────────────────┘                    │   header from the         │
                                                    │   provider, run the       │
                                                    │   validator               │
                                                    └─────────────┬─────────────┘
                                                                  │
                                                                  ▼
                                                       PayloadStatus::Valid ✅
                                                       (validator approved)

Because both sides hold the same Arc<ChainSpec> instance, no matter how the base-fee formula evolves across hard forks or how network-specific gas_limit / elasticity changes, the build and validate logics can never drift apart with one side stuck on the old rule. Conversely, if the build side computed base fees inline while the validate side went through ChainSpec, the first chain whose base_fee_params differ from the inline constants (a custom chain spec, or a chain like Optimism whose base-fee parameters change per fork) would silently start producing "blocks I built that my own validator rejects" — a silent fork at the bridge level. "Self-consistency isn't bought through an API; it's bought through a shared source of truth" is the discipline this crate carries in its bones.

Walk-through

Step 1: Add 3 workspace deps

Open the root Cargo.toml. The reth block (from Lesson 12) ends with:

reth-provider             = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-storage-api          = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
alloy-genesis             = { version = "2.0", default-features = false }

Insert 3 lines between reth-storage-api and alloy-genesis:

reth-consensus            = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-ethereum-consensus   = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-primitives-traits    = "0.3"

Three deps, three roles:

  • reth-consensus — defines the HeaderValidator trait. EthBeaconConsensus implements it. We call .validate_header_against_parent(...) via this trait.
  • reth-ethereum-consensus — provides EthBeaconConsensus<ChainSpec> — Reth's production header validator for post-merge Ethereum.
  • reth-primitives-traits from crates.io 0.3 — provides SealedHeader, the wrapper that pairs Header with its hash. This one comes from crates.io, not git — it's been spun out as a stable foundation crate.

Step 2: Update crates/evm/Cargo.toml

The [dependencies] section gains 4 lines, and reth-chainspec is promoted from [dev-dependencies]:

[dependencies]
openhl-consensus         = { workspace = true }
openhl-types             = { workspace = true }
async-trait              = { workspace = true }
eyre                     = { workspace = true }
alloy-primitives         = { workspace = true }
alloy-consensus          = { workspace = true }
reth-ethereum-primitives = { workspace = true }
reth-storage-api         = { workspace = true }
reth-consensus           = { workspace = true }    # NEW
reth-ethereum-consensus  = { workspace = true }    # NEW
reth-primitives-traits   = { workspace = true }    # NEW
reth-chainspec           = { workspace = true }    # NEW — was in [dev-dependencies]

And [dev-dependencies] loses the reth-chainspec line (it's production now):

[dev-dependencies]
tokio                = { workspace = true }
reth-node-builder    = { workspace = true, features = ["test-utils"] }
reth-node-ethereum   = { workspace = true, features = ["test-utils"] }
reth-node-core       = { workspace = true }
reth-tasks           = { workspace = true }
# reth-chainspec line is GONE — production dep now
reth-provider        = { workspace = true }
alloy-genesis        = { workspace = true }
serde_json           = { workspace = true }
tempfile             = "3"

Why reth-chainspec is now production: the bridge holds an Arc<ChainSpec> in its struct. That's a production-visible field, so the type must be a production-visible dep.

Step 3: Update imports + struct in live_node.rs

Open crates/evm/src/live_node.rs. The imports get 3 additions:

use alloy_consensus::Header;
use alloy_primitives::{Address, B256};
use async_trait::async_trait;
use openhl_consensus::bridge::{BridgeError, ConsensusBridge};
use openhl_types::{BlockHash, ExecutedBlock, PayloadAttrs, PayloadId, PayloadStatus};
use reth_chainspec::{ChainSpec, EthChainSpec};                      // NEW
use reth_consensus::HeaderValidator;                                // NEW
use reth_ethereum_consensus::EthBeaconConsensus;                    // NEW
use reth_primitives_traits::SealedHeader;                           // NEW
use reth_storage_api::{BlockNumReader, HeaderProvider};             // CHANGED: + HeaderProvider
use std::collections::HashMap;
use std::sync::{Arc, Mutex};                                        // CHANGED: + Arc

Five new types:

  • ChainSpec — Reth's chain configuration, passed at construction.
  • EthChainSpec — trait that gives ChainSpec the next_block_base_fee method.
  • HeaderValidator — trait with validate_header_against_parent. EthBeaconConsensus impls it.
  • EthBeaconConsensus — Reth's production post-merge header validator.
  • SealedHeader — (Header, hash) pair.

Two changed imports: HeaderProvider (for sealed_header_by_hash), and Arc (for sharing the chain spec).

Now the struct gains two fields:

#[derive(Debug)]
pub struct LiveRethEvmBridge<P> {
    provider: P,
    chain_spec: Arc<ChainSpec>,                          // NEW
    validator: EthBeaconConsensus<ChainSpec>,            // NEW
    state: Mutex<State>,
}

And new() is widened to take the chain spec:

impl<P> LiveRethEvmBridge<P> {
    #[must_use]
    pub fn new(provider: P, chain_spec: Arc<ChainSpec>) -> Self {
        let validator = EthBeaconConsensus::new(Arc::clone(&chain_spec));
        Self {
            provider,
            chain_spec,
            validator,
            state: Mutex::new(State::default()),
        }
    }

    #[must_use]
    pub fn chain_spec(&self) -> &Arc<ChainSpec> {
        &self.chain_spec
    }
}

State is unchanged — same next_payload_id, pending, chain, head.

The chain_spec() accessor is added because tests and future production callers will want it (e.g., to ask the chain spec what hardfork is active at a given height). Keeping it exposed via &Arc<ChainSpec> lets callers clone if they need to hold their own reference.

Step 4: Widen the trait bound on P

The impl block's where clause gains one more bound:

#[async_trait]
impl<P> ConsensusBridge for LiveRethEvmBridge<P>
where
    P: BlockNumReader + HeaderProvider<Header = Header> + Clone + Sync + 'static,
{

HeaderProvider<Header = Header> — the provider must serve full Header objects, not just numbers. The associated-type binding Header = Header says "the provider's Header type is our alloy Header type." Different Reth versions could parameterize HeaderProvider over different header types (e.g., for Optimism); we constrain ours to mainnet Ethereum's.

BlockNumReader is now redundant in some sense (anything that gives you a full header gives you its number), but we keep it explicit because:

  • Lesson 12 wrote against just BlockNumReader — keeping it documents the Lesson 12 → Lesson 13 progression
  • Future callers may want the narrower bound for code paths that only need the number

Step 5: Upgrade build_payload — production-shape headers

This is the load-bearing change. The new build_payload:

    async fn build_payload(
        &self,
        parent: BlockHash,
        attrs: PayloadAttrs,
    ) -> Result<PayloadId, BridgeError> {
        let parent_b256 = B256::from(parent.0);

        // LIVE READ: pull the parent's full sealed header from the real
        // provider so we can copy fields that EthBeaconConsensus will check
        // against during validate_payload (gas_limit drift, EIP-1559 base
        // fee, difficulty=0 post-merge).
        let parent_sealed = self
            .provider
            .sealed_header_by_hash(parent_b256)
            .map_err(|e| BridgeError::Internal(eyre::eyre!("provider error: {e}")))?
            .ok_or_else(|| {
                BridgeError::Rejected(format!("provider has no block with hash {parent_b256}"))
            })?;
        let parent_header = parent_sealed.header();

        let mut s = self.state.lock().expect("state mutex poisoned");
        let id = s.next_payload_id;
        s.next_payload_id += 1;

        let our_timestamp = attrs.timestamp.max(parent_header.timestamp + 1);

        // Compute the EIP-1559 base fee for our block via the chain spec —
        // identical math to what EthBeaconConsensus's
        // `validate_against_parent_eip1559_base_fee` will check against.
        let next_base_fee = self
            .chain_spec
            .next_block_base_fee(parent_header, our_timestamp);

        let header = Header {
            parent_hash: parent_b256,
            number: parent_header.number + 1,
            // Timestamp must be strictly greater than parent's; force at least
            // parent.timestamp + 1 even if attrs.timestamp came in stale.
            timestamp: our_timestamp,
            beneficiary: Address::from(attrs.fee_recipient),
            mix_hash: B256::from(attrs.prev_randao),
            // Keep gas_limit identical to parent so EthBeaconConsensus's
            // 1/1024 drift check passes trivially. A real payload builder
            // would tune this per network policy.
            gas_limit: parent_header.gas_limit,
            // Post-merge: difficulty must be 0.
            difficulty: alloy_primitives::U256::ZERO,
            base_fee_per_gas: next_base_fee,
            ..Default::default()
        };
        let hash = header.hash_slow();
        s.pending.insert(id, (hash, header));
        Ok(PayloadId(id))
    }

Three changes from Lesson 12:

  1. sealed_header_by_hash instead of block_number. We need the full parent header now, not just its number. The error mapping is the same: Err(provider_err) → Internal, Ok(None) → Rejected.

  2. our_timestamp = attrs.timestamp.max(parent_header.timestamp + 1). Timestamps must be strictly monotonic. If the engine passes us attrs.timestamp = 5 and parent.timestamp = 100, we use 101 (parent + 1). This prevents stale clock data from causing immediate validate_payload failures.

  3. Header construction now has 4 carefully-chosen fields (plus the ones from Lesson 12):

    • gas_limit = parent_header.gas_limit — copying ensures the 1/1024 drift check is trivially satisfied.
    • difficulty = U256::ZERO — post-merge invariant. Header::default() already gives zero, so this line documents intent. The check that enforces it is validate_header, not validate_header_against_parent, so this lesson's validator would not catch a non-zero value.
    • base_fee_per_gas = next_base_fee — computed via chain_spec.next_block_base_fee(...), the same helper the validator uses.
    • ..Default::default() — everything else (gas_used, transactions_root, etc.) stays at zero. They'd matter for full execution validation in a future stage, not for header-against-parent.

Step 6: Rewrite validate_payload

The other load-bearing change. Replace the stub with:

    async fn validate_payload(
        &self,
        block: &ExecutedBlock,
    ) -> Result<PayloadStatus, BridgeError> {
        let block_hash = B256::from(block.hash.0);
        let parent_hash = B256::from(block.parent_hash.0);

        // Find our header for this block. In single-validator mode we always
        // built it, so it sits in pending (pre-commit) or chain (post-commit).
        let header = {
            let s = self.state.lock().expect("state mutex poisoned");
            s.pending
                .values()
                .find(|(h, _)| *h == block_hash)
                .map(|(_, h)| h.clone())
                .or_else(|| s.chain.get(&block_hash).cloned())
        };
        let Some(header) = header else {
            return Ok(PayloadStatus::Invalid);
        };

        // Fetch parent sealed header from the LIVE provider.
        let Some(parent_sealed) = self
            .provider
            .sealed_header_by_hash(parent_hash)
            .map_err(|e| BridgeError::Internal(eyre::eyre!("provider error: {e}")))?
        else {
            return Ok(PayloadStatus::Invalid);
        };

        // Run Reth's real header validator. EthBeaconConsensus checks number
        // monotonicity, timestamp monotonicity, gas-limit drift, base-fee.
        let our_sealed = SealedHeader::new(header, block_hash);
        match self
            .validator
            .validate_header_against_parent(&our_sealed, &parent_sealed)
        {
            Ok(()) => Ok(PayloadStatus::Valid),
            Err(_) => Ok(PayloadStatus::Invalid),
        }
    }

Four phases:

  1. Header lookup — find our header for block.hash in pending (just-built) or chain (already-committed). If not found → Invalid. In single-validator mode, every block we validate is one we built, so it'll be in one of those two maps.
  2. Parent lookup via live provider — sealed_header_by_hash(parent_hash). If not found → Invalid. If the provider errors → BridgeError::Internal.
  3. Wrap in SealedHeader — SealedHeader::new(header, block_hash) pairs the header with its hash without re-computing.
  4. Run the validator — validator.validate_header_against_parent(&our_sealed, &parent_sealed) returns Result<(), ConsensusError>. Map Ok(()) → PayloadStatus::Valid, any Err(_) → PayloadStatus::Invalid.

The sub-checks Reth runs internally (EthBeaconConsensus::validate_header_against_parent at the pinned rev 88505c7; no need to write them yourself, but worth knowing):

  • validate_against_parent_hash_number — parent_hash matches and block.number == parent.number + 1
  • validate_against_parent_timestamp — header.timestamp > parent.timestamp
  • validate_against_parent_gas_limit — gas_limit change < parent/1024 and ≥ MINIMUM_GAS_LIMIT (skipped if with_skip_gas_limit_ramp_check(true))
  • validate_against_parent_eip1559_base_fee — base_fee_per_gas equals chain_spec.next_block_base_fee(parent, timestamp) (from London)
  • validate_against_parent_4844 — blob gas fields, only when the chain spec has blob params at that timestamp (Cancun+; not active in this lesson's dev spec)

If any fails, the validator returns Err(...). We don't propagate the specific error — at this layer the engine just needs to know "valid or not." Future debugging could log the error type.

Step 7: Update the test — 2 new assertions

The test gets a new bridge constructor call (now takes chain_spec) plus two validate_payload assertions:

    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    async fn live_bridge_builds_on_real_genesis() {
        let runtime = Runtime::test();
        let chain_spec = dev_chain_spec();
        let node_config = NodeConfig::test().dev().with_chain(chain_spec.clone());

        let NodeHandle {
            node,
            node_exit_future: _,
        } = NodeBuilder::new(node_config)
            .testing_node(runtime)
            .node(EthereumNode::default())
            .launch_with_debug_capabilities()
            .await
            .expect("launch failed");

        let genesis_hash_b256 = node
            .provider
            .block_hash(0)
            .expect("provider call failed")
            .expect("provider has no block 0 (genesis)");

        // CHANGED: bridge takes chain_spec now (wires up EthBeaconConsensus).
        let bridge = LiveRethEvmBridge::new(node.provider.clone(), chain_spec.clone());

        let attrs = PayloadAttrs {
            timestamp: 1,
            fee_recipient: [0u8; 20],
            prev_randao: [0u8; 32],
        };
        let id = bridge
            .build_payload(BlockHash(genesis_hash_b256.0), attrs.clone())
            .await
            .expect("build_payload failed");
        let block = bridge.payload_ready(id).await.expect("payload_ready failed");

        assert_eq!(block.parent_hash, BlockHash(genesis_hash_b256.0));
        assert_eq!(block.number, 1);

        // NEW: validate_payload runs EthBeaconConsensus against the live parent.
        let status = bridge
            .validate_payload(&block)
            .await
            .expect("validate_payload failed");
        assert_eq!(status, PayloadStatus::Valid);

        // NEW: unknown block hash → Invalid (we have no header to validate).
        let unknown_block = ExecutedBlock {
            hash: BlockHash([0xddu8; 32]),
            parent_hash: BlockHash(genesis_hash_b256.0),
            number: 1,
            state_root: [0u8; 32],
        };
        let status = bridge
            .validate_payload(&unknown_block)
            .await
            .expect("validate_payload failed");
        assert_eq!(status, PayloadStatus::Invalid);

        // (Negative case from Lesson 12 unchanged.)
        let fake_parent = BlockHash([0xeeu8; 32]);
        let err = bridge.build_payload(fake_parent, attrs).await.unwrap_err();
        assert!(matches!(err, BridgeError::Rejected(_)));
    }

Two new blocks:

  • validate_payload(&block) after build_payload — the just-built block must validate. This is the load-bearing assertion — proves that build and validate agree on the rules. If you got the EIP-1559 formula wrong, the number or timestamp wrong, or gas_limit drifted, this fails.
  • validate_payload(&unknown_block) — a block whose hash isn't in pending/chain returns Invalid. Tests the lookup fallthrough.

Test

cargo test -p openhl-evm live_bridge_builds_on_real_genesis --release

Output from running this at the reference commit 0844d58 (--release; the first build of the reth dependencies took ~10 minutes on our machine):

running 1 test
test live_node::tests::live_bridge_builds_on_real_genesis ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 10 filtered out; finished in 2.74s

Test runtime: ~2.7 seconds in our run (the Lesson 12 commit message reports 2.44 s); the Reth bootstrap dominates and validate_payload adds little. 10 filtered out is the other 10 tests in openhl-evm.

Full suite:

cargo test

…should produce 36 tests workspace-wide (37 in the reference; no change in count — the existing test gained assertions).

Common errors and fixes:

  • assert_eq!(status, PayloadStatus::Valid) fails — the most common issue. Your build_payload is producing a header EthBeaconConsensus rejects. Possible causes:
    • Forgot gas_limit: parent_header.gas_limit — defaults to zero, drifts >1/1024 from parent.
    • (Forgetting difficulty: U256::ZERO does not cause this: Header::default() is already zero, and the difficulty check is in validate_header, which we don't call.)
    • Computed base_fee wrong — must use chain_spec.next_block_base_fee(parent, timestamp).
    • Timestamp not strictly greater than parent — must enforce our_timestamp = attrs.timestamp.max(parent_header.timestamp + 1).
  • error[E0277]: HeaderProvider not satisfied — workspace reth-storage-api SHA mismatch with reth-provider. All reth-* git-pinned deps must share the same SHA.
  • error[E0277]: HeaderValidator is not in scope — forgot use reth_consensus::HeaderValidator. The trait must be in scope to call its methods.
  • error: 'next_block_base_fee' not found on ChainSpec — the extension trait reth_chainspec::EthChainSpec isn't in scope. next_block_base_fee isn't an inherent method on ChainSpec; it's an extension method defined on the EthChainSpec trait, and Rust's method-resolution rules require you use the trait itself to bring its methods into scope (when relying on IDE auto-import, pick EthChainSpec explicitly from the suggestion list). Fix: import the trait alongside the type — use reth_chainspec::{ChainSpec, EthChainSpec};.

Design reflection

Three load-bearing decisions encoded here:

  1. The builder and the validator share a source of truth. ChainSpec::next_block_base_fee is what builds the next block's base fee; EthBeaconConsensus::validate_header_against_parent checks it through reth_consensus_common::validation::validate_against_parent_eip1559_base_fee, which calls the same helper. No duplicated math, no risk of drift across hardforks. This is the pattern to copy any time you have a build/validate pair.

  2. The validator's error becomes Invalid, not propagated. A validator answering "no, this is malformed" is the normal path, not a crash. Mapping its Err(_) to PayloadStatus::Invalid keeps the engine running so it can pick the next proposal. Operational failures (DB errors) still escalate via BridgeError::Internal.

  3. The trait bound on P widens incrementally. Lesson 12 needed BlockNumReader; Lesson 13 needs BlockNumReader + HeaderProvider. Each lesson exposes a new capability surface. Trait bounds are spec — they tell consumers exactly what your implementation requires.

Answer key

cd ~/code/openhl-reference
git checkout 0844d58
diff -u ~/code/my-openhl/Cargo.toml ./Cargo.toml
diff -u ~/code/my-openhl/crates/evm/Cargo.toml ./crates/evm/Cargo.toml
diff -u ~/code/my-openhl/crates/evm/src/live_node.rs ./crates/evm/src/live_node.rs

The reference at 0844d58 (psyto/openhl, now archived) has 141 lines changed in live_node.rs from Lesson 12 (git show --stat 0844d58). The new struct fields, the upgraded build_payload, the rewritten validate_payload, and the new test assertions should match closely. Doc comments and exact wording can vary.

Return:

git checkout main

Common questions

Q: Why not run the four sub-checks (validate_against_parent_hash_number, etc.) manually? You could — they're pub free functions in reth_consensus_common::validation (not methods on EthBeaconConsensus). But validate_header_against_parent runs them in sequence with the right argument shapes and proper short-circuiting. Re-implementing the orchestration is the kind of error-prone work the trait method exists to prevent. It also already runs a fifth check you might forget: validate_against_parent_4844 once blob params are active. Calling the orchestrating method picks up checks like that for free.

Q: What's SealedHeader::new(header, hash) doing that's different from just keeping them as a tuple? Caching. SealedHeader stores the hash, so subsequent calls to .hash() on it don't re-compute (which is a Keccak over ~500 bytes — meaningful at high block rates). Tuples would force re-computation. It's an optimization that matters at the network edge where you process thousands of blocks per second; for our test, the savings is microseconds.

Q: Why does the test still use chain_spec.clone() even though dev_chain_spec() returns Arc<ChainSpec>? Cloning an Arc<T> increments the refcount; it doesn't copy the underlying ChainSpec data. We need three references: one inside NodeConfig, one passed to LiveRethEvmBridge::new, one for any future use. Each .clone() is just an atomic increment — measured in nanoseconds.

Q: What happens if I pass chain_spec: Arc::new(ChainSpec::default()) instead of dev_chain_spec()? The validator and the chain would disagree on what hardfork is active. ChainSpec::default() has a default genesis and no hardforks scheduled; the live node was built with dev_chain_spec() (chainId 2600, London through Shanghai at 0). With the default spec, is_london_active_at_block is false, so validate_against_parent_eip1559_base_fee silently skips the base-fee check. The test still passes, but the validator is now checking less than the chain enforces. Pass the same chain_spec to both the node and the bridge — it's the contract.

Next lesson (Lesson 14)

Two of the four ConsensusBridge methods now hit live Reth. The third one — commit — still records hashes into an in-process chain: HashMap. Lesson 14 (the last big lesson) adds a real Engine API forkchoice update, sent in-process through Reth's ConsensusEngineHandle (the same handler that serves engine_forkchoiceUpdated over JSON-RPC). It is only half of what a CL client like Lighthouse sends: without a matching newPayload, Reth answers SYNCING. Lesson 15 is then the capstone — a one-page recap, an "everything you built" diagram, and the optional production-readiness checklist (block bodies, gossip codecs, real WAL).

Summary (3 lines)

  • validate_payload calls Reth's EthBeaconConsensus::validate_header_against_parent against the live parent: number, timestamp, gas-limit drift, EIP-1559 base fee. No body or state-root checks yet.
  • Builder and validator share one Arc<ChainSpec>, so next_block_base_fee builds and checks with the same math. Test: our block → Valid, unknown hash → Invalid.
  • Validator rejection maps to PayloadStatus::Invalid, not an error. Next: commit drives Engine API forkchoice.