Lesson 12 — LiveRethEvmBridge reads parents from the real chain
Question
Build LiveRethEvmBridge<P>, whose build_payload looks up the parent block in a live Reth node's provider via BlockNumReader and refuses parents the chain has never seen.
Principle (minimum model)
LiveRethEvmBridge<P>. A newConsensusBridgeincrates/evm/src/live_node.rsholding a providerPplus aMutex<State>(payload counter,pending,chain,head). No Engine API, no RPC client: it reads Reth's storage in-process.- Generic over
P: BlockNumReader + Clone + Sync + 'static. The bridge asks for only the one capability it uses: hash → block number. In the test,Pis the live node'sBlockchainProvider. - The live read.
build_payloadcallsprovider.block_number(parent):Ok(Some(n))→ build a child header atn + 1;Ok(None)→BridgeError::Rejected;Err→BridgeError::Internal. - Still stubs.
payload_readyreads the bridge's ownpendingmap,validate_payloadalways returnsValid(Lesson 13),commitonly records intochain/head(Lesson 14). - Same trait. It implements the same
ConsensusBridgeas the earlier bridges, sorun_engine_appcould drive it unchanged. - Test.
live_bridge_builds_on_real_genesisboots a dev node, gets the genesis hash withblock_hash(0), builds on it (→number == 1,parent_hash == genesis), then builds on a made-up hash (→Rejected). ~2.4 s, dominated by the node boot.
Worked example + steps
Lesson 12 — LiveRethEvmBridge reads parents from the real chain
Goal
Concepts you'll grasp in this lesson:
- Generic over
P: BlockNumReader, not concrete onBlockchainProvider— the bridge declares exactly the one Reth capability it needs. The concrete provider type carries its own generic parameters and bounds you'd otherwise have to thread through every call site; the generic narrows the surface and makes mock testing easy. - Happy/negative pair as minimal honest validation — happy alone misses silent fallback to in-memory state; negative alone misses a bridge that always rejects. Both must be load-bearing for "the bridge talks to Reth" to be a true claim.
Result<Option<u64>>distinguishes operational from protocol failures — DB-call failure →BridgeError::Internal(alert); unknown-hash →BridgeError::Rejected(vote nil, move on). Errors carry semantics, not just messages.- Refusing unknown parents is a safety property — if the consensus engine proposes building on a hash the live chain has never seen, the bridge must refuse. This is the rule that prevents a malicious or buggy proposer from steering the EL into a forked subtree.
- Two bridges as integration milestones —
RethEvmBridge(Lesson 5, alloy-only) andLiveRethEvmBridge(Lesson 12, live provider) both stay in the codebase. They represent two stages of integration, not duplicate implementations.
Verification:
cargo test -p openhl-evm live_bridge_builds_on_real_genesis --release
…passes one new test that exercises both the happy path and the negative path:
test live_node::tests::live_bridge_builds_on_real_genesis ... ok
Happy path: boot EthereumNode, query its BlockchainProvider for the real genesis hash, hand the provider to LiveRethEvmBridge, call build_payload(genesis_hash, attrs). The resulting child block has number = 1 and parent_hash = genesis — both derived from the live provider, not synthesised in memory.
Negative path: call build_payload(BlockHash([0xee; 32]), attrs). The provider doesn't know that hash, so the bridge returns BridgeError::Rejected. Refusing to build on a parent the live chain has never seen is what makes the bridge safe to wire into consensus.
Specific changes:
crates/evm/src/live_node.rs— new file (~227 lines).LiveRethEvmBridge<P>generic overP: BlockNumReader + Clone + Sync + 'static.build_payloadis real (queries the live provider);payload_readyreads from in-memory pending state;validate_payload+commitstay stubbed for Lessons 13–14.crates/evm/Cargo.tomladds the production deps needed by the generic bound.crates/evm/src/lib.rs— wirespub mod live_node;.
Recap
After Lesson 11 your workspace has:
Cargo.toml — 12 reth-* workspace deps + alloy-genesis
crates/evm/Cargo.toml — 6 production deps + 11 dev-deps
crates/evm/src/in_memory.rs — InMemoryEvmBridge (Lesson 4)
crates/evm/src/engine.rs — RethEvmBridge (Lesson 5)
crates/evm/src/reth_node.rs — bootstrap-only smoke test
crates/consensus/ — full BFT engine + run_engine_app
cargo test passes 35 tests workspace-wide (4 types + 21 consensus + 10 evm; 36 in the reference, which also has runner.rs's 5 but no openhl-types tests). Reth boots, Malachite produces blocks, but they don't talk. RethEvmBridge uses in-process state for parent lookups; LiveRethEvmBridge doesn't exist yet.
Plan
Six things:
- Add
reth-storage-apiat the workspace level — providesBlockNumReader, the trait surface we're generic over. - Update
crates/evm/Cargo.toml— promoteeyrefrom dev-dep to production dep (forBridgeError::Internal's message construction); addreth-storage-apias production dep. - Create
crates/evm/src/live_node.rswithLiveRethEvmBridge<P>struct +ConsensusBridgeimpl (build_payloadis live, others are stubs). - Wire
pub mod live_node;intocrates/evm/src/lib.rs(production-visible this time, not#[cfg(test)]). - Add the integration test
live_bridge_builds_on_real_genesis— bootstraps a real node, asserts happy + negative paths. - Run
cargo test -p openhl-evm live_bridge_builds_on_real_genesis --release— passes in ~2.4 seconds.
This lesson teaches the generic-over-provider pattern that makes the bridge testable in isolation. LiveRethEvmBridge<P> is generic over P: BlockNumReader + Clone + Sync + 'static. In production, P is the live node's BlockchainProvider. In tests, P could be a mock provider that returns a deterministic set of (hash → number) mappings. The bridge itself doesn't care which — it just calls provider.block_number(...). This is the same pattern as run_engine_app<B: ConsensusBridge> in Lesson 10: depend on the trait, not the concrete type.
Walk-through
Step 1: Add reth-storage-api to workspace
Open the root Cargo.toml. After Lesson 11 the reth block ends with:
reth-payload-builder = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-provider = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
alloy-genesis = { version = "2.0", default-features = false }
Add one line between reth-provider and alloy-genesis:
reth-storage-api = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-storage-api is where BlockNumReader, BlockHashReader, and similar reader traits live. Same pinned SHA as the rest of the reth- deps* — version skew here would mean LiveRethEvmBridge can't accept node.provider because they'd be different versions of BlockNumReader.
Step 2: Update crates/evm/Cargo.toml
Two small changes. The [dependencies] section gets two additions:
[dependencies]
openhl-consensus = { workspace = true }
openhl-types = { workspace = true }
async-trait = { workspace = true }
eyre = { workspace = true } # NEW: was in [dev-dependencies], now production
alloy-primitives = { workspace = true }
alloy-consensus = { workspace = true }
reth-ethereum-primitives = { workspace = true }
reth-storage-api = { workspace = true } # NEW
And eyre gets removed from [dev-dependencies]:
[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 = { workspace = true }
reth-provider = { workspace = true }
alloy-genesis = { workspace = true }
serde_json = { workspace = true }
# eyre line is GONE — it's a production dep now
tempfile = "3"
Why eyre is now production: BridgeError::Internal(eyre::eyre!(...)) is constructed in build_payload (production code), not just in tests. The dev-dep listing was correct in Lesson 11 (only tests imported eyre::Result); now production code needs it.
Step 3: Create crates/evm/src/live_node.rs — module doc + imports
Top of the file. Make the role explicit and call out the remaining stubs so readers know exactly what's load-bearing in this lesson vs. what comes later:
//! `LiveRethEvmBridge` — `ConsensusBridge` backed by a real Reth provider.
//!
//! Stage 7b: parent lookups go through the live node's provider via the
//! `BlockNumReader` trait, so `build_payload` produces a child block whose
//! `number` and `parent_hash` reflect actual chain state rather than the
//! in-process synthesis of [`crate::engine::RethEvmBridge`].
//!
//! Still stubbed for now (each rolls into a later stage):
//! - `validate_payload` → Stage 7c: real `BlockExecutor` execution
//! - `commit` → Stage 7d: forkchoice via in-process Engine API
//!
//! Both stubs are visible markers of "what still needs the live node."
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_storage_api::BlockNumReader;
use std::collections::HashMap;
use std::sync::Mutex;
BlockNumReader is the single trait that drives the live read; everything else is bridge types we've used since Lesson 4.
Drawing this crate's boundary layout in one picture shows that Lesson 5's "outer = contract types / inner = alloy types" structure now gets one additional layer below it — a trait-based provider abstraction introduced for Lesson 12:
[ Outer: the consensus-layer (CL) world ]
──────────────────────────────────────────────────────────────────────
openhl-types / contract primitives (defined ourselves):
BlockHash PayloadId ExecutedBlock
──────────────────────────────────────────────────────────────────────
▲ │
│ ▼ trait-boundary conversions (same helpers as Lesson 5)
│ to_b256 / from_b256 / to_executed_block
│ │
──────────────────────────────────────────────────────────────────────
alloy-primitives / alloy-consensus (Ethereum-ecosystem standard):
B256 u64 Header
──────────────────────────────────────────────────────────────────────
│
│ self.provider.block_number(parent_b256)
▼
────── ★ NEW in Lesson 12: trait-based provider abstraction boundary ★ ──────
reth-storage-api / the abstract trait:
BlockNumReader (← the one capability the bridge actually needs)
──────────────────────────────────────────────────────────────────────
│
│ the type system hides the concrete provider
▼
──────────────────────────────────────────────────────────────────────
reth-provider / concrete impl (this is what `P` satisfies in production):
BlockchainProvider ──► MDBX storage engine ──► the real block number
──────────────────────────────────────────────────────────────────────
[ Inner: the execution-layer (EL) / actual on-disk state ]
Three things this picture pins down: (a) LiveRethEvmBridge<P> is generic over P: BlockNumReader — the bridge body never sees the concrete provider type (with its own generic parameters and bounds). (b) The trait-abstraction layer (the ★ row) lets "a mock P for tests" and "the live provider in production" plug into the same interface — anything satisfying BlockNumReader works. (c) Data narrows in type as it flows from outer to inner: BlockHash (a meaning-carrying 32-byte newtype) → B256 (alloy primitive) → a query through the trait → the single u64 MDBX returns. The trait-boundary discipline established back in Lesson 5 has been extended here by one more layer — the provider abstraction.
Step 4: Define the struct
#[derive(Debug)]
pub struct LiveRethEvmBridge<P> {
provider: P,
state: Mutex<State>,
}
#[derive(Debug, Default)]
struct State {
next_payload_id: u64,
pending: HashMap<u64, (B256, Header)>,
chain: HashMap<B256, Header>,
head: Option<B256>,
}
impl<P> LiveRethEvmBridge<P> {
#[must_use]
pub fn new(provider: P) -> Self {
Self {
provider,
state: Mutex::new(State::default()),
}
}
}
Two pieces:
LiveRethEvmBridge<P>holds the provider by value and aMutex<State>for the build/commit bookkeeping. Generic overP— no concrete provider type baked in.Statemirrors whatInMemoryEvmBridgehad (Lesson 4) — anext_payload_idcounter, apendingmap (payload_id → built header awaiting fetch), achainmap (commit history), and aheadpointer. These maps stay in place through Lesson 14; later lessons add Reth calls next to them rather than replacing them.
Step 5: The ConsensusBridge impl — build_payload is the live read
#[async_trait]
impl<P> ConsensusBridge for LiveRethEvmBridge<P>
where
P: BlockNumReader + Clone + Sync + 'static,
{
async fn build_payload(
&self,
parent: BlockHash,
attrs: PayloadAttrs,
) -> Result<PayloadId, BridgeError> {
let parent_b256 = B256::from(parent.0);
// LIVE READ: parent's block number comes from the real provider, not
// an in-process HashMap. If the provider doesn't know this hash, we
// refuse to build a child on it.
let parent_number = self
.provider
.block_number(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 mut s = self.state.lock().expect("state mutex poisoned");
let id = s.next_payload_id;
s.next_payload_id += 1;
let header = Header {
parent_hash: parent_b256,
number: parent_number + 1,
timestamp: attrs.timestamp,
beneficiary: Address::from(attrs.fee_recipient),
mix_hash: B256::from(attrs.prev_randao),
..Default::default()
};
let hash = header.hash_slow();
s.pending.insert(id, (hash, header));
Ok(PayloadId(id))
}
The trait bound P: BlockNumReader + Clone + Sync + 'static is the contract: any provider that can do hash→number lookups, that's cheap to clone, that's safe to share across threads, and that lives long enough to outlive any async task.
The build_payload body has three phases:
-
Live read (the load-bearing line).
self.provider.block_number(parent_b256)returnsResult<Option<u64>, _>:Ok(Some(n))— provider knows the parent, it's at numbern. We continue.Ok(None)— provider doesn't know the parent. We returnBridgeError::Rejected. This is what makes the bridge safe to wire into consensus — we never build on a parent the live chain has never seen.Err(e)— provider failed (DB corruption, deadlock, whatever). We returnBridgeError::Internal.
-
State allocation. Lock the mutex, grab the next ID, increment. Fast — no I/O under the lock.
-
Header synthesis. Build a child
Headerwithnumber = parent_number + 1(taken from the live read),parent_hash = parent_b256, and the attrs the engine passed. Compute the hash viaheader.hash_slow(). Store the(id → (hash, header))mapping inpending.
Step 6: payload_ready + commit stubs
These stay roughly the same as Lesson 4's in-memory bridge. validate_payload gets Reth's header validator in Lesson 13 and commit gets an Engine API forkchoice call in Lesson 14; payload_ready keeps reading the bridge's own pending map for the rest of the course:
async fn payload_ready(&self, id: PayloadId) -> Result<ExecutedBlock, BridgeError> {
let s = self.state.lock().expect("state mutex poisoned");
let n = id.0;
let (hash, header) = s
.pending
.get(&n)
.cloned()
.ok_or_else(|| BridgeError::Rejected(format!("unknown payload id {n}")))?;
Ok(ExecutedBlock {
hash: BlockHash(hash.0),
parent_hash: BlockHash(header.parent_hash.0),
number: header.number,
state_root: header.state_root.0,
})
}
async fn validate_payload(
&self,
_block: &ExecutedBlock,
) -> Result<PayloadStatus, BridgeError> {
// Stage 7c: replace with real BlockExecutor execution + state-root check.
Ok(PayloadStatus::Valid)
}
async fn commit(&self, block_hash: BlockHash) -> Result<(), BridgeError> {
// Stage 7d: replace with in-process Engine API forkchoice update.
let hash = B256::from(block_hash.0);
let mut s = self.state.lock().expect("state mutex poisoned");
let header = s
.pending
.values()
.find(|(h, _)| *h == hash)
.map(|(_, h)| h.clone())
.ok_or_else(|| BridgeError::Rejected(format!("commit for unknown hash {hash}")))?;
s.chain.insert(hash, header);
s.head = Some(hash);
Ok(())
}
}
payload_readylooks up the payload by ID inpending, builds theExecutedBlockfrom the stored header. Same shape as Lesson 4.validate_payloadisOk(PayloadStatus::Valid)— a literal "always valid" stub. The comment names Stage 7c (Lesson 13). Note that Lesson 13 ends up running Reth's header-vs-parent checks, not full block execution. Visible stubs are progress markers, not technical debt.commitrecords the block inchainand updateshead. Same shape as Lesson 4. The comment names Stage 7d (Lesson 14) as where forkchoice lands.
Step 7: Wire live_node.rs into lib.rs
Open crates/evm/src/lib.rs. From Lesson 11 it had:
pub mod engine;
pub mod in_memory;
#[cfg(test)]
mod reth_node;
pub use engine::RethEvmBridge;
pub use in_memory::InMemoryEvmBridge;
Add live_node — production-visible this time — plus a re-export:
pub mod engine;
pub mod in_memory;
pub mod live_node;
#[cfg(test)]
mod reth_node;
pub use engine::RethEvmBridge;
pub use in_memory::InMemoryEvmBridge;
pub use live_node::LiveRethEvmBridge;
Why not #[cfg(test)]? Because LiveRethEvmBridge is meant to be used from production code (in the reference repo, bin/openhl eventually constructs it). Lesson 11's bootstrap module is genuinely test-only — it just exists to validate the dep tree. Lesson 12's bridge is the production API.
Step 8: Add the integration test
Append to crates/evm/src/live_node.rs:
#[cfg(test)]
mod tests {
use super::*;
use alloy_genesis::Genesis;
use reth_chainspec::ChainSpec;
use reth_node_builder::{NodeBuilder, NodeHandle};
use reth_node_core::node_config::NodeConfig;
use reth_node_ethereum::EthereumNode;
use reth_storage_api::BlockHashReader;
use reth_tasks::Runtime;
use std::sync::Arc;
fn dev_chain_spec() -> Arc<ChainSpec> {
let custom_genesis = r#"{
"nonce": "0x42",
"timestamp": "0x0",
"extraData": "0x5343",
"gasLimit": "0x5208",
"difficulty": "0x400000000",
"mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"coinbase": "0x0000000000000000000000000000000000000000",
"alloc": {},
"number": "0x0",
"gasUsed": "0x0",
"parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"config": {
"ethash": {},
"chainId": 2600,
"homesteadBlock": 0,
"eip150Block": 0,
"eip155Block": 0,
"eip158Block": 0,
"byzantiumBlock": 0,
"constantinopleBlock": 0,
"petersburgBlock": 0,
"istanbulBlock": 0,
"berlinBlock": 0,
"londonBlock": 0,
"terminalTotalDifficulty": 0,
"terminalTotalDifficultyPassed": true,
"shanghaiTime": 0
}
}"#;
let genesis: Genesis = serde_json::from_str(custom_genesis).expect("dev genesis parses");
Arc::new(genesis.into())
}
/// END-TO-END Stage 7b: bootstrap a real Reth node, hand its provider to
/// `LiveRethEvmBridge`, build a payload on top of the real genesis block.
/// Asserts the `parent_hash` and number come from the live chain, not an
/// in-process synthesis.
#[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);
let NodeHandle {
node,
node_exit_future: _,
} = NodeBuilder::new(node_config)
.testing_node(runtime)
.node(EthereumNode::default())
.launch_with_debug_capabilities()
.await
.expect("launch failed");
// Pull the genesis hash from the live provider.
let genesis_hash_b256 = node
.provider
.block_hash(0)
.expect("provider call failed")
.expect("provider has no block 0 (genesis)");
// Construct the bridge against the live provider.
let bridge = LiveRethEvmBridge::new(node.provider.clone());
// Build a payload on the real genesis.
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");
// The bridge's lookup hit the LIVE provider — assert the resulting
// header carries genesis as its parent and is at height 1.
assert_eq!(block.parent_hash, BlockHash(genesis_hash_b256.0));
assert_eq!(block.number, 1);
// Negative case: a fabricated parent hash must be rejected because
// the live provider doesn't know it.
let fake_parent = BlockHash([0xeeu8; 32]);
let err = bridge.build_payload(fake_parent, attrs).await.unwrap_err();
assert!(matches!(err, BridgeError::Rejected(_)));
}
}
Walk through the test:
- Bootstrap a real
EthereumNode— identical setup to Lesson 11. node.provider.block_hash(0)— ask the live provider for the genesis block hash. This isBlockHashReader's API (different trait fromBlockNumReader— they're paired).LiveRethEvmBridge::new(node.provider.clone())— construct the bridge. The clone is cheap becauseBlockchainProvideris internallyArc-based.- Happy path: build a payload on the real genesis hash, fetch via
payload_ready, assertparent_hash == genesis_hashandnumber == 1. This proves the live read happened — if it were an in-memory synthesis, the parent_hash would have been whatever we passed in (still correct) butnumbercould be anything we chose.1only comes out ifprovider.block_number(genesis_hash)returnedSome(0). - Negative path:
BlockHash([0xee; 32])is a fabricated hash the chain has never seen.build_payloadmust returnBridgeError::Rejected.matches!(err, BridgeError::Rejected(_))is the exhaustive check — any other error variant would fail the test.
Test
cargo test -p openhl-evm live_bridge_builds_on_real_genesis --release
After ~30 seconds (compile + first node bootstrap):
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: ~2.4 seconds (the Reth bootstrap dominates).
Full suite:
cargo test
…should produce 36 tests workspace-wide (4 types + 21 consensus + 11 evm; the reference commit reports 37 = 26 + 11).
Common errors and fixes:
error[E0277]: P: BlockNumReader is not satisfied for ...— workspacereth-storage-apiSHA doesn't match the rest of the reth-* SHAs. Re-check Step 1.error[E0433]: failed to resolve: use of undeclared crate or module 'reth_storage_api'—reth-storage-apiis missing from[dependencies]incrates/evm/Cargo.toml(the test'suse reth_storage_api::BlockHashReader;needs it too). Re-walk Step 2.provider has no block 0 (genesis)in the happy path test —block_hash(0)returnedNone, meaning the node's database has no genesis. Check that you launched the node (and awaited the launch) before querying, with the same chain spec.- Test fails on
matches!(err, BridgeError::Rejected(_))— yourbuild_payloadpropagatedBridgeError::Internalinstead. Check the.ok_or_else(|| BridgeError::Rejected(...))line; if you used.expect(...)or.unwrap_or(0)instead, the error path won't fire.
Design reflection
Three load-bearing decisions encoded here:
-
The bridge is generic over
P: BlockNumReader, not concrete onBlockchainProvider. Production passes the live provider; tests could pass a mock; a future provider could even talk JSON-RPC to a separate Reth process. The bridge code doesn't change — only the type parameter does. -
Result<Option<u64>, _>distinguishes operational from protocol failures. A failed DB call is a different problem from "we don't know this hash." Mapping them toBridgeError::Internalvs.BridgeError::Rejectedlets consumers respond appropriately — alert on the first, ignore-and-vote-nil on the second. Errors carry semantics, not just messages. -
The two-test happy/negative pair is the minimal honest validation. Either one alone is insufficient: happy alone fails to catch silent fallback to in-memory state, negative alone fails to catch a bridge that always rejects. A live integration needs both to be load-bearing.
Answer key
cd ~/code/openhl-reference
git checkout 8d211b8
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
diff -u ~/code/my-openhl/crates/evm/src/lib.rs ./crates/evm/src/lib.rs
The reference at 8d211b8 includes ~227 lines of live_node.rs. The trait bound P: BlockNumReader + Clone + Sync + 'static, the build_payload body, and the two-path test should match closely. Doc comments and exact wording can vary.
Return:
git checkout main
Common questions
Q: Why is the bridge generic over P instead of taking BlockchainProvider directly?
Two reasons. First, BlockchainProvider is a heavy concrete type, generic over the node's types with bounds of its own — using it directly means every consumer of LiveRethEvmBridge has to thread those through. The generic P: BlockNumReader reduces the surface to exactly the one capability the bridge needs. Second, generic-over-trait makes mock testing easy — write a mock provider impl, pass it to LiveRethEvmBridge::new(...), get a unit-testable bridge that doesn't need a real node bootstrap.
Q: What's the difference between BlockNumReader::block_number and BlockHashReader::block_hash?
Direction. block_number(hash) → Option<u64> answers "what number is this hash at?" block_hash(n) → Option<B256> answers "what hash is at this number?" The test uses both: block_hash(0) to pull the genesis hash, then LiveRethEvmBridge internally uses block_number(hash) to look up the parent's number. Same chain index, two access patterns.
Q: Why Mutex<State> instead of parking_lot::Mutex<State>?
std::sync::Mutex is fine for low-contention scenarios. The bridge's state is only touched on build_payload / payload_ready / commit — each at most once per block, separated by tens to thousands of milliseconds. parking_lot matters when you have lots of contention; here you have almost none. Don't add a dep without a reason.
Q: When does this bridge actually replace RethEvmBridge?
For production use, LiveRethEvmBridge supersedes RethEvmBridge (Lesson 5). RethEvmBridge stays in the codebase as a pedagogical waypoint with its own tests (the engine test in Lesson 10 uses a separate StubBridge, not either of these). Two bridges in the codebase represent two stages of integration, not duplicate implementations.
Next lesson (Lesson 13)
The bridge reads from Reth on build_payload. But validate_payload still says Valid to everything, and the header we build is mostly zeros. Lesson 13 runs Reth's real header validator (EthBeaconConsensus::validate_header_against_parent) against the live parent, which forces build_payload to produce headers Reth accepts: copied gas limit, EIP-1559 base fee from the chain spec, increasing timestamp. Blocks still have no transactions; execution and state roots stay out of scope.
Summary (3 lines)
LiveRethEvmBridge<P: BlockNumReader>implementsConsensusBridge;build_payloadlooks up the parent's number in the live provider and builds atn + 1.- Unknown parent →
Rejected, provider failure →Internal.payload_ready/validate_payload/commitare still local stubs. - One test covers both paths against a real dev node: build on genesis → number 1; build on a made-up hash →
Rejected. Next: validate_payload runs EthBeaconConsensus.