Lesson 10 — Course milestone — the full stack in a real Reth node
Question
Course milestone: every precompile working in a real Reth node + the Bridge contract using them to update user balances. The full read + write + fill-flow loop, end-to-end.
Principle (minimum model)
- Stack lit up. OpenHlEvmFactory + 4 precompiles (read_best_bid + place_order + read_fills + a few helpers) + CLOB matching engine + Bridge contract.
- End-to-end test. (1) User deposits to Bridge. (2) Bridge calls
clob_place_order(write precompile). (3) Matching engine processes + emits a fill. (4) Bridge callsclob_read_fills(read precompile). (5) Bridge updates user balance. - Integration through chainspec. Every precompile + the Bridge contract + the matching engine all wired through one chainspec entry. Single-binary deploy.
- Performance. Round-trip Solidity → precompile → CLOB → fill back to Solidity should be < 5 ms. Production target.
- Composition guarantee. Each piece is independently tested; this lesson asserts they compose. Type-level guarantee: every precompile signature matches what the Bridge contract expects.
- Failure modes. Lock contention (mitigated by per-market locks). Dropped fills (mitigated by idempotent read). EVM crash mid-write (mitigated by the matching engine's atomic submit).
- Production parallel. Hyperliquid HyperEVM uses this exact pattern (different code, same shape).
Worked example + steps
Lesson 10 — Course milestone — the full stack in a real Reth node
Goal
Concepts you'll grasp in this lesson:
- Integration tests catch wiring bugs unit tests can't — unit tests build each piece in isolation, so a typo in
with_components(...executor(OpenHlExecutorBuilder))or a regression inEthereumAddOnsapplication would leave unit tests green while breaking production. One integration test = wiring assertion. pub(crate)is the right visibility for cross-module tests — wideningplace_ordertopubleaks the API;#[cfg(test) pub(crate)]adds ceremony for no benefit.pub(crate)says "inside this crate, anyone; outside, no one."- Inlined test calldata > DRY helper — hand-built
[u8; 128]with byte-position comments makes the ABI layout visible at the call site. For tests proving system-level correctness, every byte position should be a learnable artifact; helpers hide. - Canonical mix: one integration test + many unit tests — pieces have their own narrow tests; composition has one wide test. Failure localization comes from unit tests; wiring guarantees come from the integration test.
- The honest deferred: RPC roundtrip is Reth's responsibility, not openhl's — testing JSON-RPC → eth_call → revm dispatch would validate Reth, not openhl. The scope of "openhl plugs into Reth correctly" doesn't include "Reth's RPC server works."
Verification:
cargo test -p openhl-evm --release bridge_against_custom_evm
…passes a single new integration test, bridge_against_custom_evm_node_shares_clob_with_precompile.
Specific changes:
The test does everything Stages 9a-9c+ touched, all in one place:
- Bootstrap Reth with
OpenHlExecutorBuilder— the custom EVM with both CLOB precompiles registered. - Construct
LiveRethEvmBridgeagainst that node's provider — the bridge'snew()callsinstall_clobandinstall_fill_sink. - Bridge writes to book —
bridge.submit_order(Buy @ 200 qty 33). - Precompile sees it —
current_best_bid()returnsSome((Price(200), Qty(33))). - Precompile writes to book —
place_order(Sell @ 200 qty 33)via direct call (simulating EVM dispatch). - Bridge sees the fill —
bridge.pending_fill_count() == 1.
Full-stack integration topology: Modules 1-4 all sharing one Reth process
Lesson 10 is the first lesson where all four modules' plumbing connects a real Reth process to a real LiveRethEvmBridge via process-global statics:
┌──────────────────── one process (cargo test binary / production Reth) ─────────────────────┐
│ │
│ ╔════════════ Reth node (booted via NodeBuilder.launch()) ══════════════╗ │
│ ║ ║ │
│ ║ Executor / RPC server / mining / consensus ──┐ ║ │
│ ║ │ Custom EVM thread ║ │
│ ║ │ plugged in via ║ │
│ ║ ▼ OpenHlExecutorBuilder ║ │
│ ║ (Lessons 1 + 3 output) ║ │
│ ║ ┌─── OpenHlEvmFactory → Custom EVM (revm) ────────────────────────────┐ ║ │
│ ║ │ fork registry → openhl_precompiles_for(spec): │ ║ │
│ ║ │ 0x...0c1b → read_best_bid [Module 2: Lessons 2 + 5] │ ║ │
│ ║ │ 0x...0c1c → place_order [Module 3+4: Lessons 7+8+9] │ ║ │
│ ║ └──────────────────┬──────────────────────┬─────────────────────────────┘ ║ │
│ ║ │ CLOB_STATE.read() │ FILL_SINK.read() ║ │
│ ╚═════════════════════│══════════════════════│═════════════════════════════╝ │
│ ──────────────────────│──────────────────────│────────────────────────────────────────────── │
│ process-global statics (in-process, reachable lock-free as references) │
│ ┌──────────────────────────────────┐ ┌──────────────────────────────────────────────────┐ │
│ │ static CLOB_STATE │ │ static FILL_SINK │ │
│ │ RwLock<Option<Arc<Mutex<Book>>>>│ │ RwLock<Option<Arc<Mutex<Vec<Fill>>>>> │ │
│ │ ▲ │ │ ▲ │ │
│ │ │ bridge::new() │ │ │ bridge::new() │ │
│ │ │ install_clob(Arc::clone) │ │ │ install_fill_sink(Arc::clone) │ │
│ │ │ wired in Lesson 4 │ │ │ wired in Lesson 9 │ │
│ └───┼──────────────────────────────┘ └────┼──────────────────────────────────────────────┘ │
│ ────│──────────────────────────────────────│────────────────────────────────────────────────── │
│ ╔═══│════════════ LiveRethEvmBridge (an object in the same process) ════════════════════╗ │
│ ║ ▼ (holds the same Arc as the precompile) ▼ (holds the same Arc as the precompile) ║ │
│ ║ bridge.clob bridge.pending_fills ║ │
│ ║ : Arc<Mutex<Book>> : Arc<Mutex<Vec<Fill>>> ║ │
│ ║ ║ │
│ ║ bridge.submit_order(Order{…}) ─► self.clob.submit() → pushes fills into pending_fills ║ │
│ ║ bridge.build_payload() ─► self.pending_fills.drain() → attaches to next block ║ │
│ ║ ║ │
│ ║ provider = handle.node.provider (taken from the NodeBuilder-returned node handle) ║ │
│ ╚══════════════════════════════════════════════════════════════════════════════════════╝ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
The path the Lesson 10 integration test traces through this topology:
Phase A uninstall_{clob,fill_sink}() to clear globals
→ NodeBuilder.launch() boots the Reth process (the Reth node block above comes up)
→ LiveRethEvmBridge::new(handle.node.provider, ...) installs into both globals
→ Result: every Arc arrow in the diagram is wired up
Phase B bridge.submit_order(Buy@200 q33)
→ bridge.clob.submit() puts a bid on the Book
→ the precompile reads the same Arc via CLOB_STATE
→ current_best_bid() == Some((Price(200), Qty(33))) ◄ wiring proof #1
Phase C crate::precompiles::place_order(Sell@200 q33 calldata) — direct call
→ the Custom EVM's place_order body in the diagram is exercised
→ CLOB_STATE.read() → clob.lock().submit() crosses → SubmitResult.fills
→ FILL_SINK.read() → extend(fills) into the sink Arc
→ bridge.pending_fills holds the same Arc, sees the increment
→ bridge.pending_fill_count() == 1 ◄ wiring proof #2
Phase D uninstall_{fill_sink,clob}() → drop(handle) so the Reth process shuts down clean
Four observation points:
- Module 1 (Lessons 1+3):
OpenHlExecutorBuilderis genuinely plugged into Reth viaNodeBuilder, and the Custom EVM boots (= the EVM block inside the Reth node above) - Module 2 (Lessons 2+5):
read_best_bidreads live state, satisfying Phase B's wiring proof #1 - Module 3 (Lessons 7+8):
place_orderwrites live state, so the first half of Phase C (the Order lands on the Book) succeeds - Module 4 (Lesson 9): the resulting fills travel through FILL_SINK to the bridge, satisfying wiring proof #2
What Lesson 10 proves is that all four hold simultaneously — if any single wire were cut, either current_best_bid() or pending_fill_count() would fail. A typo in the NodeBuilder chain that breaks production while every unit test stays green is exactly the regression this single test rules out.
This is the course milestone. After Lesson 10, the architecture proven by 47 unit tests is also proven by 1 integration test — as the topology above shows, a real Reth node process, a real bridge object, both precompiles, both globals, and the matching engine all mesh inside a single in-process space, exercised end-to-end.
To make this work, one production-code change is needed: place_order must become pub(crate) so the integration test (which lives in live_node.rs, a sibling module) can call it directly.
Recap
After Lesson 9:
- The precompile module has
CLOB_STATE+FILL_SINK, bothOption<Arc<Mutex<T>>>globals. - The bridge installs into both globals during
new(). - Unit tests prove: read works (Lesson 6), write works (Lesson 8), fills route (Lesson 9).
- What hasn't been tested: the combination in a real Reth node. Unit tests bypass Reth's
NodeBuilder,EvmFactorydispatch,EthereumNode::components()plumbing.
Lesson 10 closes that gap with a single integration test.
Plan
Two edits across two files:
crates/evm/src/precompiles/mod.rs— changefn place_ordertopub(crate) fn place_order. The integration test will call it directly. One word added.crates/evm/src/live_node.rs— add thebridge_against_custom_evm_node_shares_clob_with_precompiletest inside the existing#[cfg(test)] mod testsblock. ~70 lines, mostly setup + 7 assertions.
No new production code beyond the visibility change. Lesson 10's value is in the proof, not in the new behavior.
(Answer: Unit tests can't observe wiring mistakes between the bridge and Reth's executor. Each unit test builds the precompile in isolation, or builds the bridge in isolation; none of them exercise the path where a NodeBuilder::launch() flow constructs an OpenHlEvmFactory instance and the bridge sees the same CLOB through the precompile registered in that EVM. A typo in the with_components(...executor(OpenHlExecutorBuilder)) chain — or a regression where EthereumAddOns stops being applied — would leave unit tests green but break the actual production path. Integration test = wiring assertion.)
Walk-through
Step 1: Make place_order pub(crate)
In crates/evm/src/precompiles/mod.rs, find the fn place_order line:
#[allow(clippy::unnecessary_wraps)]
fn place_order(input: &[u8], _gas_limit: u64, _reservoir: u64) -> PrecompileResult {
Change to:
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn place_order(input: &[u8], _gas_limit: u64, _reservoir: u64) -> PrecompileResult {
That's the change. pub(crate) = visible to the rest of the openhl-evm crate, but not to the world. Three reasons not to make it fully pub:
- The precompile is registered into the registry by
openhl_precompiles. Outside callers should invoke it viaPrecompile::execute(...)through the registry, not by name. Keeping itpub(crate)discourages bypass. - The function signature is REVM-specific (
PrecompileFn = fn(&[u8], u64, u64) -> PrecompileResult). Exposing it widely would couple downstream callers to REVM's calling convention. - The integration test lives in this crate, so
pub(crate)is exactly the visibility that test needs — no more.
read_best_bid stays private. No test outside the precompiles module calls it directly. Keep visibility minimal.
Step 2: Add the integration test
Open crates/evm/src/live_node.rs. Find the #[cfg(test)] mod tests block at the bottom of the file. Add this test at the end:
/// **Stage 9d**: bootstrap a Reth node WITH `OpenHlExecutorBuilder` (so its
/// EVM has our CLOB precompiles registered), construct a `LiveRethEvmBridge`
/// against that node's provider, submit an order via the bridge — verify
/// that the precompile module's process-global `CLOB_STATE` now reflects
/// the order. This proves the full bridge ↔ custom-EVM-node integration:
/// the same `Arc<Mutex<Book>>` that the bridge's `submit_order` writes to
/// is the one any smart contract calling `clob_read_best_bid` through this
/// node's EVM would see.
///
/// Doesn't yet invoke the precompile via RPC `eth_call` — that's deferred
/// indefinitely (validates Reth's plumbing rather than openhl behavior).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn bridge_against_custom_evm_node_shares_clob_with_precompile() {
use crate::OpenHlExecutorBuilder;
use crate::precompiles::{
CLOB_PLACE_ORDER, current_best_bid, uninstall_clob, uninstall_fill_sink,
};
use openhl_clob::{AccountId, OrderId, OrderType, Price, Qty, Side};
use reth_node_ethereum::node::EthereumAddOns;
// Start from a clean global state — other tests may have left a CLOB
// or fill sink installed; that's fine for those tests but would mask
// bugs here (especially the "sink was wired by bridge::new" assertion).
uninstall_clob();
uninstall_fill_sink();
let runtime = Runtime::test();
let chain_spec = dev_chain_spec();
let node_config = NodeConfig::test().dev().with_chain(chain_spec.clone());
let handle = NodeBuilder::new(node_config)
.testing_node(runtime)
.with_types::<EthereumNode>()
.with_components(EthereumNode::components().executor(OpenHlExecutorBuilder))
.with_add_ons(EthereumAddOns::default())
.launch()
.await
.expect("launch of custom-EVM node failed");
// Build the bridge against the live custom-EVM node's provider.
// The bridge installs its CLOB as the precompile's global state
// (per the install_clob call inside LiveRethEvmBridge::new).
let bridge = LiveRethEvmBridge::new(handle.node.provider.clone(), chain_spec);
// Pre-condition: precompile sees an empty book.
assert_eq!(current_best_bid(), None);
// Submit a resting bid via the bridge. This goes through Book::submit
// under the same Arc<Mutex<Book>> the precompile reads from.
bridge.submit_order(Order {
id: OrderId(1),
account: AccountId(42),
side: Side::Buy,
qty: Qty(33),
order_type: OrderType::Limit { price: Price(200) },
});
// Post-condition: the precompile's view (which is what a smart
// contract calling `clob_read_best_bid` through this node would see)
// now reflects the order.
let best = current_best_bid().expect("CLOB has bids after submit_order");
assert_eq!(best.0, Price(200));
assert_eq!(best.1, Qty(33));
// === Stage 9c+ ===
// Now hit the WRITE precompile: place a crossing Sell @ 200 qty 33
// via `place_order`. The bridge's pending_fills should see the fill
// even though we never went through bridge.submit_order. This proves
// the FILL_SINK that LiveRethEvmBridge::new installed is the same
// Arc<Mutex<Vec<Fill>>> the bridge later drains in build_payload.
assert_eq!(
bridge.pending_fill_count(),
0,
"fills empty before crossing taker via precompile"
);
let mut calldata = [0u8; 128];
// account_id = 7 (last 8 bytes of slot 0)
calldata[24..32].copy_from_slice(&7u64.to_be_bytes());
// side = Sell (1) at byte 63
calldata[63] = 1;
// price = 200 (last 8 bytes of slot 2)
calldata[88..96].copy_from_slice(&200u64.to_be_bytes());
// qty = 33 (last 8 bytes of slot 3)
calldata[120..128].copy_from_slice(&33u64.to_be_bytes());
let r = crate::precompiles::place_order(&calldata, 100_000, 0)
.expect("place_order must not error");
let order_id_bytes = &r.bytes[24..32];
let order_id = u64::from_be_bytes(order_id_bytes.try_into().unwrap());
assert!(order_id > 0, "successful place_order returns nonzero id");
// The fill from the cross should have landed in bridge's pending_fills
// via the FILL_SINK install_fill_sink path inside LiveRethEvmBridge::new.
assert_eq!(
bridge.pending_fill_count(),
1,
"precompile-placed cross must populate bridge.pending_fills (Stage 9c+)"
);
// CLOB_PLACE_ORDER's address constant is part of the public surface
// (and registered into the precompiles set by `openhl_precompiles`);
// touch it here so the import resolves and the constant stays load-bearing.
let _ = CLOB_PLACE_ORDER;
// Clean up the globals so other tests can start clean.
uninstall_fill_sink();
uninstall_clob();
// Drop the node handle explicitly to make the lifecycle visible
// in the trace.
drop(handle);
}
The test is long but every section has a job. Let me walk through the four phases.
Phase A — Setup (uninstall + NodeBuilder)
uninstall_clob();
uninstall_fill_sink();
let runtime = Runtime::test();
let chain_spec = dev_chain_spec();
let node_config = NodeConfig::test().dev().with_chain(chain_spec.clone());
let handle = NodeBuilder::new(node_config)
.testing_node(runtime)
.with_types::<EthereumNode>()
.with_components(EthereumNode::components().executor(OpenHlExecutorBuilder))
.with_add_ons(EthereumAddOns::default())
.launch()
.await
.expect("launch of custom-EVM node failed");
Why both uninstall_clob AND uninstall_fill_sink at the start? Other tests may have left either or both installed. If we ran in the same cargo test invocation after, say, Lesson 9's place_order_routes_fills_to_installed_sink, the sink would still be set to some stray Arc. We can't trust prior state.
Why is this a tokio::test(flavor = "multi_thread", worker_threads = 4)? Reth's NodeBuilder.launch() is async; it spawns background tasks (executor, RPC, mining, etc.). Single-threaded tokio would block on these. Multi-thread + 4 workers is the canonical Reth integration-test setup. Less = test stalls; more = wasteful in CI.
The NodeBuilder chain is identical to Lesson 3's reth_dev_node_with_openhl_executor test. Same builder methods, same order, same OpenHlExecutorBuilder plug-in. Reusing the proven sequence keeps the new test's failure surface focused on what Lesson 10 introduces: the bridge + precompile composition, not the Node bootstrap itself.
Phase B — Bridge construction + bridge → precompile read
let bridge = LiveRethEvmBridge::new(handle.node.provider.clone(), chain_spec);
assert_eq!(current_best_bid(), None);
bridge.submit_order(Order {
id: OrderId(1),
account: AccountId(42),
side: Side::Buy,
qty: Qty(33),
order_type: OrderType::Limit { price: Price(200) },
});
let best = current_best_bid().expect("CLOB has bids after submit_order");
assert_eq!(best.0, Price(200));
assert_eq!(best.1, Qty(33));
LiveRethEvmBridge::new(...) does five things internally:
- Creates
Arc<Mutex<Book>>(the CLOB). - Creates
Arc<Mutex<Vec<Fill>>>(the fills buffer). - Calls
install_clob— the precompile module'sCLOB_STATEglobal now points to the bridge's Book. - Calls
install_fill_sink— theFILL_SINKglobal now points to the bridge's fills buffer. - Returns
Self { clob, pending_fills, ... }.
After this single call, the bridge and the precompile module are wired together via two globals.
The pre-condition current_best_bid() == None proves we started from a clean state (Phase A's uninstalls worked). The submit_order produces a resting bid in the bridge's Book. The post-condition current_best_bid() == Some(...) proves the precompile sees the bridge's write — they share the same Arc.
This is the Stage 9d proof. A smart contract calling STATICCALL(0x...0c1b) through this exact node would route via the registered precompile → through current_best_bid() → through CLOB_STATE → into the bridge's Book → see this bid.
Phase C — Stage 9c+ extension: precompile → bridge fills
assert_eq!(
bridge.pending_fill_count(),
0,
"fills empty before crossing taker via precompile"
);
let mut calldata = [0u8; 128];
calldata[24..32].copy_from_slice(&7u64.to_be_bytes());
calldata[63] = 1;
calldata[88..96].copy_from_slice(&200u64.to_be_bytes());
calldata[120..128].copy_from_slice(&33u64.to_be_bytes());
let r = crate::precompiles::place_order(&calldata, 100_000, 0)
.expect("place_order must not error");
let order_id_bytes = &r.bytes[24..32];
let order_id = u64::from_be_bytes(order_id_bytes.try_into().unwrap());
assert!(order_id > 0, "successful place_order returns nonzero id");
assert_eq!(
bridge.pending_fill_count(),
1,
"precompile-placed cross must populate bridge.pending_fills (Stage 9c+)"
);
This phase is what Stage 9c+ added (commit d19ba1b). The first call to place_order simulates a smart contract calling the write precompile. The crossing Sell @ 200 qty 33 hits the resting Buy @ 200 qty 33 — exactly one Fill produced.
The hand-built calldata is identical to what place_order_calldata produces. We inline it here for explicitness — every byte position is annotated, so a reader can trace the ABI layout without jumping to a helper. For integration tests proving end-to-end correctness, calldata explicitness matters more than DRY.
pending_fill_count() jumped from 0 to 1. The Fill flowed through 5 indirections to get there:
place_order
→ submit_result.fills (Vec<Fill>)
→ FILL_SINK.read() → Some(sink: Arc<Mutex<Vec<Fill>>>)
→ sink.lock().extend(...)
→ same Arc as bridge.pending_fills
→ bridge.pending_fill_count() sees the increment
That's the Stage 9c+ thesis, end-to-end.
(Answer: Two reasons. (1) The Stage 9c+ commit's design is to call place_order directly — it's pub(crate) for exactly this. Going through the registry would require constructing a Precompiles set, knowing which hardfork we're at, etc. — extra plumbing for no additional proof. (2) Lesson 3 already proved the registry path works. Lesson 10's job is to prove the bridge ↔ precompile module wiring, not the registry path. Direct call narrows the test's scope.)
Phase D — Cleanup
let _ = CLOB_PLACE_ORDER;
uninstall_fill_sink();
uninstall_clob();
drop(handle);
Three small things:
let _ = CLOB_PLACE_ORDER;— touches the address constant to prove it's load-bearing. Why? Because the test importsCLOB_PLACE_ORDERbut doesn't otherwise use it (the calldata is hand-built without going through the precompile address). Without this line, clippy would warnunused_imports. Thelet _ = ...is a documented usage that satisfies the linter and signals "this constant exists; don't delete it."- Reverse-order uninstall. Install order was clob → fill_sink. Uninstall is fill_sink → clob. Reverse-order cleanup is the canonical Rust pattern (mirrors RAII drop order). Idiomatic, low-cost.
drop(handle)explicit. Rust would drop the handle at end-of-scope anyway. But naming it makes the node-lifecycle visible in the test's trace — readers see "node ends here." For an integration test that bootstraps Reth, the lifecycle moments are worth flagging.
Test
cargo test -p openhl-evm --release bridge_against_custom_evm
Output (after ~5 seconds of Reth bootstrap + test execution):
running 1 test
test live_node::tests::bridge_against_custom_evm_node_shares_clob_with_precompile ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 47 filtered out
To run the full crate's tests:
cargo test -p openhl-evm --release
running 48 tests
... 48 tests pass ...
test result: ok. 48 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
One more than Lesson 9 (47 → 48). Now 47 unit tests + 1 integration test all green.
Common errors and fixes:
error[E0603]: function 'place_order' is private— you forgot Step 1. Addpub(crate)to thefn place_ordersignature.error[E0277]: 'NodeBuilder<...>' does not satisfy the trait...— typo in the NodeBuilder chain. Compare to Lesson 3'sreth_dev_node_with_openhl_executortest — same chain, same method order.- Test hangs forever —
worker_threads = 1or single-threaded tokio. Useflavor = "multi_thread", worker_threads = 4. - Fails fast (no hang) with
pending_fill_count == 0— suspect a missing.awaitonplace_order(...)/launch().awaitpath. Futures are lazy; without.await, the operation is silently dropped (silent skip), not blocked. current_best_bid()returnsNoneafter submit_order —install_clobwasn't actually called insidebridge.new(). Re-check Lesson 4's bridge changes. Or: another test running in parallel diduninstall_clob()mid-execution. Verify the TEST_SERIALIZER pattern at all global-touching tests (most should have it from Lesson 5).pending_fill_countreturns 0 after place_order — likelyinstall_fill_sinkwasn't called insidebridge.new()(Lesson 9 Step 7), orplace_order's fill-routing block has a bug (Lesson 9 Step 3 — verify thedrop(book)comes before the sink lock).assertion failed: bridge.pending_fill_count() == 1with count = 0 — the place_order's submit returned 0 fills, so nothing was pushed. Verify your hand-built calldata: account=7, side=1 (Sell), price=200, qty=33. Specifically checkcalldata[63] = 1for side=Sell; if it's 0 the order is a Buy and won't cross.
Design reflection
Five points:
-
Integration tests catch wiring bugs unit tests can't. All the pieces have unit tests proving they work in isolation. Lesson 10 is the first test that proves they work composed. The wiring between Lesson 3's NodeBuilder, Lesson 4's install_clob, Lesson 9's install_fill_sink, and the running Reth process — that wiring has no unit test. One integration test for end-to-end + many unit tests for piece-correctness is the canonical mix.
-
pub(crate)is the right visibility for cross-module tests. Addingpubwidens API surface. Adding#[cfg(test)] pub(crate)adds ceremony for no benefit (visibility is compile-time only).pub(crate)says "inside this crate, anyone can call it; outside, no." Exactly what cross-module testing wants. -
Test calldata: explicit > DRY. The hand-built
[u8; 128]calldata in Phase C is whatplace_order_calldatawould produce — but inlining it with byte-position comments makes the ABI layout visible at the call site. For tests proving system-level correctness, every byte position should be a learnable artifact. Helpers hide; integration tests reveal. -
No helper for "spawn-bridge-with-custom-EVM-node." Reth's
NodeAdaptergeneric complexity makes return-type-naming painful. Inline composition is uglier to write once but easier to read. The cost of premature abstraction in test code is the same as in production: more code paths to debug. Wait for the third caller before abstracting. -
The honest deferred: RPC
eth_callroundtrip. This test doesn't go through Reth's RPC server. A real Solidity contract callingclob_read_best_bidvia JSON-RPC would exercise additional plumbing (RPC server, transaction simulation, etc.) that we haven't proven. We're not proving Reth works; we're proving openhl plugs into Reth correctly. The RPC layer is Reth's responsibility; testing it again would validate Reth, not openhl.
Answer key
cd ~/code/openhl-reference
git checkout d19ba1b
diff -u ~/code/my-openhl/crates/evm/src/precompiles/mod.rs ./crates/evm/src/precompiles/mod.rs
diff -u ~/code/my-openhl/crates/evm/src/live_node.rs ./crates/evm/src/live_node.rs
After Lesson 10, both diffs should be empty. Your code matches the head of Stage 9c+ (the Stage 9d test extended with the 9c+ extension). Stage 9 is now closed. All openhl Stage 9 milestones — 9a (custom EVM bootstraps), 9b (live CLOB read), 9c (write path), 9c+ (fills route to bridge), 9d (bridge integration) — are reproduced in this course.
Return:
git checkout main
Common questions
Q: Does this test cover the RPC path? E.g., a Solidity contract using web3.js to call clob_read_best_bid?
No. The test calls precompiles directly via Rust — crate::precompiles::place_order(...) and current_best_bid(). The RPC path (JSON-RPC server → eth_call → revm dispatch → our precompile) is additional plumbing that's Reth's responsibility. We trust Reth to handle the RPC layer correctly. If we tested it, we'd be testing Reth, not openhl. Out of scope.
Q: What if multiple NodeBuilder.launch() calls happen in parallel (e.g., parallel tests)?
Each launch() produces a separate Reth process state, but they all share the process-global CLOB_STATE and FILL_SINK. That's why this test calls uninstall_clob + uninstall_fill_sink at start AND end — parallel tests can race on the globals. The TEST_SERIALIZER pattern from Lesson 5 doesn't reach into this test because it's in live_node.rs's test module, not the precompile's. For full safety we'd need a cross-module serializer, but at v0 the test happens to be the only one in its module that touches both globals.
Q: Why is chain_spec.clone() needed?
NodeConfig::dev().with_chain(chain_spec.clone()) consumes one clone for the node config. LiveRethEvmBridge::new(provider, chain_spec) consumes the original (the bridge stores it as an Arc). Cloning a ChainSpec is cheap (it's typically wrapped in Arc internally) — and the alternative would be ownership wrangling that adds cognitive load to the test. Clone is the right tool here.
Q: Couldn't we just submit a marketable order via the bridge instead of the precompile in Phase C?
We could — bridge.submit_order(Sell @ 200 qty 33) would also produce one fill. But that would test the bridge-side write path, which is course 7's territory. Lesson 10 specifically wants to test the precompile-side write path through the FILL_SINK to the bridge's pending_fills. Calling place_order directly is what proves Stage 9c+'s wiring.
Course milestone — what's now proven
After Lesson 10:
- Module 1:
OpenHlEvmFactory+OpenHlExecutorBuilderplugged into Reth viaNodeBuilder. Custom EVM boots with our precompile registered. - Module 2:
read_best_bidreads live CLOB state via theCLOB_STATEglobal. Smart contracts see real orderbook data. - Module 3:
place_orderwrites to live CLOB state. The EVM↔CLOB surface is bidirectional via0x...0c1b(read) and0x...0c1c(write). - Module 4: Fills from precompile-placed orders flow into the bridge's
pending_fillsvia theFILL_SINKglobal. EVM-side trades become payload fills.
47 unit tests prove each piece. 1 integration test proves the composition. A smart contract calling either precompile through this Reth node sees and writes to the same Book the bridge orchestrates.
Next lesson (Lesson 11)
Lesson 11 is the capstone — no new code. We reflect on what's been built, name the deferred items (RPC roundtrip, multi-validator OrderId, transaction-scoped state shadowing, staticcall mutation refusal), and list the next-stage extensions (additional read precompiles for best_ask/depth/mid-price, a clob_cancel_order precompile, fills-as-EVM-events). The Lesson 11 lesson is for cementing the mental model and seeing the architecture as a whole.
Summary (3 lines)
- Course milestone: 4 precompiles + Bridge contract + matching engine + OpenHlEvmFactory all working in a real Reth node.
- End-to-end loop: deposit → place_order → matching → fill → read_fills → balance update. < 5 ms round-trip.
- Composition guarantee asserted; failure modes mitigated. Production parallel: Hyperliquid HyperEVM. Capstone next.