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: 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<EngineHandle>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 — 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_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.- All four
ConsensusBridgemethods now hit real Reth — this lesson closes the loop.build_payload/payload_ready/validate_payload/commitall 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:
| 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 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>>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 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:
- 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 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— 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 canonical wire-format payload for anengine_forkchoiceUpdatedV4call. 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. 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());
// 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:
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 exposeadd_ons_handle. To pull out the beacon engine handle, we need the explicit form.handle.node.add_ons_handle.beacon_engine_handle.clone()— the engine handle lives inside add_ons. It's anArc-based handle internally; 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 38 tests workspace-wide (Lesson 13's 37 + the new test).
Common errors and fixes:
- **
error[E0282]: type annotations needed forOption<ConsensusEngineHandle<_>>** — theengine_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 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);or anode.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 (warning: unused implementor of 'Future'fires; 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<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 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 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.