Lesson 3 — NodeBuilder wiring + registry callability tests
Question
Wire the precompile through NodeBuilder and prove it's callable end-to-end from a real Reth node. Reachability milestone: at the end of this lesson, a Solidity contract running on your Reth node can call the precompile.
Principle (minimum model)
NodeBuilderintegration code. ~10 lines: import the factory + register viawith_evm_factory(...)+ add a chainspec annotation for precompile activation block.- Chainspec entry.
precompiles: { activation_block: 0, addresses: ["0xCL...B1"] }. Lets the validator decide which precompiles are active at which block. - Reachability test. Solidity contract
Tester { function check() returns uint256 { return clob_read_best_bid(0); } }. Deploy on a Reth node with the factory; callcheck(); assert result =1_000_000_00. - Registry callability tests. Foundry tests for every precompile in the registry; assert each is callable + returns the expected hardcoded value.
- Test harness.
OpenHlTestNode::new()boots a Reth node with the factory; provides an AlloyProviderfor deployment + calls. ~50 lines. - Reachability milestone. Not "what it returns is correct" (that's Lesson 5); just "it's callable from inside Reth". Prevents wiring bugs from blocking later work.
- Why an explicit milestone. Building toward the live-state milestone (L6); each module ends with a passing reachability test before moving on.
Worked example + steps
Lesson 3 — NodeBuilder wiring + registry callability tests
Goal
Concepts you'll grasp in this lesson:
- Test scope = bug localization — three unit tests in increasing scope (function body → registry registration → registry dispatch) so a failure points to exactly which layer is broken.
- The extend-not-replace dual assertion — checking both that
CLOB_READ_BEST_BIDis present AND that ECDSA recover at0x...01is still present catches the silent-replace bug that a single-assertion test would let through. NodeBuilder.with_components(EthereumNode::components().executor(OpenHlExecutorBuilder))— the explicit-builder path that swaps one slot while inheriting all other Reth defaults; this is the "configure, don't fork" property in code.Precompile::executevs direct function call — the dispatch test proves thePrecompile::new(...)wiring is correct (right function pointer, right id, right address) independent of whether the function body is right.- Integration test = wiring assertion, not behavior assertion — proving
NodeBuilder+OpenHlExecutorBuilder+EthereumAddOnscompose cleanly is a different concern from "does the precompile read the right bytes" (unit tests cover that).
Verification:
cargo test -p openhl-evm reth_dev_node_with_openhl_executor --release
cargo test -p openhl-evm --lib precompiles
…both pass.
Specific changes:
You'll have written 4 new tests total:
- 1 integration test in
crates/evm/src/reth_node.rs—reth_dev_node_with_openhl_executor. Bootstraps a Reth node withOpenHlExecutorBuilderswapped in for the default executor. Validates that theEvmFactory+ExecutorBuildercomposition spawns cleanly. - 3 unit tests in
crates/evm/src/precompiles/mod.rs:read_best_bid_returns_hardcoded_price_and_qty— direct function-call test.openhl_precompiles_registers_clob_address— extend-not-replace invariant.registered_precompile_is_invokable_via_registry— full registry-dispatch test (the path REVM uses internally).
This is the milestone lesson for Module 1. After Lesson 3, the custom EVM + precompile are not just compile-clean — they're proven reachable from EVM execution. Modules 2-4 build the content (live state, write paths, bridge integration); Module 1 set up the plumbing.
Recap
After Lesson 2:
openhl_evm.rshasOpenHlEvmFactory+OpenHlExecutorBuilder(Lesson 1).precompiles/mod.rshasCLOB_READ_BEST_BID+read_best_bid+openhl_precompiles(Lesson 2).cargo check -p openhl-evmpasses.
Nothing has invoked any of this code. Lesson 3 writes the four tests that prove the plumbing works.
Plan
Five things:
- Update imports in
reth_node.rs— addEthereumAddOns(needed forwith_add_ons(...)) andcrate::OpenHlExecutorBuilder(the type we'll wire in). - Add
reth_dev_node_with_openhl_executorintegration test — same shape as course 6'sreth_dev_node_bootstraps, but uses the explicit-builder path with.with_components(EthereumNode::components().executor(OpenHlExecutorBuilder)). - Add
#[cfg(test)] mod teststoprecompiles/mod.rswith 3 unit tests. - Run both test paths — integration test passes, 3 unit tests pass.
- Verify everything else still passes —
cargo test -p openhl-evm --releaseshows all prior course-6 + course-7 tests still green.
The 3 unit tests cover three distinct concerns:
| Test | Concern | If it fails, the bug is in… |
|---|---|---|
read_best_bid_returns_hardcoded_price_and_qty | The function's body is correct (writes the right bytes) | Lesson 2's read_best_bid implementation |
openhl_precompiles_registers_clob_address | Extend-not-replace invariant | Lesson 2's openhl_precompiles body — likely the wrong clone() or extend(...) semantics |
registered_precompile_is_invokable_via_registry | EVM dispatch path through the registry works | The Precompile::new(...) call shape, the PrecompileId, or registration ordering |
(Answer: because the test enforces the extend-not-replace invariant. If your openhl_precompiles accidentally created a fresh Precompiles set instead of cloning the base and extending it, CLOB_READ_BEST_BID would still be present, but the standard Ethereum precompiles (ECDSA recover, SHA-256, etc.) would be gone. The base set is one of the load-bearing things our wrapper must preserve. Without ECDSA recover, any contract that verifies signatures would revert. The dual assertion catches the silent-replace bug.)
Walk-through
Step 1: Update imports in reth_node.rs
Open crates/evm/src/reth_node.rs. The existing test module (mod tests from course 6) imports:
use reth_node_ethereum::EthereumNode;
Change to:
use reth_node_ethereum::{node::EthereumAddOns, EthereumNode};
Also add an import for OpenHlExecutorBuilder. Put it just after the use block, before dev_chain_spec():
use crate::OpenHlExecutorBuilder;
Two imports because EthereumAddOns is needed for .with_add_ons(...) (the explicit-builder path requires the add_ons argument, even if we don't customize them), and OpenHlExecutorBuilder is the type we're swapping in.
Step 2: Add reth_dev_node_with_openhl_executor integration test
Append the following test to the mod tests block in reth_node.rs, after the existing reth_dev_node_bootstraps test:
/// Stage 9a: prove that `NodeBuilder` accepts `OpenHlExecutorBuilder` in
/// place of Reth's default executor, and that the resulting node still
/// spawns cleanly with our custom precompile registered.
///
/// Doesn't yet invoke the precompile (that requires deploying a
/// Solidity contract); just validates the `EvmFactory` + `ExecutorBuilder`
/// composition compiles, spawns, and tears down.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn reth_dev_node_with_openhl_executor() {
let runtime = Runtime::test();
let chain_spec = dev_chain_spec();
let expected_chain_id = chain_spec.chain.id();
let node_config = NodeConfig::test().dev().with_chain(chain_spec);
let result: Result<()> = async {
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?;
// The node spawned with our custom EVM. We don't need to inspect
// further — if the EvmFactory or ExecutorBuilder were broken,
// launch() would have failed.
let _ = expected_chain_id;
Ok(())
}
.await;
if let Err(e) = result {
panic!("Reth dev node bootstrap with OpenHl EVM failed: {e:?}");
}
}
Compare against course 6's reth_dev_node_bootstraps test — same setup pattern, but one critical line differs:
// course 6:
.node(EthereumNode::default())
.launch_with_debug_capabilities()
// course 8:
.with_components(EthereumNode::components().executor(OpenHlExecutorBuilder))
.with_add_ons(EthereumAddOns::default())
.launch()
The course-6 path uses .node(...) which is shorthand — it takes a pre-built node spec. The course-8 path uses the explicit builder: swap in OpenHlExecutorBuilder while keeping every other component (network, payload pool, RPC handler) at default. That's the "you don't fork Reth, you configure it" property.
The .executor(OpenHlExecutorBuilder) chain is the load-bearing piece. EthereumNode::components() returns a default ComponentsBuilder; .executor(...) overrides one slot. The remaining slots (network, payload, pool, etc.) come from defaults. One slot swapped, everything else inherited.
Step 3: Add the mod tests block to precompiles/mod.rs
Open crates/evm/src/precompiles/mod.rs. Append the following at the end of the file (after openhl_precompiles):
#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::U256;
/// Direct unit test of the precompile function: invoked with empty input,
/// it returns the hardcoded (price=100, qty=10) as 64 big-endian u256 bytes.
#[test]
fn read_best_bid_returns_hardcoded_price_and_qty() {
let result = read_best_bid(&[], 100_000, 0).expect("precompile must not error");
assert_eq!(result.bytes.len(), 64);
let price = U256::from_be_slice(&result.bytes[0..32]);
let qty = U256::from_be_slice(&result.bytes[32..64]);
assert_eq!(price, U256::from(100u64));
assert_eq!(qty, U256::from(10u64));
assert_eq!(result.gas_used, CLOB_BASE_GAS_COST);
}
/// Registry test: `openhl_precompiles()` extends a base precompile set
/// with our CLOB precompile at the well-known address. This is what the
/// Stage 9a `EvmFactory` plugs into every EVM instance Reth constructs.
#[test]
fn openhl_precompiles_registers_clob_address() {
let base = Precompiles::cancun();
let extended = openhl_precompiles(base);
// The CLOB address must be in the extended set.
assert!(
extended.contains(&CLOB_READ_BEST_BID),
"openhl_precompiles must register the CLOB_READ_BEST_BID address"
);
// The base Ethereum precompiles (e.g. ECDSA recover at 0x...01) must
// still be present — we EXTEND, not replace.
let ecrecover: Address = alloy_primitives::address!("0x0000000000000000000000000000000000000001");
assert!(
extended.contains(&ecrecover),
"extended set must retain base Ethereum precompiles"
);
}
/// Invoke the registered precompile end-to-end through the registry
/// (rather than calling `read_best_bid` directly). This proves the
/// registration is wired such that an EVM dispatch to the address hits
/// our function — the same path Reth's EVM uses on `staticcall` to
/// `CLOB_READ_BEST_BID`.
#[test]
fn registered_precompile_is_invokable_via_registry() {
let extended = openhl_precompiles(Precompiles::cancun());
let precompile = extended
.get(&CLOB_READ_BEST_BID)
.expect("CLOB precompile must be registered");
// Precompile::execute is the public dispatch method — same as what
// the EVM calls internally when a contract STATICCALLs the address.
let result = precompile
.execute(&[], 100_000, 0)
.expect("call must not error");
assert_eq!(result.bytes.len(), 64);
let price = U256::from_be_slice(&result.bytes[0..32]);
let qty = U256::from_be_slice(&result.bytes[32..64]);
assert_eq!(price, U256::from(100u64));
assert_eq!(qty, U256::from(10u64));
}
}
Three tests in increasing scope:
read_best_bid_returns_hardcoded_price_and_qty— calls the function directly with(empty_input, gas_limit=100_000, reservoir=0). Asserts byte length, decoded price, decoded qty, gas used. The narrowest scope — just the function, no registry, no EVM.openhl_precompiles_registers_clob_address— callsopenhl_precompiles(Precompiles::cancun()), checks that both our address AND the standard ECDSA recover address are in the extended set. The extend-not-replace invariant is the load-bearing assertion: a buggy wrapper could replace the base set instead of extending it.registered_precompile_is_invokable_via_registry— extracts the precompile from the registry via.get(&CLOB_READ_BEST_BID), calls its.execute(...)method. The full dispatch path — same code REVM uses internally on aSTATICCALL.
The alloy_primitives::U256 import is needed for decoding the 64-byte response. U256::from_be_slice(&bytes[..]) decodes a 32-byte big-endian slice into a U256 value.
Step 4: Run the tests
cargo test -p openhl-evm reth_dev_node_with_openhl_executor --release
After ~30 seconds (first incremental build with the new tests):
running 1 test
test reth_node::tests::reth_dev_node_with_openhl_executor ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Then the unit tests:
cargo test -p openhl-evm --lib precompiles
running 3 tests
test precompiles::tests::openhl_precompiles_registers_clob_address ... ok
test precompiles::tests::read_best_bid_returns_hardcoded_price_and_qty ... ok
test precompiles::tests::registered_precompile_is_invokable_via_registry ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Note --lib ensures we run the unit tests inside the library (not integration tests, which live in tests/). Without --lib, cargo test precompiles would also try to match integration test names.
Step 5: Verify nothing else broke
Full suite:
cargo test -p openhl-evm --release
After ~30 seconds:
running 42 tests
... 42 tests pass ...
test result: ok. 42 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
42 tests workspace-wide for openhl-evm (39 from courses 6+7 + 3 new unit tests + 1 new integration test - some test counts may overlap because --lib and integration tests share name patterns; the exact count varies). All prior tests still green.
Common errors and fixes:
- Integration test fails with
with_componentsnot found — the new test useswith_componentsinstead of the shorthand.node(...). Make sure you replaced the shorthand entirely, not just appended to it. error[E0277]: 'EthereumAddOns' is not a 'NodeAddOns'— wrong import path. Usereth_node_ethereum::node::EthereumAddOns(withnode::in the path), not justreth_node_ethereum::EthereumAddOns.assert!(extended.contains(&ecrecover))fails — youropenhl_precompilesbody created a freshPrecompilesset instead of cloning the base. Re-check Lesson 2's Step 4: it must belet mut precompiles = base.clone(); precompiles.extend(...); precompiles. NOTlet precompiles = Precompiles::default(); precompiles.extend(...).result.gas_useddoesn't matchCLOB_BASE_GAS_COST— the constant has a different value than whatread_best_bidcharges. Re-check Lesson 2's Step 3:PrecompileOutput::new(CLOB_BASE_GAS_COST, ...)— both must reference the same constant.- Test
registered_precompile_is_invokable_via_registrypanics — yourPrecompile::new(...)call in Lesson 2'sopenhl_precompileswas wrong (e.g., wrong function pointer or wrong argument order). Re-check the 3-argument shape:(PrecompileId, Address, fn).
Design reflection
Lining up the four tests this lesson wrote by "scope width" in one picture makes it visible why this 4-tier shape works as a bug-localization tool:
Test scope (narrow ─────────────────────► wide)
┌─────────────────────────────────────────────────────────┐
│ │
│ ① Function body alone │
│ ┌──────────────────────────────────────┐ │
│ │ read_best_bid_returns_hardcoded_* │ ── unit │
│ │ (call read_best_bid directly, │ │
│ │ verify bytes) │ │
│ └──────────────────────────────────────┘ │
│ ▲ Failure → Lesson 2 Step 3 function body │
│ │
│ ② Registry registration invariant │
│ ┌──────────────────────────────────────┐ │
│ │ openhl_precompiles_registers_clob_* │ ── unit │
│ │ (both our address AND ECDSA present │ │
│ │ in extended set = extend-not-replace)│ │
│ └──────────────────────────────────────┘ │
│ ▲ Failure → Lesson 2 Step 4 wrong clone/extend │
│ semantics (e.g. building a new │
│ set that replaces the base) │
│ │
│ ③ EVM dispatch through the registry │
│ ┌──────────────────────────────────────┐ │
│ │ registered_precompile_is_invokable_* │ ── unit │
│ │ (.get(addr) → .execute(), same path │ │
│ │ REVM uses on STATICCALL) │ │
│ └──────────────────────────────────────┘ │
│ ▲ Failure → Lesson 2 Step 4 Precompile::new() │
│ assembly (id / fn / arg order) │
│ │
│ ④ Full node composition (integration) │
│ ┌──────────────────────────────────────┐ │
│ │ reth_dev_node_with_openhl_executor │ ── integration│
│ │ (NodeBuilder + ExecutorBuilder + │ │
│ │ EthereumAddOns compose; launch) │ │
│ └──────────────────────────────────────┘ │
│ ▲ Failure → Lesson 1 Factory / Builder wiring, │
│ or with_components / add_ons │
│ trait mismatch │
└─────────────────────────────────────────────────────────┘
The "narrower tests fail before wider ones" pattern is the
diagnostic signal we want:
① fails alone → debug the function body
only ② fails → wrapper semantics (clone vs replace)
only ③ fails → registration assembly (fn ptr vs id vs address)
only ④ fails → NodeBuilder trait wiring (outside the factory)
The key idea: when scopes differ, the position of the bug is uniquely constrained by which subset of tests fail. If ①, ②, ③ pass and only ④ fails, the function, registry, and dispatch are all fine — the defect must live in the Reth NodeBuilder wiring (Lesson 1's OpenHlExecutorBuilder / EvmFactory trait bounds). Conversely, ① failing alone while ②–④ pass is impossible — a broken function body cascades into dispatch failures too. That dependency direction is precisely what gives the "tests in increasing scope" discipline its diagnostic power.
Three load-bearing decisions encoded here:
-
Tests in increasing scope. The 3 unit tests start with the narrowest (function body) and expand outward (registry registration → registry dispatch). When one fails, you know exactly which layer is broken. Test scope = bug localization.
-
The extend-not-replace check is the dual assertion. A passing test for
extended.contains(CLOB_READ_BEST_BID)alone doesn't prove the wrapper isn't catastrophically wrong — a buggy wrapper that replaces the base set would still pass. Asserting that ECDSA recover is also there catches the silent-replace bug. A single assertion can pass for the wrong reasons; the dual asserts together can't. -
The integration test doesn't invoke the precompile. The full RPC roundtrip would require deploying a Solidity contract — that's Reth-RPC testing surface, not precompile testing. The Module-1 milestone is "the EvmFactory + ExecutorBuilder spawn cleanly." The unit tests (Step 3) cover the precompile behavior; the integration test covers the assembly. Two tests with different scope, addressed separately.
Answer key
cd ~/code/openhl-reference
git checkout 2ba97c6
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/reth_node.rs ./crates/evm/src/reth_node.rs
After Lesson 3, your code matches the reference at 2ba97c6 — both Stage 9a's NodeBuilder wiring and Stage 9e's 3 unit tests are present. Only doc-comment wording may differ.
Return:
git checkout main
Common questions
Q: Why use EthereumNode::components() instead of EthereumNode::default()?
default() returns a pre-built node spec where every component is fixed; you can't swap individual components. components() returns a ComponentsBuilder that exposes .executor(...), .network(...), .payload(...), etc. as chainable methods. You use components() when you need to swap one or more slots; default() when you accept everything as-is.
Q: What does Precompile::execute(&[], 100_000, 0) actually do internally?
It's the public dispatch method on the Precompile type. Internally it calls the stored function pointer (our read_best_bid) with the provided arguments. REVM uses this same method when a smart contract STATICCALLs the precompile's address — the EVM looks up the address in the precompile registry, gets back a &Precompile, and calls .execute(input, gas_limit, reservoir).
Q: Why does the integration test need --release?
For speed. --release cuts the test runtime from ~5 seconds (debug) to ~1 second by enabling optimizations. The other unit tests are tiny enough that the debug overhead is negligible.
Q: Could the .with_add_ons(EthereumAddOns::default()) be skipped?
No — NodeBuilder's build chain requires every "slot" to be filled, even with defaults. Skipping it would fail at compile time. The explicit EthereumAddOns::default() says "use the defaults" without ambiguity.
Q: Why is the integration test using Result<()> and an async block instead of unwrap() chains?
For better error reporting. If something inside the NodeBuilder chain fails, the ? operator propagates the error to the outer result, and the panic! at the end prints {e:?} so the failure cause is visible. With .unwrap(), you'd get a generic panic without the original error chain.
Next lesson (Lesson 4)
The precompile is registered and proven callable, but it returns hardcoded values. Lesson 4 starts wiring live CLOB state to the precompile — adding install_clob() so the bridge can inject its Arc<Mutex<Book>> into the precompile module, and updating openhl_precompiles to accept the shared state. After Lesson 4, the precompile is capable of returning real data; Lesson 5 makes it actually read from the shared book.
Summary (3 lines)
NodeBuilderwiring = ~10 lines (factory registration + chainspec entry). Solidity contract running on Reth can now call the precompile.- Registry callability tests assert each precompile is callable + returns expected hardcoded value.
OpenHlTestNodeharness ~50 lines. - Reachability milestone established (not correctness — yet). Next module: live state — install_clob bridges EVM to matching engine.