Lesson 14 — commit drives Reth's Engine API forkchoice
Question
Make LiveRethEvmBridge::commit record the block locally, then send a best-effort ForkchoiceUpdated to Reth's in-process Engine API through an optional ConsensusEngineHandle.
Principle (minimum model)
- In-process Engine API.
ConsensusEngineHandle<EthEngineTypes>::fork_choice_updated(state, None)sends a message straight to Reth's engine, the same handler that servesengine_forkchoiceUpdatedover JSON-RPC. We take it fromnode.add_ons_handle.beacon_engine_handle. - Optional handle.
engine_handle: Option<...>starts asNone;with_engine_handle(h)installs it. Without a handle,commitbehaves exactly as in Lesson 13. - Local first.
commitlooks up the header inpending(unknown hash →Rejected), inserts it intochainand setsheadbefore touching Reth. - Engine second, best-effort. If a handle is installed,
commitsendsForkchoiceState { head, safe, finalized }all set to the new hash and discards the reply withlet _ =. - Reth answers
SYNCING. We never send the block itself vianewPayload, so Reth doesn't know the hash and its canonical head does not move. This lesson proves the wire is connected, not that Reth's chain advances. - Test.
commit_sends_forkchoice_to_engine_when_handle_installedlaunches a node with plain.launch(), installs the handle, builds on genesis, assertscommitreturnsOk, and asserts an unknown hash is stillRejected.
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: HashMapis 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<ConsensusEngineHandle>for test ergonomics — without optionality, every unit test would need to bootstrap a real node just to get a non-test engine handle. WithOption, tests passNonefor the local path andSome(handle)for the integration path. Type-level optionality avoids forcing infrastructure into every test.- Engine response is intentionally discarded —
SYNCINGis the expected response right now because no matchingengine_newPayloadwas sent first. TreatingSYNCINGas 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
ForkchoiceStatecollapse — on mainnet, safe and finalized trail the head (finalization takes about two epochs, 64 slots). 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_handleis the in-process Engine API — the same handle that backs the network-facing JSON-RPCengine_*methods that external CL clients (Lighthouse, Prysm) would use. We're taking the in-process shortcut, but the surface is identical.- Three of the four
ConsensusBridgemethods now call into Reth —build_payload(provider + chain spec),validate_payload(EthBeaconConsensus) andcommit(engine handle).payload_readystill reads the bridge's ownpendingmap by design.
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, three of the four ConsensusBridge methods now call into Reth:
| Method | What it does | What real Reth code runs |
|---|---|---|
build_payload | Build a child block | HeaderProvider::sealed_header_by_hash, ChainSpec::next_block_base_fee |
payload_ready | Fetch the built block | (local — bridge's pending map) |
validate_payload | Check the block | EthBeaconConsensus::validate_header_against_parent |
commit | Make the block canonical | ConsensusEngineHandle::fork_choice_updated |
Engine returns SYNCING for now — and that's expected at this stage. We're not sending matching newPayload calls (that needs real execution payloads, 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>>onLiveRethEvmBridge. - New builder method
with_engine_handle()(#[must_use]) and introspectionhas_engine_handle(). commit()now does two things: (1) local bookkeeping (unchanged from Lesson 13), then (2) if an engine handle is installed, fire aForkchoiceUpdatedto Reth's in-process Engine API and discard the response.- New integration test that bootstraps
EthereumNode, installsadd_ons_handle.beacon_engine_handleon the bridge, and asserts thatcommitreturnsOkwith the handle installed and still rejects an unknown hash. (It doesn't inspect Reth's reply.)
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 36 tests workspace-wide (37 in the reference). The bridge knows the canonical chain; Reth doesn't.
Plan
Six things:
- Add 2 workspace deps:
reth-ethereum-engine-primitives(forEthEngineTypes) andalloy-rpc-types-engine(forForkchoiceState). - Update
crates/evm/Cargo.toml— add 3 new production deps (the 2 above plusreth-engine-primitiveswhich gives usConsensusEngineHandle). - Update imports + struct in
live_node.rs— new fieldengine_handle: Option<ConsensusEngineHandle<EthEngineTypes>>. - Add the builder methods —
with_engine_handle()consumes self and installs the handle;has_engine_handle()is aconst fnaccessor. - Rewrite
commit()— local bookkeeping first (unchanged), then best-effortForkchoiceUpdatedif an engine handle is installed. - Add the integration test — bootstraps
EthereumNode, pullsadd_ons_handle.beacon_engine_handle, plumbs it throughwith_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 don't propagate it (at this stage the reply is simply discarded) — 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; its reply (SYNCING, INVALID, or a closed-channel error) is discarded with let _ = and 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 installs a handle and asserts that commit still returns Ok (it doesn't inspect Reth's reply).
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— providesEthEngineTypes, the type bundle that says "Ethereum mainnet's engine surface" (vs. Optimism, custom L2s). OurConsensusEngineHandle<EthEngineTypes>is parameterized over it.alloy-rpc-types-engine— providesForkchoiceState { head_block_hash, safe_block_hash, finalized_block_hash }, the first parameter of everyengine_forkchoiceUpdatedversion. 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; it is where ConsensusEngineHandle lives. 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. Themut selfparameter 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 isself(consuming), not&mut self, so the patternlet bridge = ...; bridge.with_engine_handle(h);will movebridgeout 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()— aconst fnaccessor. Useful for tests and assertions ("did the wiring actually take effect?").constbecause checkingOption::is_some()doesn't require any runtime computation.new()initialization — the only change isengine_handle: None. Callers who want the handle useLiveRethEvmBridge::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:
-
Local bookkeeping — same shape as Lesson 13. Lookup pending header by hash, insert into
chain, updatehead. If header is missing →BridgeError::Rejected. The header binding is nowlet _headerbecause we don't use it later in this function; the binding exists for clarity and future telemetry. -
Best-effort engine notification — only when
engine_handle.is_some(). Build theForkchoiceStatewith 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). -
The
let _ = ...awaitis intentional — we discard the engine's response. Engine returns:VALID— once we sendengine_newPayloadfirst with the matching block body, this is the happy case.SYNCING— what we get right now, because we haven't sentnewPayload. 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());
// Plain launch(), not launch_with_debug_capabilities(): in --dev mode
// the debug launcher also starts Reth's LocalMiner, which sends its
// own forkchoice updates every second.
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:
with_types::<EthereumNode>()+with_components(...)+with_add_ons(EthereumAddOns::default())+.launch()— the explicit builder path with a plain launch. Lessons 11–13 usedlaunch_with_debug_capabilities(), which in--devmode also spawns Reth'sLocalMiner; that miner sends its own forkchoice updates to the same engine every second. A plainlaunch()keeps our bridge the only one driving forkchoice. (The explicitwith_componentsform is also where the reference later plugs in its custom executor.)handle.node.add_ons_handle.beacon_engine_handle.clone()— the engine handle lives inside add_ons. It wraps a channel sender to the engine; cloning is cheap..with_engine_handle(engine_handle)— our new builder method. Without this,commitdoes only local bookkeeping. With this,commitalso fires forkchoice.assert!(bridge.has_engine_handle())— the wiring guard. Ifwith_engine_handle()had a bug, this would catch it before the rest of the test runs.commit(block.hash).await.expect("commit failed")— the main assertion. Note we don't check what the engine returned — only thatcommitreturnsOk(()). The engine's SYNCING response is discarded insidecommitper Step 5.- 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 37 tests workspace-wide (Lesson 13's 36 + the new test). The reference commit reports 83: by then it also contains the Stage 8–9 CLOB, funding and precompile crates and tests.
Common errors and fixes:
error[E0412]: cannot find typeEthEngineTypesin this scope(orConsensusEngineHandle) — a missing import or a missing crate incrates/evm/Cargo.toml. Re-check Steps 2 and 3.- **
error: cannot find structEthereumAddOnsin modulereth_node_ethereum::node** — version drift betweenreth-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. WhenEthereumNodedrops at the end of the test, each actor waits on itsJoinHandle; if any oneshot is still pending or a socket isn't released, the runtime stalls. Verify that the test ends with proper cleanup — explicitdrop(handle);orhandle.node.task_executor.graceful_shutdown_with_timeout(...)where appropriate.- Side note: dropping
.awaitfromlet _ = handle.fork_choice_updated(state, None).awaitdoesn't cause a hang — it causes a silent skip (rustc stays quiet because oflet _ =; clippy'slet_underscore_futurelint flags it; the future is dropped on the spot and the engine notification never runs). A missing.awaitproduces 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.
- Side note: dropping
assert!(bridge.has_engine_handle())fails —with_engine_handleis#[must_use]but you didn't bind the return:let bridge = ...new(...); bridge.with_engine_handle(h);. Must belet bridge = ...new(...).with_engine_handle(h);.- Commit returns
Okbut the test for unknown hash also returnsOk(no rejection) — your commit logic is reaching the engine path before the local lookup. Re-check Step 5 — the?propagatesBridgeError::Rejectedand exits before the engine block.
Design reflection
Three load-bearing decisions encoded here:
-
Local state first, engine second. The bridge's
chain: HashMapis 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. -
Option<ConsensusEngineHandle>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 passNoneand exercise the local path; integration tests passSome(handle)and exercise both. Type-level optionality is how you avoid forcing infrastructure into every test. -
The engine response is intentionally discarded.
SYNCINGis the expected response right now (we haven't sentnewPayload). 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 contains additional code (CLOB integration from Stage 8 and the custom executor from Stage 9, which is why its test uses OpenHlExecutorBuilder) 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), while "safe" and "finalized" trail behind; finalization takes about two epochs (64 slots, ~13 minutes). (Reth's own dev LocalMiner imitates this by pointing safe 32 blocks and finalized 64 blocks behind the head.) 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 can't make an unknown block canonical, so it answers with a SYNCING payload status and treats the hash as something to download. In our isolated dev node there are no peers, so nothing arrives and Reth's head stays at genesis. 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 a single in-process round trip 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 consensus↔EVM bridge in which three of the four ConsensusBridge methods call into Reth (payload_ready stays local). 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)
commitrecords the block locally first, then (if aConsensusEngineHandleis installed) sendsfork_choice_updatedwith head = safe = finalized and discards the reply.- Reth answers
SYNCINGbecause nonewPayloadwas sent; its head doesn't move. The lesson proves the wire, not chain advancement. - Test: plain
.launch()(noLocalMiner), handle fromadd_ons_handle.beacon_engine_handle,commit→Ok, unknown hash →Rejected. Capstone next.