FABRKNT
Build OpenHL — from `cargo init` to a single-validator devnet
Live Reth
Lesson 15 of 16·CONTENT50 min90 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Build OpenHL — from `cargo init` to a single-validator devnet
Lesson role
CONTENT
Sequence
15 / 16

Lesson 14 — commit drives Reth's Engine API forkchoice

Question

bridge.commit(block_hash) calls Reth's Engine API engine_forkchoiceUpdatedV3 to finalise the block. State persisted; head advanced. The full integration loop is now closed.

Principle (minimum model)

  • Engine API forkchoiceUpdatedV3. Updates Reth's head + finalised + safe block hashes.
  • bridge.commit(block_hash). Computes the forkchoice state (head = block_hash; finalised = block_hash; safe = block_hash). Calls Engine API.
  • State persisted. Reth flushes to disk; survives restart. The block is now part of the canonical chain.
  • Single-validator simplification. All three hashes (head / finalised / safe) are the same. Multi-validator separates them.
  • End-to-end test. Boot Reth + OpenHlNode; produce 3 blocks; restart; assert the 3 blocks are still in Reth.
  • Closing the loop. Block flow: Malachite → bridge.propose → bridge.validate_payload (Reth check) → bridge.commit (Reth Engine API) → Reth flushes to disk → block on canonical chain.
  • Why end-to-end matters. All prior work could have hidden integration bugs. This lesson + the persistence test prove they're absent.

Worked example + steps

Lesson 14 — commit drives Reth's Engine API forkchoice

Goal

Concepts you'll grasp in this lesson:

  • Local-first, engine-second commit ordering — the bridge's chain: HashMap is the consensus layer's source of truth. Committing locally first and notifying the engine second means a failed engine call never forces a rollback of a consensus commit (which would violate safety). This generalizes: primary store first, secondary indexes/replicas after.
  • Option<EngineHandle> for test ergonomics — without optionality, every unit test would need to bootstrap a real node just to get a non-test engine handle. With Option, tests pass None for the local path and Some(handle) for the integration path. Type-level optionality avoids forcing infrastructure into every test.
  • Engine response is intentionally discardedSYNCING is the expected response right now because no matching engine_newPayload was sent first. Treating SYNCING as an error would force every caller to know Lesson 14 is partial. Discarding keeps the API honest: "commit completed locally; downstream notification was best-effort."
  • The three-field ForkchoiceState collapse — mainnet distinguishes head / safe / finalized (instant / 32-slot / 64+-slot checkpoints). v0 single-validator OpenHL has no such distinction — every commit is final, so all three fields take the same hash. The shape is preserved for forward compat with multi-validator OpenHL.
  • add_ons_handle.beacon_engine_handle is the in-process Engine API — the same handle that backs the network-facing JSON-RPC engine_* methods that external CL clients (Lighthouse, Prysm) would use. We're taking the in-process shortcut, but the surface is identical.
  • All four ConsensusBridge methods now hit real Reth — this lesson closes the loop. build_payload / payload_ready / validate_payload / commit all reach real Reth code paths.

Verification:

cargo test -p openhl-evm commit_sends_forkchoice_to_engine_when_handle_installed --release

…passes one new integration test. Combined with Lessons 11–13's existing tests, your bridge now has all four ConsensusBridge methods hitting real Reth code paths:

MethodWhat it doesWhat real Reth code runs
build_payloadBuild a child blockHeaderProvider::sealed_header_by_hash, ChainSpec::next_block_base_fee
payload_readyFetch the built block(local — bridge's pending map)
validate_payloadCheck the blockEthBeaconConsensus::validate_header_against_parent
commitMake the block canonicalConsensusEngineHandle::fork_choice_updated

Engine returns SYNCING for now — and that's correct at this stage. We're not yet sending matching engine_newPayload calls (that needs EVM-executable transaction bodies, which are out of scope for this course). The wire is connected; payload-execution alignment is the next chunk of work after fills become EVM transactions.

Specific changes:

  • New optional field engine_handle: Option<ConsensusEngineHandle<EthEngineTypes>> on LiveRethEvmBridge.
  • New builder method with_engine_handle() (#[must_use]) and introspection has_engine_handle().
  • commit() now does two things: (1) local bookkeeping (unchanged from Lesson 13), then (2) if an engine handle is installed, fire a ForkchoiceUpdated to Reth's in-process Engine API and discard the response.
  • New integration test that bootstraps EthereumNode, installs add_ons_handle.beacon_engine_handle on the bridge, and asserts both the local commit and the forkchoice path fire.

Recap

After Lesson 13 your crates/evm/src/live_node.rs has:

pub struct LiveRethEvmBridge<P> {
    provider: P,
    chain_spec: Arc<ChainSpec>,
    validator: EthBeaconConsensus<ChainSpec>,
    state: Mutex<State>,
}

build_payload, payload_ready, and validate_payload all run against live Reth. commit still records the new head in state.chain (in-process HashMap) and updates state.head. Local-only. RPC clients querying the live Reth node still see genesis as the head — the consensus engine doesn't know we've decided anything.

cargo test passes 37 tests workspace-wide. The bridge knows the canonical chain; Reth doesn't.

Plan

Six things:

  1. Add 2 workspace deps: reth-ethereum-engine-primitives (for EthEngineTypes) and alloy-rpc-types-engine (for ForkchoiceState).
  2. Update crates/evm/Cargo.toml — add 3 new production deps (the 2 above plus reth-engine-primitives which gives us ConsensusEngineHandle).
  3. Update imports + struct in live_node.rs — new field engine_handle: Option<ConsensusEngineHandle<EthEngineTypes>>.
  4. Add the builder methodswith_engine_handle() consumes self and installs the handle; has_engine_handle() is a const fn accessor.
  5. Rewrite commit() — local bookkeeping first (unchanged), then best-effort ForkchoiceUpdated if an engine handle is installed.
  6. Add the integration test — bootstraps EthereumNode, pulls add_ons_handle.beacon_engine_handle, plumbs it through with_engine_handle(), exercises the commit path.

This lesson teaches the side-effect-after-success pattern. The bridge's local bookkeeping is the source of truth for our consensus layer — it has to succeed before anything else can happen. The Engine API call is a side effect: useful (downstream RPC clients see the new head), but its failure shouldn't roll back our commit. The pattern is:

1. Do the thing that has to succeed (local state mutation).
2. Best-effort side effects (fire-and-mostly-forget).
3. Return success.

If step 2 fails, we log it but don't propagate — because step 1 already happened, and rolling it back would leave us in an inconsistent state. Side effects that follow a success are different from side effects that gate a success.

Laying out what happens when commit is called — Phase 1 (must succeed) and Phase 2 (best-effort) — in chronological order makes it obvious why a Phase 2 failure must not undo Phase 1:

   [ openhl-consensus ] (Malachite actor)
              │
              │ bridge.commit(block_hash).await
              ▼
  ┌──────────────────────────────────────────────────────────────────┐
  │ ◆ LiveRethEvmBridge::commit()                                     │
  │                                                                    │
  │  [ Phase 1: canonical commit (must succeed) ]                      │
  │   ├── acquire Mutex<State> via state.lock()                        │
  │   ├── look up the header in pending (Rejected if not found)       │
  │   ├── state.chain.insert(hash, header)  ◄── new canonical entry   │
  │   └── state.head = Some(hash)           ◄── source-of-truth update │
  │                                                                    │
  │   ※ Past this point, the consensus layer treats the block as       │
  │      committed; downstream `payload_ready` / next `build_payload`  │
  │      will read this value immediately.                              │
  └──────────────────────────────┬───────────────────────────────────┘
                                 │ (local commit succeeded — no rollback)
                                 ▼
  ┌──────────────────────────────────────────────────────────────────┐
  │  [ Phase 2: best-effort side-effect (fire-and-mostly-forget) ]   │
  │   ├── ForkchoiceState { head_block_hash, safe = head, finalized = head } │
  │   └── if let Some(handle) = &self.engine_handle {                      │
  │           let _ = handle.fork_choice_updated(state, None).await;       │
  │       }                                                                │
  └──────────────────────────────┬───────────────────────────────────┘
                                 │
                                 ▼ in-process Engine API
                       ┌──────────────────────────────┐
                       │ Reth engine actor              │
                       │ (Currently has no body, so it  │
                       │  replies with PayloadStatus    │
                       │  ::SYNCING)                    │
                       └──────────────┬───────────────┘
                                      │ response is discarded via `let _ =`
                                      ▼
                              `commit` returns Ok(())
                              CL proceeds to the next round, unaware

Three things this picture pins down: (a) Phase 1's state.chain.insert + state.head update is the consensus-side "committed" source-of-truth — past this line, downstream code (payload_ready, the next build_payload) reads from these structures immediately. (b) Phase 2's fork_choice_updated is a downstream-notification side effect; SYNCING / connection failures / panics get logged but are not turned into Err — if a Phase 2 failure surfaced as Err, consensus would treat "commit failed" as true and try to roll back already-finalized state, breaking safety. (c) When engine_handle: Option<...> is None, Phase 2 is skipped entirely — unit tests can exercise "Phase 1 only, no Reth bootstrap." Lesson 14's integration test passes Some(handle) and asserts that both phases fire.

Walk-through

Step 1: Add 2 workspace deps

Open the root Cargo.toml. The reth block (after Lesson 13) ends with:

reth-ethereum-consensus   = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-primitives-traits    = "0.3"
alloy-genesis             = { version = "2.0", default-features = false }

Add 1 line right after reth-ethereum-consensus:

reth-ethereum-engine-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }

And add 1 line to the alloy block lower down (find the existing alloy-consensus workspace dep):

alloy-rpc-types-engine = { version = "2.0", default-features = false }

Two deps, two roles:

  • reth-ethereum-engine-primitives — provides EthEngineTypes, the type bundle that says "Ethereum mainnet's engine surface" (vs. Optimism, custom L2s). Our ConsensusEngineHandle<EthEngineTypes> is parameterized over it.
  • alloy-rpc-types-engine — provides ForkchoiceState { head_block_hash, safe_block_hash, finalized_block_hash }, the canonical wire-format payload for an engine_forkchoiceUpdatedV4 call. Same struct CL clients (Lighthouse, Prysm) send to EL clients over JSON-RPC; we're using it in-process.

Note the version on alloy-rpc-types-engine: it's pinned to 2.0 to match Reth v2.2.0's own pinned alloy-rpc-types-engine of 2.0.4. Mismatched versions here would cause ForkchoiceState to be two different types and the engine handle would reject our calls.

Step 2: Update crates/evm/Cargo.toml

The [dependencies] block gains 3 lines:

[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 }
reth-ethereum-consensus  = { workspace = true }
reth-primitives-traits   = { workspace = true }
reth-chainspec           = { workspace = true }
reth-engine-primitives          = { workspace = true }    # NEW: ConsensusEngineHandle
reth-ethereum-engine-primitives = { workspace = true }    # NEW: EthEngineTypes
alloy-rpc-types-engine          = { workspace = true }    # NEW: ForkchoiceState

reth-engine-primitives has been a workspace dep since Lesson 1 (for reth-engine-primitives is where PayloadAttributesBuilder lives, used in some intermediate stages). Here we promote it from "available in the workspace" to "imported by this crate."

Step 3: Update imports + struct in live_node.rs

Open crates/evm/src/live_node.rs. The imports gain 3 lines:

use alloy_consensus::Header;
use alloy_primitives::{Address, B256};
use alloy_rpc_types_engine::ForkchoiceState;                        // NEW
use async_trait::async_trait;
use openhl_consensus::bridge::{BridgeError, ConsensusBridge};
use openhl_types::{BlockHash, ExecutedBlock, PayloadAttrs, PayloadId, PayloadStatus};
use reth_chainspec::{ChainSpec, EthChainSpec};
use reth_consensus::HeaderValidator;
use reth_engine_primitives::ConsensusEngineHandle;                  // NEW
use reth_ethereum_consensus::EthBeaconConsensus;
use reth_ethereum_engine_primitives::EthEngineTypes;                // NEW
use reth_primitives_traits::SealedHeader;
use reth_storage_api::{BlockNumReader, HeaderProvider};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

Three new types:

  • ForkchoiceState — the payload (head/safe/finalized block hashes) we send to the engine.
  • ConsensusEngineHandle — the handle Reth gives us to send messages to its engine actor.
  • EthEngineTypes — the type parameter binding the handle to Ethereum mainnet's engine surface.

Now the struct gains one field — engine_handle, optional:

#[derive(Debug)]
pub struct LiveRethEvmBridge<P> {
    provider: P,
    chain_spec: Arc<ChainSpec>,
    validator: EthBeaconConsensus<ChainSpec>,
    /// Optional in-process Engine API handle. When installed via
    /// [`Self::with_engine_handle`], `commit` sends a `ForkchoiceUpdated`
    /// to Reth so its canonical chain advances in lockstep with consensus.
    /// `None` at v0 means commits stay local to the bridge's `state.chain`
    /// `HashMap` — fine for unit tests, but RPC clients won't see new heads.
    engine_handle: Option<ConsensusEngineHandle<EthEngineTypes>>,           // NEW
    state: Mutex<State>,
}

State is unchanged.

Step 4: Update new() and add the builder methods

new() initializes engine_handle: None:

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,
            engine_handle: None,                                  // NEW
            state: Mutex::new(State::default()),
        }
    }

    /// Install a Reth in-process Engine API handle. After this call,
    /// `commit` will fire a `ForkchoiceUpdated` to Reth's consensus engine
    /// alongside its own local bookkeeping. Without an engine handle, the
    /// bridge still works (commits go to its internal `HashMap`) but Reth's
    /// canonical chain won't advance — RPC and any other Reth consumer will
    /// see only the genesis block.
    #[must_use]
    pub fn with_engine_handle(
        mut self,
        handle: ConsensusEngineHandle<EthEngineTypes>,
    ) -> Self {
        self.engine_handle = Some(handle);
        self
    }

    #[must_use]
    pub const fn has_engine_handle(&self) -> bool {
        self.engine_handle.is_some()
    }

    #[must_use]
    pub fn chain_spec(&self) -> &Arc<ChainSpec> {
        &self.chain_spec
    }
}

Three new methods:

  • with_engine_handle() — consume-and-return-self builder. The mut self parameter takes ownership, mutates, returns. This is the canonical Rust "builder method" pattern. #[must_use] because forgetting to bind the return value (e.g., bridge.with_engine_handle(h);) silently drops the modified bridge. Note: this is self (consuming), not &mut self, so the pattern let bridge = ...; bridge.with_engine_handle(h); will move bridge out and leave you unable to use it on subsequent lines. The idiomatic shape is to chain from the constructor in a single expression — let bridge = LiveRethEvmBridge::new(p, c).with_engine_handle(h); (which is what Step 6's integration test does). For conditional wiring, keep construction → configuration → binding inside one expression: let bridge = if want_engine { LiveRethEvmBridge::new(p, c).with_engine_handle(h) } else { LiveRethEvmBridge::new(p, c) };.
  • has_engine_handle() — a const fn accessor. Useful for tests and assertions ("did the wiring actually take effect?"). const because checking Option::is_some() doesn't require any runtime computation.
  • new() initialization — the only change is engine_handle: None. Callers who want the handle use LiveRethEvmBridge::new(p, c).with_engine_handle(h).

Step 5: Rewrite commit() — local first, engine best-effort

The load-bearing change. Replace Lesson 13's commit with:

    async fn commit(&self, block_hash: BlockHash) -> Result<(), BridgeError> {
        let hash = B256::from(block_hash.0);

        // Local bookkeeping first. If this fails, we never call the engine
        // — the bridge stays in a consistent state.
        let _header = {
            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.clone());
            s.head = Some(hash);
            header
        };

        // Best-effort: if an Engine API handle has been installed, also tell
        // Reth's consensus engine about the new canonical head. We always
        // commit *locally* first (above) — sending to the engine is best-
        // effort at this stage because we haven't yet uploaded a real
        // ExecutionPayload via newPayload, so the engine will return
        // SYNCING/INVALID. The wire being connected is what 7d proves; full
        // payload-execution alignment is downstream once fills become EVM
        // transactions.
        if let Some(handle) = &self.engine_handle {
            let state = ForkchoiceState {
                head_block_hash: hash,
                safe_block_hash: hash,
                finalized_block_hash: hash,
            };
            let _ = handle.fork_choice_updated(state, None).await;
        }

        Ok(())
    }

Two phases:

  1. Local bookkeeping — same shape as Lesson 13. Lookup pending header by hash, insert into chain, update head. If header is missing → BridgeError::Rejected. The header binding is now let _header because we don't use it later in this function; the binding exists for clarity and future telemetry.

  2. Best-effort engine notification — only when engine_handle.is_some(). Build the ForkchoiceState with all three slots (head, safe, finalized) pointing to the new hash. Why all three to the same hash? At v0 we don't have a separate finalization layer — every committed block is also safe and finalized in our model. Production multi-validator chains would track these separately (a block can be the head but not yet finalized until 2/3 of validators have voted on its descendants).

  3. The let _ = ...await is intentional — we discard the engine's response. Engine returns:

    • VALID — once we send engine_newPayload first with the matching block body, this is the happy case.
    • SYNCING — what we get right now, because we haven't sent newPayload. Engine wants to fetch the block from peers but there are no peers.
    • INVALID — would mean we asked the engine to make canonical a block it has rejected. Shouldn't happen in practice for a block we built ourselves.

For Lesson 14, all three responses lead to the same code path: continue. Our local bookkeeping already happened.

Step 6: Update the test (rename + add engine wiring)

Open the existing test live_bridge_builds_on_real_genesis from Lesson 13. We add a new test rather than modifying the existing one — the Lesson 12 / Lesson 13 test still proves what it proved, and adding a separate test keeps the new behaviour isolated.

Append to the tests module in crates/evm/src/live_node.rs:

    /// **Stage 7d**: with a Reth `ConsensusEngineHandle` installed, `commit`
    /// sends a `ForkchoiceUpdated` to the in-process Engine API. The bridge's
    /// own bookkeeping still happens (so existing callers don't regress), but
    /// now Reth is told about the new head too.
    ///
    /// At this stage the engine will respond SYNCING because we haven't sent
    /// a matching `newPayload` (`build_payload` doesn't yet produce a real
    /// `ExecutionPayload`). That's intentional: Lesson 14 proves the wire is
    /// connected. Full alignment between Malachite's commit and Reth's
    /// canonical head needs `newPayload` integration, which is the next
    /// staging chunk after fills become EVM transactions.
    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    async fn commit_sends_forkchoice_to_engine_when_handle_installed() {
        use reth_node_ethereum::node::EthereumAddOns;

        let runtime = Runtime::test();
        let chain_spec = dev_chain_spec();
        let node_config = NodeConfig::test().dev().with_chain(chain_spec.clone());

        // We need add_ons_handle for the engine handle — use the explicit
        // NodeBuilder path with EthereumAddOns rather than launch_with_dbg.
        let handle = NodeBuilder::new(node_config)
            .testing_node(runtime)
            .with_types::<EthereumNode>()
            .with_components(EthereumNode::components())
            .with_add_ons(EthereumAddOns::default())
            .launch()
            .await
            .expect("launch failed");

        // Pull the engine handle out of add_ons. This is what RPC's
        // engine_forkchoiceUpdated endpoint would dispatch to — we're
        // taking the in-process shortcut around the JSON-RPC layer.
        let engine_handle = handle.node.add_ons_handle.beacon_engine_handle.clone();

        let bridge = LiveRethEvmBridge::new(handle.node.provider.clone(), chain_spec)
            .with_engine_handle(engine_handle);
        assert!(
            bridge.has_engine_handle(),
            "with_engine_handle must install the handle"
        );

        let genesis_hash_b256 = handle
            .node
            .provider
            .block_hash(0)
            .expect("provider call failed")
            .expect("provider has no genesis");

        // Build a payload on top of genesis so commit has something to find.
        let attrs = PayloadAttrs {
            timestamp: 1,
            fee_recipient: [0u8; 20],
            prev_randao: [0u8; 32],
        };
        let id = bridge
            .build_payload(BlockHash(genesis_hash_b256.0), attrs)
            .await
            .expect("build_payload failed");
        let block = bridge.payload_ready(id).await.expect("payload_ready failed");

        // The actual test: commit should not panic, not block forever, not
        // surface an error from the engine-side SYNCING response. We're
        // proving the wire is connected — that fork_choice_updated reaches
        // the engine and returns *some* response (even SYNCING).
        bridge
            .commit(block.hash)
            .await
            .expect("commit failed even though local bookkeeping should succeed");

        // Negative case: a commit for an unknown hash must still be Rejected
        // (the engine-side call doesn't happen because the bridge bails out
        // before it).
        let bogus = BlockHash([0xddu8; 32]);
        let err = bridge.commit(bogus).await.unwrap_err();
        assert!(
            matches!(err, BridgeError::Rejected(_)),
            "unknown hash must yield Rejected"
        );

        drop(handle);
    }

Walk through what's new:

  1. with_types::<EthereumNode>() + with_components(...) + with_add_ons(EthereumAddOns::default()) — the explicit builder path. launch_with_debug_capabilities (Lessons 11–13) is a shortcut that doesn't expose add_ons_handle. To pull out the beacon engine handle, we need the explicit form.
  2. handle.node.add_ons_handle.beacon_engine_handle.clone() — the engine handle lives inside add_ons. It's an Arc-based handle internally; cloning is cheap.
  3. .with_engine_handle(engine_handle) — our new builder method. Without this, commit does only local bookkeeping. With this, commit also fires forkchoice.
  4. assert!(bridge.has_engine_handle()) — the wiring guard. If with_engine_handle() had a bug, this would catch it before the rest of the test runs.
  5. commit(block.hash).await.expect("commit failed") — the main assertion. Note we don't check what the engine returned — only that commit returns Ok(()). The engine's SYNCING response is discarded inside commit per Step 5.
  6. Negative case retained — unknown hash still yields BridgeError::Rejected. The engine path never fires because the bridge bails before reaching it.

Test

cargo test -p openhl-evm commit_sends_forkchoice_to_engine_when_handle_installed --release

After ~30 seconds (compile + node bootstrap):

running 1 test
test live_node::tests::commit_sends_forkchoice_to_engine_when_handle_installed ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Test runtime: ~3 seconds (Reth bootstrap + forkchoice round-trip).

Full suite:

cargo test

…should produce 38 tests workspace-wide (Lesson 13's 37 + the new test).

Common errors and fixes:

  • **error[E0282]: type annotations needed for Option<ConsensusEngineHandle<_>>** — the engine_handle: Noneinnew()needs the type parameter inferred. Either the struct field's type annotation is missing/wrong, or you forgot theEthEngineTypes` import. Re-check Step 3.
  • **error: cannot find struct EthereumAddOnsin modulereth_node_ethereum::node** — version drift between reth-node-ethereumand the rest ofreth-*`. All git-pinned reth deps must share the same SHA.
  • Test hangs > 30s — the most likely cause is that EthereumNode's background tasks (engine actor, payload builder, libp2p, RPC stubs, …) aren't cleaning up promptly, and Tokio runtime teardown is blocked on them. When EthereumNode drops at the end of the test, each actor waits on its JoinHandle; if any oneshot is still pending or a socket isn't released, the runtime stalls. Verify that the test ends with proper cleanup — explicit drop(handle); or a node.task_executor().graceful_shutdown_with_timeout(...) where appropriate.
    • Side note: dropping .await from let _ = handle.fork_choice_updated(state, None).await doesn't cause a hang — it causes a silent skip (warning: unused implementor of 'Future' fires; the future is dropped on the spot and the engine notification never runs). A missing .await produces a "Reth never gets notified" bug that lets the test sail through; hangs and silent skips are diagnostically different beasts, so identify which symptom you're seeing before chasing the wrong fix.
  • assert!(bridge.has_engine_handle()) failswith_engine_handle is #[must_use] but you didn't bind the return: let bridge = ...new(...); bridge.with_engine_handle(h);. Must be let bridge = ...new(...).with_engine_handle(h);.
  • Commit returns Ok but the test for unknown hash also returns Ok (no rejection) — your commit logic is reaching the engine path before the local lookup. Re-check Step 5 — the ? propagates BridgeError::Rejected and exits before the engine block.

Design reflection

Three load-bearing decisions encoded here:

  1. Local state first, engine second. The bridge's chain: HashMap is the consensus layer's source of truth. If we sent to the engine first and it failed, we'd have to decide whether to roll back local state — and rolling back consensus commits is a violation of safety. The order forces the right answer: succeed locally, then notify downstream. This pattern generalizes to any system with a primary store + secondary indexes/replicas.

  2. Option<EngineHandle> keeps the test surface clean. Without optionality, every unit test would need to bootstrap a real node just to get a non-test engine handle. With optionality, tests pass None and exercise the local path; integration tests pass Some(handle) and exercise both. Type-level optionality is how you avoid forcing infrastructure into every test.

  3. The engine response is intentionally discarded. SYNCING is the expected response right now (we haven't sent newPayload). Returning errors on it would force every consumer to know that Lesson 14 is a partial integration. Discarding keeps the API contract clean: "commit completed locally, downstream notification was best-effort." What clients need to know is what they need to know — no more.

Answer key

cd ~/code/openhl-reference
git checkout 0cac571
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 0cac571 may contain additional code (CLOB integration from Stage 8) that we didn't introduce in this course. The Stage 7d-specific changes — engine_handle field, with_engine_handle() builder, the commit body restructure, the integration test using add_ons_handle.beacon_engine_handle — should match closely. Doc comments and exact wording can vary.

Return:

git checkout main

Common questions

Q: What's add_ons_handle and why is the engine handle in there? add_ons_handle is Reth's bundle of "extra capabilities" attached to a launched node — RPC servers, engine API endpoints, payload builder hooks. The beacon engine handle is one of these because the engine API is what external CL clients (Lighthouse, Prysm) would use over JSON-RPC. We're taking the in-process shortcut by pulling the handle directly, but the same handle backs the network-facing API.

Q: Why does ForkchoiceState have three fields (head/safe/finalized) when we set them all to the same value? Because the Engine API is designed for chains with separate finalization layers. In Ethereum mainnet, the head can advance on every slot (12 seconds), but a block is "safe" only after 32 slots (a Casper checkpoint), and "finalized" only after 64+ slots. Our v0 single-validator chain has no such distinction — every commit is final. Setting all three to the same hash is the v0 simplification; multi-validator OpenHL would distinguish them.

Q: What does the engine actually do when it gets ForkchoiceUpdated with no matching newPayload? It responds with PayloadStatusEnum::Syncing and internally starts trying to sync the block from peers. In our isolated dev node, there are no peers, so the sync request goes nowhere. The engine just sits in a "waiting for block" state for that hash. This is fine — we never actually need the engine to advance its canonical chain for Lesson 14's purpose. Future course material that introduces real block bodies via newPayload would close this gap.

Q: Can I send ForkchoiceUpdated asynchronously and return immediately, instead of awaiting? You could — tokio::spawn(handle.fork_choice_updated(...)) would fire-and-forget. But the await is fast (sub-millisecond for SYNCING) and gives you the option to log the response. The async-spawn approach also makes test ordering harder (does the engine see the update before the test exits?). Awaiting is the safer default.

Next lesson (Lesson 15 — capstone)

You now have a complete consensus↔EVM bridge. All four ConsensusBridge methods reach real Reth code paths. Lesson 15 is the capstone: a one-page recap showing the full system, the things you skipped that production needs (block bodies via newPayload, real Codec impls instead of stubs, gossip codecs, persistent WAL), and the natural next courses to take. No new code — just a victory lap and a roadmap.

Summary (3 lines)

  • bridge.commit(block_hash) calls Reth's Engine API forkchoice; finalises the block; state persisted.
  • Single-validator: head = finalised = safe. End-to-end test: 3 blocks + restart + still in Reth.
  • Loop closed: Malachite → bridge methods → Reth → disk. Integration verified. Capstone next.