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 Consensus trait for Ethereum PoS. Validates header + block + state.bridge.validate_payload(payload). CallsEthBeaconConsensus::validate_block(payload). If valid, accept; if not, reject with reason.- Real validation. Parent hash + timestamp + gas limit + state root + receipts root + transactions root. All canonical Ethereum.
- Test scenario. Build a Block; validate it; modify state root; validate; assert second call fails.
- Why this matters. Malachite + Reth integration is bidirectional: Malachite produces blocks; Reth validates them. Both must agree.
- Production parallel. Hyperliquid HyperEVM uses this exact pattern; HyperBFT proposes; HyperEVM validates.
- Async + blocking. Validation can be slow (state root recomputation); bridge.validate_payload is async; doesn't block Malachite's event loop.
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. Producing headers with gas_limit copied from parent (1/1024 drift bound), correct EIP-1559 base fee, zero difficulty (post-merge), monotonic timestamps — every one of those is now mechanically checked.
- 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-chainspec,alloy-eips). 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 37 tests workspace-wide. 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 (Cancun / 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) │ │ └─ post-merge invariants
│ ├─ 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, every fork starting from Cancun 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. Any non-zero value fails the validator.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 4 sub-checks Reth runs internally (no need to write them yourself, but worth knowing):
validate_against_parent_hash_number— block.number == parent.number + 1validate_against_parent_timestamp— header.timestamp > parent.timestampvalidate_against_parent_gas_limit— gas_limit within 1/1024 of parentvalidate_against_parent_eip1559_base_fee— base_fee_per_gas matches the EIP-1559 formula
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, or the difficulty was non-zero, 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
After ~30 seconds (compile + test):
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; 0 filtered out
Test runtime: still ~2.4 seconds — the Reth bootstrap dominates, and validate_payload adds < 1ms.
Full suite:
cargo test
…should produce 37 tests workspace-wide (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
difficulty: U256::ZERO— defaults to non-zero, fails post-merge check. - Forgot
gas_limit: parent_header.gas_limit— defaults to zero, drifts >1/1024 from parent. - 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_against_parent_eip1559_base_feecalls the same helper to check. 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 has ~141 lines changed in live_node.rs from Lesson 12. 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 all pub on EthBeaconConsensus. But validate_header_against_parent runs all four 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. Bonus: future Reth versions might add a fifth check; calling the orchestrating method picks it up 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() is a minimal Ethereum mainnet shape; the live node was built with dev_chain_spec() (chainId 2600, all forks at 0). They'd diverge on the EthChainSpec::is_fork_active_at_timestamp(...) checks the validator runs internally. 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) replaces that with a real Engine API forkchoice update, the JSON-RPC call that Reth uses to commit blocks in production. After Lesson 14, our bridge produces the same wire-format actions that any Ethereum CL client (Lighthouse, Prysm, Teku) would. 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_block. Real Ethereum consensus rules; canonical validation.- Test: modify state root → validation fails. Malachite + Reth integration is bidirectional and must agree.
- Production parallel: HyperBFT proposes, HyperEVM validates. Next: commit drives Engine API forkchoice.