Lesson 4 — InMemoryEvmBridge — first impl of the trait
Question
Implement ConsensusBridge for the first time: InMemoryEvmBridge, a fake EVM in openhl-evm that synthesizes blocks in a Mutex<State> and passes 5 tokio tests.
Principle (minimum model)
- Struct.
InMemoryEvmBridge { state: Mutex<State> }, whereStateholdsnext_payload_id: u64,pending: HashMap<u64, ExecutedBlock>,chain: HashMap<[u8; 32], ExecutedBlock>andhead: Option<BlockHash>. build_payload. Allocates the next id, numbers the blockparent.number + 1(or 1 if the parent is unknown), synthesizes a hash from(id, number), and stores the block inpending.payload_ready. Clones the block out ofpending; an unknown id isBridgeError::Rejected.validate_payload. AlwaysOk(PayloadStatus::Valid)— a test double, not a validator.commit. Finds the block inpendingby hash, copies it intochain, setshead; an unknown hash isRejected.- Why a test double first. It proves the trait is implementable and testable without compiling Reth (that arrives in Lesson 5).
- Tests. 5
#[tokio::test]s: build→ready round-trip, always-valid, commit advances head, numbers increase on a committed parent, unknown-hash commit errors. Answer key: openhl commit3b43586. - Same shape later.
RethEvmBridge(Lesson 5) andLiveRethEvmBridge(Lessons 12–14) keep theMutex<State>withpending/chain/head.
Worked example + steps
Lesson 4 — InMemoryEvmBridge — first impl of the trait
Goal
Concepts you'll grasp in this lesson:
- Test-double-first impl strategy — why we write a fake EVM before touching Reth. The trait is exercised end-to-end without compiling Reth's roughly thousand-crate dependency graph, so these tests build and run in seconds.
Mutex<State>for interior mutability — wrapping a privateStatestruct in a singleMutexto satisfy theSend + Syncbound from Lesson 3. Locking once per method is fine for test code and carries over structurally toLiveRethEvmBridgein Lesson 12 onward.pendingvschainmap split — speculative builds and canonical commits are different lifecycles. Encoding the split here forces every later impl to respect the same data flow (build is speculative; commit is final).async_traitimpl ergonomics — why theimplblock needs the same#[async_trait]attribute as the trait (both sides must agree on the boxed-future signatures), so the impl can be used asdyn ConsensusBridge.
Verification:
cargo test -p openhl-evm
…passes 5 tests covering build → ready → commit flows of the in-memory bridge. You have the first concrete implementation of ConsensusBridge from Lesson 3 — a test double that pretends to be an EVM, stores fake blocks, and lets you exercise the trait without spinning up Reth.
Specific changes:
- 3 dependencies + 1 dev-dependency added to
crates/evm/Cargo.toml:openhl-consensus,openhl-types,async-trait, andtokio(dev). crates/evm/src/in_memory.rs— new file withInMemoryEvmBridgestruct, privateState,Mutex<State>, the 4-methodimpl ConsensusBridge, ahex_shorthelper, and 5 unit tests.crates/evm/src/lib.rs— wirespub mod in_memory; pub use in_memory::InMemoryEvmBridge;.
Recap
After Lesson 3:
crates/types/src/lib.rs — 5 types + Display + 4 tests passing
crates/consensus/src/bridge.rs — ConsensusBridge trait + BridgeError
crates/consensus/src/lib.rs — pub mod bridge;
crates/evm/src/lib.rs — //! EVM execution layer doc only, no code
crates/evm/Cargo.toml — empty [dependencies]
cargo check --workspace passes; cargo test -p openhl-evm runs 0 tests.
Plan
You'll do four things:
- Add 3 dependencies + 1 dev-dependency to
crates/evm/Cargo.toml:openhl-consensus(for the trait and error type),openhl-types(for the contract types),async-trait(for the#[async_trait]macro), andtokioas dev-dep (so test functions can be#[tokio::test]). - Create
crates/evm/src/in_memory.rswith: theInMemoryEvmBridgestruct, a privateStatestruct held in aMutex, animpl ConsensusBridge for InMemoryEvmBridgeblock providing all 4 async methods, ahex_shorthelper, and a#[cfg(test)] mod testswith 5 tests. - Wire
in_memoryinto the crate viapub mod in_memory; pub use in_memory::InMemoryEvmBridge;incrates/evm/src/lib.rs. - Run
cargo test -p openhl-evmand watch 5 tests pass.
This is the first time you write a Rust impl. The pattern you encode here repeats: RethEvmBridge in Lesson 5 uses the same skeleton, and LiveRethEvmBridge in Lesson 12 onward does too. The state-management pattern (Mutex<State> with pending vs chain maps) propagates to those impls too.
Walk-through
Step 1: Add dependencies to crates/evm/Cargo.toml
Open crates/evm/Cargo.toml. Replace the empty [dependencies] section:
[dependencies]
openhl-consensus = { workspace = true }
openhl-types = { workspace = true }
async-trait = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
The four:
openhl-consensus— to referencebridge::{ConsensusBridge, BridgeError}from the implopenhl-types— to useBlockHash,PayloadId, etc.async-trait—#[async_trait]attribute for the impl blocktokio(dev) —#[tokio::test]for async test functions
cargo check -p openhl-evm should still pass — declared deps without using them.
Step 2: Create the file
touch crates/evm/src/in_memory.rs
Add the module-level doc:
//! In-memory `ConsensusBridge` — a test double for the EL side.
//!
//! Useful for unit-testing the consensus crate without spinning up Reth. The
//! real Reth-backed implementation lives in `engine.rs` (lands in Lesson 5).
Step 3: Add the imports + structs
use async_trait::async_trait;
use openhl_consensus::bridge::{BridgeError, ConsensusBridge};
use openhl_types::{BlockHash, ExecutedBlock, PayloadAttrs, PayloadId, PayloadStatus};
use std::collections::HashMap;
use std::fmt::Write as _;
use std::sync::Mutex;
#[derive(Debug, Default)]
pub struct InMemoryEvmBridge {
state: Mutex<State>,
}
#[derive(Debug, Default)]
struct State {
next_payload_id: u64,
pending: HashMap<u64, ExecutedBlock>,
chain: HashMap<[u8; 32], ExecutedBlock>,
head: Option<BlockHash>,
}
impl InMemoryEvmBridge {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
Walk through what each field is:
InMemoryEvmBridge — the public struct. Single field: state: Mutex<State>. The mutex makes the type Send + Sync (it can be shared between threads safely), which the trait requires. Everything mutable lives inside the mutex.
State (private) — three pieces of bookkeeping:
next_payload_id: u64— monotonic counter. Everybuild_payloadcall increments this and uses the previous value as the returnedPayloadId.pending: HashMap<u64, ExecutedBlock>— blocks thatbuild_payloadproduced butcommithasn't accepted yet. Keyed byPayloadId.chain: HashMap<[u8; 32], ExecutedBlock>— committed blocks. Keyed by raw 32-byte hash (notBlockHashnewtype — saves a.0accessor when looking up).head: Option<BlockHash>— the most recently committed hash.Noneif nothing committed yet.
The split between pending and chain matters: by the time commit(hash) is called, the block is already in pending (from a prior build_payload). commit copies it from pending into chain and updates head (it leaves the pending entry in place — see Step 7). This mirrors how a real EL maintains both an in-flight payload buffer and a finalized chain.
Walking the 4 fields of State (next_payload_id / pending / chain / head) through the build_payload → payload_ready → commit lifecycle in one picture makes it obvious why the same shape gets reused in the real RethEvmBridge (Lesson 5) and LiveRethEvmBridge (Lesson 12 onward):
【 Block lifecycle inside InMemoryEvmBridge 】
1. build_payload(parent, attrs)
│
▼
┌────────────────────────────────────────────────────────────┐
│ chain.get(&parent.0) → look up the parent's number │
│ next_payload_id += 1; return that id as PayloadId │
│ Synthesize a new ExecutedBlock (number = parent + 1, …) │
│ pending.insert(PayloadId, ExecutedBlock) ◄── speculative │
└────────────────────────────────────────────────────────────┘
│
▼ (return just the PayloadId to CL)
2. payload_ready(id)
│
▼
┌────────────────────────────────────────────────────────────┐
│ pending.get(&id).cloned() ◄── lend the unconfirmed block │
│ (keep a copy in pending — it hasn't been committed yet) │
└────────────────────────────────────────────────────────────┘
│
▼ (return the ExecutedBlock)
3. commit(hash) // CL calls this only after a 2/3+ quorum
│
▼
┌────────────────────────────────────────────────────────────┐
│ Find the block in pending by hash (the entry is left there) │
│ chain.insert(hash.0, ExecutedBlock) ◄── promote to canonical│
│ head = Some(hash) ◄── update the new head │
└────────────────────────────────────────────────────────────┘
│
▼ (return Ok(()); the block is now finalized)
The key thing the picture pins down is "pending = speculative (unconfirmed) / chain = finalized" — the two lifetimes are physically separated at the map level. build_payload optimistically piles up; only commit has the authority to promote a block from pending to chain. A real EL keeps a similar split between payloads being built and the canonical chain, and openhl's later bridges keep exactly these four fields (LiveRethEvmBridge stores pending: HashMap<u64, (B256, Header)>, chain: HashMap<B256, Header>). That is why swapping in the real bridge in Lesson 5 / Lesson 12 onward doesn't change how data flows — only what the maps hold.
impl InMemoryEvmBridge::new — the constructor. #[must_use] is a hint to clippy: if a caller writes InMemoryEvmBridge::new(); without binding, that's almost certainly a bug.
Step 4: Implement ConsensusBridge — build_payload
#[async_trait]
impl ConsensusBridge for InMemoryEvmBridge {
async fn build_payload(
&self,
parent: BlockHash,
_attrs: PayloadAttrs,
) -> Result<PayloadId, BridgeError> {
let mut s = self.state.lock().expect("state mutex poisoned");
let id = s.next_payload_id;
s.next_payload_id += 1;
let parent_number = s.chain.get(&parent.0).map_or(0, |b| b.number);
let number = parent_number + 1;
let mut hash_bytes = [0u8; 32];
hash_bytes[..8].copy_from_slice(&id.to_le_bytes());
hash_bytes[8..16].copy_from_slice(&number.to_le_bytes());
let block = ExecutedBlock {
hash: BlockHash(hash_bytes),
parent_hash: parent,
number,
state_root: [0u8; 32],
};
s.pending.insert(id, block);
Ok(PayloadId(id))
}
// ...continued below
Step by step:
self.state.lock().expect("state mutex poisoned")— acquire the mutex. The.expectcovers thePoisonErrorcase: a previous holder panicked while holding the lock, leaving it in an indeterminate state. The right move is to panic ourselves (a poisoned state machine is unsafe to continue from). The string identifies the lock for debug output.id = s.next_payload_id; s.next_payload_id += 1;— allocate a fresh payload ID. Monotonic, no reuse. This is the equivalent of a sequence in a database.s.chain.get(&parent.0).map_or(0, |b| b.number)— find the parent block's number. If we've never committed that parent (e.g., a test genesis hash), default to 0 (so the child is block 1). The.0unpacks theBlockHashnewtype to get the inner[u8; 32].- Synthesize a hash from
(id, number)— first 8 bytes fromid.to_le_bytes(), next 8 bytes fromnumber.to_le_bytes(), rest zero. Why this and not real hashing? Because we're a test double; the hash just needs to be unique per build.(id, number)is unique by construction, so the synthesized hash is too. - Build the
ExecutedBlockand stash it inpending. The block has parent_hash, number, hash, and a zero state_root (we didn't run an EVM). - Return
Ok(PayloadId(id)).
Step 5: Implement payload_ready
Continuing the same impl block:
async fn payload_ready(&self, id: PayloadId) -> Result<ExecutedBlock, BridgeError> {
let s = self.state.lock().expect("state mutex poisoned");
let n = id.0;
s.pending
.get(&n)
.cloned()
.ok_or_else(|| BridgeError::Rejected(format!("unknown payload id {n}")))
}
Look up the block in pending by ID. If found, clone (the caller wants ownership; pending keeps a copy in case the block isn't committed yet and the caller asks again). If not found, return a Rejected error with a descriptive message.
Note: payload_ready is read-only. The let s = self.state.lock() doesn't need mut because we only call .get(), no insert or remove.
Step 6: Implement validate_payload
async fn validate_payload(
&self,
_block: &ExecutedBlock,
) -> Result<PayloadStatus, BridgeError> {
Ok(PayloadStatus::Valid)
}
The simplest one in this impl. We're a test double — we just assert any block is valid. Real validation in Lesson 13 will run EthBeaconConsensus::validate_header_against_parent against the actual parent. For now, returning Valid makes consensus tests work.
Important: _block (leading underscore). This tells the compiler "I'm intentionally not using this arg." Without the underscore, you'd get an unused_variables warning. With it, the warning is suppressed.
Step 7: Implement commit
async fn commit(&self, block_hash: BlockHash) -> Result<(), BridgeError> {
let mut s = self.state.lock().expect("state mutex poisoned");
let block = s
.pending
.values()
.find(|b| b.hash == block_hash)
.cloned()
.ok_or_else(|| {
let hex = hex_short(&block_hash.0);
BridgeError::Rejected(format!("commit for unknown hash {hex}"))
})?;
s.chain.insert(block_hash.0, block);
s.head = Some(block_hash);
Ok(())
}
}
The flow:
- Lock state for writing.
- Search
pending.values()for a block matchingblock_hash. Note we iterate by value becausependingis keyed byPayloadId, not block hash — we need to scan to find a block by hash. (In a real implementation with O(1) hash→block lookup, you'd have a second index. For a test double, O(n) scan is fine.) - If not found, return a
Rejectederror with a short hex-formatted hash. - If found, insert into
chain(keyed by hash bytes) and updatehead.
Note we don't remove from pending — the block lives in both maps after commit. Real impls might pending.remove(&id), but for tests it doesn't matter.
The hex_short helper is the next file section:
📍 Placement note.
hex_shortlives outside theimpl ConsensusBridge for InMemoryEvmBridge { ... }block, as a standalone private function at the end of the file (it takes no&selfand depends on no struct state — it's a plain byte → string utility). Defining it inside theimplwould make it look like a method on the trait and mislead readers into thinkingConsensusBridgerequires it.
fn hex_short(bytes: &[u8; 32]) -> String {
let mut s = String::with_capacity(18);
s.push_str("0x");
for b in &bytes[..8] {
write!(&mut s, "{b:02x}").expect("write to String never fails");
}
s
}
A 0x-prefixed hex string of the first 8 bytes — short enough to fit in a log line. The write!(&mut s, ...) invocation needs use std::fmt::Write as _; at the top of the file (we already added it in Step 3). The as _ rename pulls in the trait for its methods only without polluting the namespace with the name Write.
Step 8: Wire in_memory into the crate
Open crates/evm/src/lib.rs. Currently:
//! EVM execution layer — Reth integration.
Replace with:
//! EVM execution layer — Reth integration.
pub mod in_memory;
pub use in_memory::InMemoryEvmBridge;
pub mod in_memory; exposes the module. pub use in_memory::InMemoryEvmBridge; re-exports the struct at the crate root, so downstream crates can write use openhl_evm::InMemoryEvmBridge; instead of use openhl_evm::in_memory::InMemoryEvmBridge;.
Step 9: Add unit tests
At the bottom of crates/evm/src/in_memory.rs, append:
#[cfg(test)]
mod tests {
use super::*;
fn attrs() -> PayloadAttrs {
PayloadAttrs {
timestamp: 0,
fee_recipient: [0u8; 20],
prev_randao: [0u8; 32],
}
}
#[tokio::test]
async fn build_then_ready_returns_same_block() {
let bridge = InMemoryEvmBridge::new();
let parent = BlockHash([1u8; 32]);
let id = bridge.build_payload(parent, attrs()).await.unwrap();
let block = bridge.payload_ready(id).await.unwrap();
assert_eq!(block.parent_hash, parent);
assert_eq!(block.number, 1);
}
#[tokio::test]
async fn validate_returns_valid() {
let bridge = InMemoryEvmBridge::new();
let block = ExecutedBlock {
hash: BlockHash([2u8; 32]),
parent_hash: BlockHash([1u8; 32]),
number: 1,
state_root: [0u8; 32],
};
let status = bridge.validate_payload(&block).await.unwrap();
assert_eq!(status, PayloadStatus::Valid);
}
#[tokio::test]
async fn commit_advances_head_and_records_block() {
let bridge = InMemoryEvmBridge::new();
let parent = BlockHash([1u8; 32]);
let id = bridge.build_payload(parent, attrs()).await.unwrap();
let block = bridge.payload_ready(id).await.unwrap();
bridge.commit(block.hash).await.unwrap();
let s = bridge.state.lock().unwrap();
assert_eq!(s.head, Some(block.hash));
assert!(s.chain.contains_key(&block.hash.0));
}
#[tokio::test]
async fn build_on_committed_parent_increments_number() {
let bridge = InMemoryEvmBridge::new();
let genesis = BlockHash([1u8; 32]);
let id1 = bridge.build_payload(genesis, attrs()).await.unwrap();
let block1 = bridge.payload_ready(id1).await.unwrap();
bridge.commit(block1.hash).await.unwrap();
let id2 = bridge.build_payload(block1.hash, attrs()).await.unwrap();
let block2 = bridge.payload_ready(id2).await.unwrap();
assert_eq!(block2.number, 2);
assert_eq!(block2.parent_hash, block1.hash);
}
#[tokio::test]
async fn commit_unknown_hash_errors() {
let bridge = InMemoryEvmBridge::new();
let err = bridge.commit(BlockHash([9u8; 32])).await.unwrap_err();
assert!(matches!(err, BridgeError::Rejected(_)));
}
}
What each test covers:
| Test | What it proves |
|---|---|
build_then_ready_returns_same_block | build_payload + payload_ready round-trip works. Number = 1 on top of fake genesis. |
validate_returns_valid | validate_payload always returns Valid (test double behavior). |
commit_advances_head_and_records_block | After commit, head points to the new block AND chain map contains it. |
build_on_committed_parent_increments_number | Number monotonicity: build on parent block 1 produces block 2. |
commit_unknown_hash_errors | Commit for a hash that isn't in pending returns BridgeError::Rejected. |
#[tokio::test] is the async-aware version of #[test]. It sets up a tokio runtime for the test and awaits the async body.
Test
cargo test -p openhl-evm
Expected:
running 5 tests
test in_memory::tests::build_on_committed_parent_increments_number ... ok
test in_memory::tests::build_then_ready_returns_same_block ... ok
test in_memory::tests::commit_advances_head_and_records_block ... ok
test in_memory::tests::commit_unknown_hash_errors ... ok
test in_memory::tests::validate_returns_valid ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Common errors and fixes:
#[derive(Default)]fails onStateorInMemoryEvmBridge. Every field must implementDefault.u64,HashMap,OptionandMutex<State>all do, so the usual cause is a field type you changed — or a missing#[derive(Default)]onStateitself.unused import: std::fmt::Write(a rustc warning) — you haven't writtenhex_shortyet; the import is only used by itswrite!call.error[E0599]: cannot write into &mut String— the opposite:hex_shortexists but theuse std::fmt::Write as _;line is missing or inside another function. Put it at the top of the file.#[tokio::test]not found —tokioisn't in[dev-dependencies]. Re-check Step 1.- A test asserts
block.number == 1but you get0. You probably forgot the+ 1inlet number = parent_number + 1;.
Design reflection
Two load-bearing decisions encoded:
-
State lives behind a
Mutex<State>. This is what makesInMemoryEvmBridgethread-safe — and thereforeSend + Sync. The alternative (lock-free, atomic-only mutation) would be far more complex for a test double. Locks are fine when the contention is low (test code) or the critical sections are short (real code). The pattern propagates toLiveRethEvmBridgein Lesson 12 onward, which uses the sameMutex<State>shape. -
pendingandchainare separate maps. A real EL has the same split — payloads currently being built vs blocks committed to canonical chain. By encoding this in the test double, the shape of the data flow carries forward to production impls. If we used one combined map, we'd be implying "build = commit" — which is wrong. Build is speculative; commit is final.
Answer key
cd ~/code/openhl-reference
git checkout 3b43586
diff -u ~/code/my-openhl/crates/evm/src/in_memory.rs ./crates/evm/src/in_memory.rs
diff -u ~/code/my-openhl/crates/evm/Cargo.toml ./crates/evm/Cargo.toml
diff -u ~/code/my-openhl/crates/evm/src/lib.rs ./crates/evm/src/lib.rs
Variations are OK in test order, doc-comment wording, and exact debug-message format. The reference's module doc says the Reth bridge "lands in Module 1 L10" (an older numbering — it's this course's Lesson 5), and its lib.rs has no doc comment. The struct shape, the Mutex<State> pattern, the 4 method impls and the 5 tests should match.
Return:
git checkout main
Common questions
Q: My commit_advances_head_and_records_block test panics with "mutex poisoned".
Check the first panic first.
In this course, each test creates its own InMemoryEvmBridge::new(), so tests do not share one Mutex<State>. The common cause is a panic earlier in the same test while holding the lock, followed by another state.lock() in that test.
Triage steps:
- Find the first
thread 'tests::...' panicked at ...line at the top ofcargo testoutput. - Fix that root panic and rerun.
- Use
cargo test -p openhl-evm -- --test-threads=1only when you need a parallelism sanity check.
Q: Should pending use HashMap<PayloadId, _> instead of HashMap<u64, _>?
Either works. The openhl convention is to use the inner type (u64) at the storage layer to avoid wrapping/unwrapping inside lookups. The public API still uses PayloadId. The trade-off: with HashMap<PayloadId, _>, you get type safety at the price of .0 accessors on every key. With HashMap<u64, _>, you give up some type safety at the storage layer but avoid the noise. Personal preference; we picked u64.
Q: Why is hex_short only first 8 bytes? Why not full?
Logs need to be short. A full 32-byte hex is 64 chars — eats the log line. The first 8 bytes (16 hex chars + "0x") is enough to identify a block in dev/test scenarios. Production logs would use the full hash; the helper would change accordingly.
Q: Tests pass but I get clippy warnings about unused_imports.
Make sure each import is actually used somewhere in your code. The boilerplate I gave imports std::fmt::Write as _ — that's only used inside hex_short. If you didn't write hex_short yet, the import is unused. Add the helper or remove the import.
Next lesson (Lesson 5)
You have a working ConsensusBridge impl, but it doesn't use Reth at all. Lesson 5 writes the next impl: RethEvmBridge. Same trait, but the ExecutedBlock is now built from a real alloy_consensus::Header (not synthesized), and the BlockHash comes from a real B256 computed by alloy's Header::hash_slow (keccak over the RLP-encoded header). Still in-memory state (no live Reth provider), but the types are real. This is the bridge between toy types (Lesson 4) and live integration (Lesson 11 onward).
Summary (3 lines)
InMemoryEvmBridge=Mutex<State>(next_payload_id,pending,chain,head) + the 4 trait methods. No real EVM; hashes synthesized from(id, number).pending= speculative builds,chain= committed blocks; onlycommitpromotes. 5 tokio tests pass.- The same shape carries into the Reth-backed bridges. Next: RethEvmBridge with real alloy types.