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 theHeaderValidator/Consensus/FullConsensustraits 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 callsEthBeaconConsensus::validate_header_against_parent.Ok(())→PayloadStatus::Valid; anyErr→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_payloadit →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_payloadhas to produce headers Reth accepts. - Cheap, synchronous work.
validate_payloadis anasync fnon theConsensusBridgetrait, but at this stage it does only a provider lookup and four header comparisons under astd::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_feeis the same helper both the builder uses to set base fee andEthBeaconConsensususes 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 anErr. Mapping the error to a status keeps the engine running so it can pick the next proposal. Only DB errors escalate toBridgeError::Internal. - Trait bounds widen incrementally — Lesson 12 needed
BlockNumReader; Lesson 13 needsBlockNumReader + HeaderProvider. Each lesson exposes a new capability surface. Trait bounds are spec: they document exactly what Reth surface the bridge requires. SealedHeadercaches the hash — wrappingHeader+ precomputedB256avoids 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, andreth-chainspecpromoted from dev-deps). crates/evm/src/live_node.rs— ~141 lines changed. New struct fieldschain_spec: Arc<ChainSpec>andvalidator: EthBeaconConsensus<ChainSpec>.build_payloadnow produces production-shape headers (parent-derived gas_limit,next_block_base_fee,difficulty: U256::ZERO, snapped timestamp).validate_payloadis rewritten to callEthBeaconConsensus::validate_header_against_parent.- The shape of the file doesn't change — same struct, same
ConsensusBridgeimpl. What changes is whatvalidate_payloaddoes.
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:
- Add 3 workspace deps:
reth-consensus(traitHeaderValidator),reth-ethereum-consensus(concreteEthBeaconConsensus),reth-primitives-traits(SealedHeader). - Update
crates/evm/Cargo.toml— promotereth-chainspecfrom dev-dep to production dep, add 3 new production deps. - Add 2 new fields to
LiveRethEvmBridge:chain_spec: Arc<ChainSpec>andvalidator: EthBeaconConsensus<ChainSpec>. Updatenew()to take the chain spec. - Widen the trait bound on
P— now alsoHeaderProvider<Header = Header>(for fetching parent's full sealed header). - Upgrade
build_payload— pull parent's fullSealedHeader, compute next_block_base_fee, copy gas_limit, zero difficulty, enforce timestamp monotonicity. - Rewrite
validate_payload— find our header in pending/chain, fetch parent sealed from provider, runvalidator.validate_header_against_parent. - Add 2 new assertions to the test —
Validon our just-built block,Invalidon 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 theHeaderValidatortrait.EthBeaconConsensusimplements it. We call.validate_header_against_parent(...)via this trait.reth-ethereum-consensus— providesEthBeaconConsensus<ChainSpec>— Reth's production header validator for post-merge Ethereum.reth-primitives-traitsfrom crates.io0.3— providesSealedHeader, the wrapper that pairsHeaderwith 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 givesChainSpecthenext_block_base_feemethod.HeaderValidator— trait withvalidate_header_against_parent.EthBeaconConsensusimpls 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:
-
sealed_header_by_hashinstead ofblock_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. -
our_timestamp = attrs.timestamp.max(parent_header.timestamp + 1). Timestamps must be strictly monotonic. If the engine passes usattrs.timestamp = 5andparent.timestamp = 100, we use101(parent + 1). This prevents stale clock data from causing immediatevalidate_payloadfailures. -
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 isvalidate_header, notvalidate_header_against_parent, so this lesson's validator would not catch a non-zero value.base_fee_per_gas = next_base_fee— computed viachain_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:
- Header lookup — find our header for
block.hashinpending(just-built) orchain(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. - Parent lookup via live provider —
sealed_header_by_hash(parent_hash). If not found →Invalid. If the provider errors →BridgeError::Internal. - Wrap in
SealedHeader—SealedHeader::new(header, block_hash)pairs the header with its hash without re-computing. - Run the validator —
validator.validate_header_against_parent(&our_sealed, &parent_sealed)returnsResult<(), ConsensusError>. MapOk(())→PayloadStatus::Valid, anyErr(_)→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 + 1validate_against_parent_timestamp— header.timestamp > parent.timestampvalidate_against_parent_gas_limit— gas_limit change < parent/1024 and ≥MINIMUM_GAS_LIMIT(skipped ifwith_skip_gas_limit_ramp_check(true))validate_against_parent_eip1559_base_fee— base_fee_per_gas equalschain_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)afterbuild_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 returnsInvalid. 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. Yourbuild_payloadis producing a headerEthBeaconConsensusrejects. Possible causes:- Forgot
gas_limit: parent_header.gas_limit— defaults to zero, drifts >1/1024 from parent. - (Forgetting
difficulty: U256::ZEROdoes not cause this:Header::default()is already zero, and the difficulty check is invalidate_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).
- Forgot
error[E0277]: HeaderProvider not satisfied— workspacereth-storage-apiSHA mismatch withreth-provider. All reth-* git-pinned deps must share the same SHA.error[E0277]: HeaderValidator is not in scope— forgotuse reth_consensus::HeaderValidator. The trait must be in scope to call its methods.error: 'next_block_base_fee' not found on ChainSpec— the extension traitreth_chainspec::EthChainSpecisn't in scope.next_block_base_feeisn't an inherent method onChainSpec; it's an extension method defined on theEthChainSpectrait, and Rust's method-resolution rules require youusethe trait itself to bring its methods into scope (when relying on IDE auto-import, pickEthChainSpecexplicitly from the suggestion list). Fix: import the trait alongside the type —use reth_chainspec::{ChainSpec, EthChainSpec};.
Design reflection
Three load-bearing decisions encoded here:
-
The builder and the validator share a source of truth.
ChainSpec::next_block_base_feeis what builds the next block's base fee;EthBeaconConsensus::validate_header_against_parentchecks it throughreth_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. -
The validator's error becomes
Invalid, not propagated. A validator answering "no, this is malformed" is the normal path, not a crash. Mapping itsErr(_)toPayloadStatus::Invalidkeeps the engine running so it can pick the next proposal. Operational failures (DB errors) still escalate viaBridgeError::Internal. -
The trait bound on
Pwidens incrementally. Lesson 12 neededBlockNumReader; Lesson 13 needsBlockNumReader + 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_payloadcalls Reth'sEthBeaconConsensus::validate_header_against_parentagainst 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>, sonext_block_base_feebuilds 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.