Lesson 12 — LiveRethEvmBridge reads parents from the real chain
Question
Replace the InMemory bridge with LiveRethEvmBridge — calls Reth's Engine API for parent lookups. Real chain state behind the trait; Malachite doesn't know the difference.
Principle (minimum model)
LiveRethEvmBridge. Holds anArc<EthereumNode>(or RPC client). Methods call into Reth's Engine API.get_parent(hash) -> Block. Callsengine_getBlockByHashvia Reth's Engine API. Real chain lookup.get_best_block() -> Block. Callsengine_getBlockByNumber("latest").- Caching. Lookups can be slow; cache recent results. LRU with 1000 entries.
- Same trait. Malachite still calls
bridge.get_parent(...); doesn't know it now goes to Reth. The abstraction holds. - Async; doesn't block. Engine API calls are async; bridge methods are async; Malachite's event loop unblocks during the await.
- Tests. Boot Reth + LiveRethEvmBridge; assert
get_parentreturns the genesis block.
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 has 30+ trait bounds you'd otherwise have to thread through every call site; the generic narrows the surface and makes mock testing trivial. - 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 — 13 reth-* workspace deps + alloy-genesis
crates/evm/Cargo.toml — 6 production deps + 11 dev-deps
crates/evm/src/bridges/ — InMemoryEvmBridge (Lesson 4) + RethEvmBridge (Lesson 5)
crates/evm/src/reth_node.rs — bootstrap-only smoke test
crates/consensus/ — full BFT engine + run_engine_app
cargo test passes 36 tests workspace-wide. 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 MockProvider 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 30+ trait 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. Lessons 13–15 replace each of these with live Reth structures.
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 two stay roughly the same as Lesson 4's in-memory bridge — the live-Reth integration for them lands in Lesson 13 (payload_ready against Reth's real payload-builder) and Lesson 15 (commit against the Engine API):
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 Lesson 14 (Stage 7c) as where the real execution lands. Visible stubs are progress markers, not technical debt.commitrecords the block inchainand updateshead. Same shape as Lesson 4. The comment names Lesson 15 (Stage 7d) 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 bridges;
#[cfg(test)]
mod reth_node;
Add live_node — production-visible this time:
pub mod bridges;
pub mod live_node;
#[cfg(test)]
mod reth_node;
Why not #[cfg(test)]? Because in Lessons 13–15 we'll use LiveRethEvmBridge from production code (eventually from bin/openhl/src/main.rs). 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 37 tests workspace-wide.
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_provider'— you forgot to addreth-provider = { workspace = true }to the[dev-dependencies]block ofcrates/evm/Cargo.toml(note: it'sreth-provider, notreth-storage-api— the latter gives you the trait, the former gives you the concrete provider type you need in tests). The fix isn't atest-utilsfeature — it's adding the dependency itself. Re-walk the Step 2 dependency list.provider has no block with hash 0x000...in the happy path test — you're queryingblock_hash(0)but it returnsNone. Check that you're using.dev()mode inNodeConfig(test mode without dev sometimes doesn't pre-seed genesis correctly).- 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. - Test compiles but says "P is private" — your
LiveRethEvmBridge<P>needspub struct ... { provider: P, ... }. Even thoughproviderispub, the generic parameter beingpubis implicit.
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; future module 7 might pass aRemoteProviderthat talks 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 with 30+ trait bounds in its definition — using it directly means every consumer of LiveRethEvmBridge has to thread those bounds 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 MockProvider 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?
It already has — RethEvmBridge (Lesson 5) is now superseded by LiveRethEvmBridge for production use. RethEvmBridge stays in the codebase as a pedagogical waypoint and as the in-memory variant used in StubBridge for engine tests. 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 the pending HashMap is still just an in-process synthesis — the engine asks for "the next block to propose" and we hand back a header we made up. Lesson 13 replaces pending with Reth's actual PayloadBuilder — the same machinery Reth uses to assemble blocks for the JSON-RPC engine_getPayloadV4 call. By the end of Lesson 13, the bridge produces blocks that real Ethereum tooling could accept (full transaction lists, receipts, gas usage, state root). This is the transition from "the bridge talks to Reth's storage" to "the bridge is fully integrated with Reth's execution pipeline."
Summary (3 lines)
LiveRethEvmBridgeimpls the trait via Reth's Engine API. get_parent + get_best_block hit the real chain.- LRU cache mitigates latency. Async throughout; Malachite's event loop unblocks.
- Same trait, real backing. Tests assert basic lookups work. Next: validate_payload runs EthBeaconConsensus.