Skip to content
← Build OpenHL — from `cargo init` to a single-validator devnet
Live Reth
Lesson 13 of 16·CONTENT50 min100 XP
Course
Build OpenHL — from `cargo init` to a single-validator devnet
Lesson role
CONTENT
Sequence
13 / 16

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 new ConsensusBridge in crates/evm/src/live_node.rs holding a provider P plus a Mutex<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, P is the live node's BlockchainProvider.
  • The live read. build_payload calls provider.block_number(parent): Ok(Some(n)) → build a child header at n + 1; Ok(None) → BridgeError::Rejected; Err → BridgeError::Internal.
  • Still stubs. payload_ready reads the bridge's own pending map, validate_payload always returns Valid (Lesson 13), commit only records into chain/head (Lesson 14).
  • Same trait. It implements the same ConsensusBridge as the earlier bridges, so run_engine_app could drive it unchanged.
  • Test. live_bridge_builds_on_real_genesis boots a dev node, gets the genesis hash with block_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 on BlockchainProvider — 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) and LiveRethEvmBridge (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 over P: BlockNumReader + Clone + Sync + 'static. build_payload is real (queries the live provider); payload_ready reads from in-memory pending state; validate_payload + commit stay stubbed for Lessons 13–14.
  • crates/evm/Cargo.toml adds the production deps needed by the generic bound.
  • crates/evm/src/lib.rs — wires pub 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:

  1. Add reth-storage-api at the workspace level — provides BlockNumReader, the trait surface we're generic over.
  2. Update crates/evm/Cargo.toml — promote eyre from dev-dep to production dep (for BridgeError::Internal's message construction); add reth-storage-api as production dep.
  3. Create crates/evm/src/live_node.rs with LiveRethEvmBridge<P> struct + ConsensusBridge impl (build_payload is live, others are stubs).
  4. Wire pub mod live_node; into crates/evm/src/lib.rs (production-visible this time, not #[cfg(test)]).
  5. Add the integration test live_bridge_builds_on_real_genesis — bootstraps a real node, asserts happy + negative paths.
  6. 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 a Mutex<State> for the build/commit bookkeeping. Generic over P — no concrete provider type baked in.
  • State mirrors what InMemoryEvmBridge had (Lesson 4) — a next_payload_id counter, a pending map (payload_id → built header awaiting fetch), a chain map (commit history), and a head pointer. 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:

  1. Live read (the load-bearing line). self.provider.block_number(parent_b256) returns Result<Option<u64>, _>:

    • Ok(Some(n)) — provider knows the parent, it's at number n. We continue.
    • Ok(None) — provider doesn't know the parent. We return BridgeError::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 return BridgeError::Internal.
  2. State allocation. Lock the mutex, grab the next ID, increment. Fast — no I/O under the lock.

  3. Header synthesis. Build a child Header with number = parent_number + 1 (taken from the live read), parent_hash = parent_b256, and the attrs the engine passed. Compute the hash via header.hash_slow(). Store the (id → (hash, header)) mapping in pending.

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_ready looks up the payload by ID in pending, builds the ExecutedBlock from the stored header. Same shape as Lesson 4.
  • validate_payload is Ok(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.
  • commit records the block in chain and updates head. 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:

  1. Bootstrap a real EthereumNode — identical setup to Lesson 11.
  2. node.provider.block_hash(0) — ask the live provider for the genesis block hash. This is BlockHashReader's API (different trait from BlockNumReader — they're paired).
  3. LiveRethEvmBridge::new(node.provider.clone()) — construct the bridge. The clone is cheap because BlockchainProvider is internally Arc-based.
  4. Happy path: build a payload on the real genesis hash, fetch via payload_ready, assert parent_hash == genesis_hash and number == 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) but number could be anything we chose. 1 only comes out if provider.block_number(genesis_hash) returned Some(0).
  5. Negative path: BlockHash([0xee; 32]) is a fabricated hash the chain has never seen. build_payload must return BridgeError::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 ... — workspace reth-storage-api SHA 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-api is missing from [dependencies] in crates/evm/Cargo.toml (the test's use 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) returned None, 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(_)) — your build_payload propagated BridgeError::Internal instead. 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:

  1. The bridge is generic over P: BlockNumReader, not concrete on BlockchainProvider. 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.

  2. 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 to BridgeError::Internal vs. BridgeError::Rejected lets consumers respond appropriately — alert on the first, ignore-and-vote-nil on the second. Errors carry semantics, not just messages.

  3. 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> implements ConsensusBridge; build_payload looks up the parent's number in the live provider and builds at n + 1.
  • Unknown parent → Rejected, provider failure → Internal. payload_ready / validate_payload / commit are 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.