Lesson 1 — OpenHlEvmFactory — hooking into every EVM creation
Question
Reth creates a new EVM instance for every block. OpenHlEvmFactory is the hook — it lets you inject precompiles + custom Database bindings into every EVM creation. Build it as a 100-line scaffold.
Principle (minimum model)
EvmFactorytrait. Reth's extension point:fn create_evm(&self, db: D, env: BlockEnv) -> EVM. Implement once; Reth calls it everywhere.OpenHlEvmFactorystruct. Holds references to the CLOB matching engine + the fill sink + the precompile registry. Cloned per-block (cheap, just Arc clones).- Precompile injection. Inside
create_evm, build the precompile map + inject viaEvmConfig::with_precompiles. Includes both standard precompiles and the custom CLOB ones. - Database injection. Wrap Reth's Database trait in a
ClobAwareDbthat gives precompiles access to CLOB state via a custom interface. - NodeBuilder integration.
NodeBuilder::with_evm_factory(OpenHlEvmFactory::new(clob, fill_sink)). Replaces Reth's default EthEvmFactory. - Tests. Spin up a Reth node with the factory; assert the EVM is created with the right precompile set + Database wrapper.
- Why a factory, not a one-off injection. Every block creates a new EVM; the factory pattern means the injection is centralised + correct everywhere.
Worked example + steps
Lesson 1 — OpenHlEvmFactory — hooking into every EVM creation
Goal
Concepts you'll grasp in this lesson:
EvmFactory+ExecutorBuilderas Reth's "swap one slot" seam — every EVM that Reth constructs (payload build, block validation, eth_call RPC, debug RPC) goes through one factory, so registering custom precompiles once propagates everywhere.alloy-evm(abstract traits) vsreth-evm(concrete wiring) — why both deps are required: the trait layer expresses what an EVM is, the executor layer expresses how Reth runs it.- Per-spec
OnceLockcaching ofPrecompiles— building a precompile set is expensive (hashing addresses),create_evmis hot, so each hardfork tier's set is constructed once and shared as&'static. - Stub-with-stable-signature as an incremental-construction tactic — the passthrough
openhl_precompiles(base) -> Precompileslets the factory wire up now while Lesson 2 fills the body later, with no call-site rewrites.
Verification:
cargo check -p openhl-evm
…compiles cleanly.
Specific changes:
You'll have two new modules in crates/evm/src/:
openhl_evm.rs—OpenHlEvmFactory(Reth'sEvmFactoryslot) +OpenHlExecutorBuilder(Reth'sExecutorBuilderslot) + per-hardfork precompile dispatch viaOnceLock. About 80 LOC.precompiles/mod.rs— a stubopenhl_precompiles(base) -> Precompilesthat passes through unchanged. Lesson 2 fills in the actual read precompile.
You also add 5 new dependencies (1 workspace-level, 4 crate-level — including 1 new git-pinned dep for reth-node-api).
After Lesson 1, the custom EVM structure exists end-to-end. Reth can construct EVM instances via our factory; the factory's job (register custom precompiles) just doesn't do anything yet because Lesson 2 is what defines those precompiles.
Recap
After course 7, crates/evm/src/ has:
crates/evm/src/
├── bridges/ Lessons 4–5: InMemoryEvmBridge, RethEvmBridge
├── reth_node.rs Lesson 11 (c6): bootstrap proof (test-only)
└── live_node.rs Lessons 12–14 (c6) + Lessons 9–11 (c7): LiveRethEvmBridge<P>
cargo test -p openhl-evm clob_fills_flow_into_payload --release passes. The bridge owns a CLOB and routes fills through build_payload. But smart contracts running inside the bridge's Reth node can't see the CLOB — that's the gap Lesson 1 starts closing.
Plan
Seven things:
- Add
alloy-evm = "0.34"to the workspaceCargo.toml. This is the publicalloy-evmcrate (not git-pinned to Reth) that providesEvmFactory,Database,EvmEnv, etc. - Add 4 deps to
crates/evm/Cargo.toml:reth-evm,reth-evm-ethereum,reth-node-api(new git dep — same SHA as the rest), and promotereth-node-builderfrom[dev-dependencies]to[dependencies]. - Create
crates/evm/src/openhl_evm.rswithOpenHlEvmFactory+OpenHlExecutorBuilder+precompiles_for(spec). - Create
crates/evm/src/precompiles/mod.rsas a passthrough stub. - Wire
pub mod openhl_evm; mod precompiles;intocrates/evm/src/lib.rs. - Re-export
OpenHlEvmFactoryandOpenHlExecutorBuilderat crate root for Lesson 3's NodeBuilder integration. cargo check -p openhl-evm— clean.
This is the dependency-heaviest lesson in course 8. Once the scaffold compiles, Lesson 2 adds the actual precompile content; Lesson 3 wires it into NodeBuilder and tests that the precompile is reachable from EVM execution.
Walk-through
Step 1: Add alloy-evm to the workspace
Open the root Cargo.toml. The alloy block (after Lesson 11 in course 6 / Lesson 12 in course 7) ends with:
alloy-rpc-types-engine = { version = "2.0", default-features = false }
alloy-genesis = { version = "2.0", default-features = false }
Add one line:
alloy-evm = { version = "0.34", default-features = false }
alloy-evm is the public alloy crate that provides REVM's abstractions at the trait level (EvmFactory, Database, EvmEnv). It's a stable crates.io dependency, not git-pinned to Reth — same status as alloy-genesis and alloy-rpc-types-engine.
Step 2: Update crates/evm/Cargo.toml
Open crates/evm/Cargo.toml. After course 7's Lesson 9 + Lesson 12 (course 6), the [dependencies] section has 12 entries. We add 4 more (3 new + 1 promotion):
[dependencies]
openhl-consensus = { workspace = true }
openhl-types = { workspace = true }
openhl-clob = { workspace = true }
async-trait = { workspace = true }
eyre = { workspace = true }
alloy-primitives = { workspace = true }
alloy-consensus = { workspace = true }
alloy-rpc-types-engine = { 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 }
reth-ethereum-engine-primitives = { workspace = true }
reth-evm = { workspace = true } # NEW
reth-evm-ethereum = { workspace = true } # NEW
reth-node-api = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" } # NEW (1-off git dep)
reth-node-builder = { workspace = true } # NEW (was dev-dep)
alloy-evm = { workspace = true } # NEW
reth-node-builder moves from [dev-dependencies] to [dependencies] — production code (OpenHlExecutorBuilder) now uses it. Remove the line in [dev-dependencies]:
[dev-dependencies]
tokio = { workspace = true }
reth-node-ethereum = { workspace = true, features = ["test-utils"] }
reth-node-core = { workspace = true }
reth-tasks = { workspace = true }
reth-provider = { workspace = true }
alloy-genesis = { workspace = true }
serde_json = { workspace = true }
tempfile = "3"
# reth-node-builder removed from here — now a production dep
reth-node-api is a one-off direct git dep (not via workspace). The workspace Cargo.toml doesn't declare it; we inline the git+rev directly. This is intentional: reth-node-api is used in exactly one crate (openhl-evm), and the rest of the workspace doesn't need it. Promoting it to a workspace dep would force every crate's build graph to know about it.
⚠️ The
revmust match the rest of thereth-*crates exactly. When you inlinerev = "88505c7...", the commit hash has to be identical to the one already pinned by the otherreth-*crates in the workspace. Reth shares types (FullNodeTypes,NodeTypes,BuilderContext, etc.) strictly across its internal crates, and Cargo's same-version-same-source rule means even a one-character mismatch causes those types to be treated as different identities — producing cryptic errors likeexpected ChainSpec, found ChainSpec. The temptation to "just bumpreth-node-apito a newer rev" is strong; resist it unless you're upgrading everyreth-*rev together. Operationally, confirm aftercargo check -p openhl-evmthat rootCargo.lockresolves one coherent graph. A stray rev here causes Cargo to resolve duplicate Reth trees, which increases build time and explodes into type-identity mismatches.
Step 3: Create crates/evm/src/precompiles/mod.rs (stub)
Before writing openhl_evm.rs, we need the precompile module to exist (because openhl_evm.rs imports from it). Create the directory + file:
mkdir -p crates/evm/src/precompiles
touch crates/evm/src/precompiles/mod.rs
Open crates/evm/src/precompiles/mod.rs and write:
//! Custom REVM precompiles that expose CLOB state to EVM execution.
//!
//! Stage 9a — scout commit. Lesson 2 adds the first real precompile
//! (`clob_read_best_bid` at 0x...0c1b) that returns a hardcoded best-bid
//! response so smart contracts can prove the precompile is reachable.
//! Lesson 4+ wires it to live CLOB state.
use alloy_evm::revm::precompile::Precompiles;
/// Wraps Reth's spec-default precompile set, adding openhl's CLOB precompiles.
///
/// Lesson 1 (this lesson): passthrough — clones the base unchanged.
/// Lesson 2: registers `clob_read_best_bid`.
/// Lesson 7+: registers `clob_place_order`.
#[must_use]
pub fn openhl_precompiles(base: &Precompiles) -> Precompiles {
// Lesson 2 will replace this with `let mut precompiles = base.clone();
// precompiles.extend([...]); precompiles`.
base.clone()
}
3 lines of body. The function takes a Precompiles set (Reth's default for the current hardfork) and returns it unchanged. Lesson 2 inserts the actual clob_read_best_bid between base and the return.
The function signature is the stable contract the EVM factory depends on. Lessons 2–11 will change the contents of this function, but openhl_precompiles(base: Precompiles) -> Precompiles stays the same shape throughout.
Step 4: Create crates/evm/src/openhl_evm.rs
The main file. Top of the file:
//! `OpenHlEvmFactory` + `OpenHlExecutorBuilder` — Reth's `ConfigureEvm` slot,
//! filled with our custom-precompile EVM.
//!
//! Stage 9a (scout commit) — modelled on Reth's `examples/custom-evm/src/main.rs`
//! pattern. The factory's `create_evm` installs `openhl_precompiles(...)` so
//! any EVM execution path (RPC call, payload assembly, validation) sees the
//! CLOB precompile registered at `CLOB_READ_BEST_BID`.
use alloy_evm::{
eth::EthEvmContext,
precompiles::PrecompilesMap,
revm::{
context::{BlockEnv, Context, TxEnv},
context_interface::result::{EVMError, HaltReason},
handler::EthPrecompiles,
inspector::{Inspector, NoOpInspector},
interpreter::interpreter::EthInterpreter,
precompile::Precompiles,
primitives::hardfork::SpecId,
MainBuilder, MainContext,
},
Database, EvmEnv, EvmFactory,
};
use reth_chainspec::ChainSpec;
use reth_ethereum_primitives::EthPrimitives;
use reth_evm_ethereum::{EthEvm, EthEvmConfig};
use reth_node_api::{FullNodeTypes, NodeTypes};
use reth_node_builder::{components::ExecutorBuilder, BuilderContext};
use std::sync::OnceLock;
use crate::precompiles::openhl_precompiles;
20-ish imports. Most are REVM internals via alloy-evm's re-exports. Worth scanning, not memorizing:
EvmFactory— the trait we'll implement. Reth calls our factory'screate_evmwhenever it needs an EVM instance.ExecutorBuilder— the trait we'll implement forOpenHlExecutorBuilder. Reth'sNodeBuilderuses it to construct an EVM config.Precompiles— REVM's collection of precompiled contracts. We add to this.OnceLock— std's once-init primitive. We cache the per-spec precompile sets.
Now the factory struct:
/// EVM factory that registers openhl's custom precompiles on every EVM
/// instance Reth constructs (for payload assembly, block validation, RPC
/// state queries, etc.).
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct OpenHlEvmFactory;
impl EvmFactory for OpenHlEvmFactory {
type Evm<DB: Database, I: Inspector<EthEvmContext<DB>, EthInterpreter>> =
EthEvm<DB, I, Self::Precompiles>;
type Tx = TxEnv;
type Error<DBError: core::error::Error + Send + Sync + 'static> = EVMError<DBError>;
type HaltReason = HaltReason;
type Context<DB: Database> = EthEvmContext<DB>;
type Spec = SpecId;
type BlockEnv = BlockEnv;
type Precompiles = PrecompilesMap;
fn create_evm<DB: Database>(&self, db: DB, input: EvmEnv) -> Self::Evm<DB, NoOpInspector> {
let spec = input.cfg_env.spec;
let evm = Context::mainnet()
.with_db(db)
.with_cfg(input.cfg_env)
.with_block(input.block_env)
.build_mainnet_with_inspector(NoOpInspector {})
.with_precompiles(PrecompilesMap::from_static(precompiles_for(spec)));
EthEvm::new(evm, false)
}
fn create_evm_with_inspector<DB: Database, I: Inspector<Self::Context<DB>, EthInterpreter>>(
&self,
db: DB,
input: EvmEnv,
inspector: I,
) -> Self::Evm<DB, I> {
EthEvm::new(
self.create_evm(db, input).into_inner().with_inspector(inspector),
true,
)
}
}
The 8 associated types are scaffolding — every EvmFactory impl needs them, and most are the same as Reth's defaults. The interesting part is create_evm. Five steps:
Context::mainnet()— REVM's "Ethereum mainnet" preset (gas constants, etc.)..with_db(db)+.with_cfg(input.cfg_env)+.with_block(input.block_env)— install the database, config, and block env passed in..build_mainnet_with_inspector(NoOpInspector {})— construct the EVM with a no-op inspector (no tracing)..with_precompiles(PrecompilesMap::from_static(precompiles_for(spec)))— install our precompiles.precompiles_for(spec)returns the right precompile set for the current Ethereum hardfork.EthEvm::new(evm, false)— wrap in Reth's EthEvm type.
create_evm_with_inspector is the same path with a custom inspector instead of the no-op. Most callers use create_evm; the inspector variant is for debug RPC.
Step 5: Add the precompiles_for(spec) helper
Below the factory impl:
/// Lazily-initialised per-spec precompile sets. `OnceLock` ensures we build
/// each set once and share the static reference across every `create_evm` call,
/// matching the pattern in Reth's custom-evm example. Shanghai/Paris/London
/// don't add new precompiles, so they fall through to the Berlin set.
fn precompiles_for(spec: SpecId) -> &'static Precompiles {
static PRAGUE: OnceLock<Precompiles> = OnceLock::new();
static CANCUN: OnceLock<Precompiles> = OnceLock::new();
static FALLBACK: OnceLock<Precompiles> = OnceLock::new();
match spec {
SpecId::PRAGUE | SpecId::OSAKA => {
PRAGUE.get_or_init(|| openhl_precompiles(Precompiles::prague()))
}
SpecId::CANCUN => CANCUN.get_or_init(|| openhl_precompiles(Precompiles::cancun())),
// For older hardforks (Berlin/London/Paris/Shanghai), use the Berlin
// set as the most-recent-additions-cutoff base plus ours.
_ => FALLBACK.get_or_init(|| {
let base = EthPrecompiles::new(spec).precompiles;
openhl_precompiles(base)
}),
}
}
Each Ethereum hardfork has a different set of standard precompiles (ECDSA recovery, SHA-256, ModExp, EC-pairing, etc.). Cancun adds the point evaluation precompile for blobs. Prague will add yet more. Our wrapper openhl_precompiles injects our custom precompile(s) into whichever base set is active.
Three OnceLocks, one per hardfork tier:
PRAGUE— covers Prague + Osaka (Osaka inherits Prague's precompiles for now).CANCUN— covers Cancun.FALLBACK— Berlin/London/Paris/Shanghai, usingEthPrecompiles::new(spec)to get whatever set Reth thinks is right for that spec.
Why OnceLock instead of computing per call? Because Precompiles is a HashMap-backed structure that's expensive to construct (hashing every precompile address). Computing it once per spec + caching is one of the optimizations Reth's custom-evm example demonstrates. Caching matters because create_evm is called very frequently — every RPC eth_call, every block validation, every block build.
Step 6: Add the OpenHlExecutorBuilder
At the bottom of openhl_evm.rs:
/// Executor builder that swaps in `OpenHlEvmFactory` while keeping all other
/// Reth `EthereumNode` components at default.
#[derive(Debug, Default, Clone, Copy)]
#[non_exhaustive]
pub struct OpenHlExecutorBuilder;
impl<Node> ExecutorBuilder<Node> for OpenHlExecutorBuilder
where
Node: FullNodeTypes<Types: NodeTypes<ChainSpec = ChainSpec, Primitives = EthPrimitives>>,
{
type EVM = EthEvmConfig<ChainSpec, OpenHlEvmFactory>;
async fn build_evm(self, ctx: &BuilderContext<Node>) -> eyre::Result<Self::EVM> {
Ok(EthEvmConfig::new_with_evm_factory(
ctx.chain_spec(),
OpenHlEvmFactory,
))
}
}
10 lines. The ExecutorBuilder trait is Reth's hook for swapping the EVM config used by EthereumNode. The associated type EVM = EthEvmConfig<ChainSpec, OpenHlEvmFactory> says "use Reth's standard EthEvmConfig, but parameterize over our factory." build_evm constructs that config.
The trait bound Node: FullNodeTypes<Types: NodeTypes<ChainSpec = ChainSpec, Primitives = EthPrimitives>> constrains what kind of node this builder works with — Ethereum mainnet primitives, our ChainSpec. Anything more exotic (Optimism, OP Stack) wouldn't satisfy these bounds; that's by design.
#[non_exhaustive] on both structs lets us add fields later without a breaking API change. They're unit structs now; if openhl ever needs them to carry configuration, the attribute means consumers can't construct them via OpenHlExecutorBuilder {} literal.
Step 7: Wire into crates/evm/src/lib.rs
Open crates/evm/src/lib.rs. Currently it has the bridges + reth_node + live_node modules from previous courses. Add two lines:
//! ... existing module doc ...
pub mod bridges; // existing
pub mod live_node; // existing (course 6+)
pub mod openhl_evm; // NEW
mod precompiles; // NEW (internal)
#[cfg(test)]
mod reth_node; // existing (test-only smoke)
pub use openhl_evm::{OpenHlEvmFactory, OpenHlExecutorBuilder}; // NEW
// ... existing re-exports ...
Two changes:
pub mod openhl_evm— visible to consumers.mod precompiles— internal, not exposed externally. Smart contracts call precompiles by address; consumers ofopenhl-evmdon't need to importopenhl_precompilesdirectly.
The re-export at the bottom (pub use openhl_evm::{OpenHlEvmFactory, OpenHlExecutorBuilder}) makes the two types accessible as openhl_evm::OpenHlEvmFactory from consumer code. Lesson 3's NodeBuilder integration will use them.
Test
cargo check -p openhl-evm
First run is slow — alloy-evm + the new Reth crates pull in non-trivial code. Expect ~30-60 seconds. Subsequent runs use the cache.
Expected:
Compiling openhl-evm v0.1.0 (.../crates/evm)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 32.45s
No warnings (the import list is long but every item is used). No errors.
Regression check for existing tests — Lesson 1 doesn't add tests for the new modules yet, but you should confirm that the dep additions and the lib.rs edits haven't broken the 39 tests carried over from prior courses (Course 6's bridges / live_node and Course 7's CLOB-bridge integration):
cargo test -p openhl-evm --release
39 tests should still pass. This isn't a test of Lesson 1's new logic — it's purely a regression check that the structural changes haven't broken existing behaviour. The first test for the new modules lands in Lesson 3 (precompile_is_callable_via_registry). If you only want to confirm the structure compiles at Lesson 1, cargo check -p openhl-evm alone is enough.
Common errors and fixes:
error[E0432]: unresolved import 'reth_node_api'— the inline git dep wasn't added. Re-check Step 2.error[E0277]: 'EvmFactory' is not implemented for 'OpenHlEvmFactory'(some associated type) — typo in one of the 8 associated types. Compare against the reference at SHA1761d4d. Most common:type Spec = SpecIdvstype Spec = u64or similar.error[E0282]: type annotations needed for 'PrecompilesMap'—PrecompilesMap::from_staticreturns a generic; the call site needs to know the type. In our case, thewith_precompiles(...)call provides the inference. If the compiler complains, double-check the imports.unused import: 'openhl_precompiles'— the function is referenced inprecompiles_for's closures. If you see this warning, you may have writtenPrecompiles::prague()directly instead ofopenhl_precompiles(Precompiles::prague()). Wrap each base set inopenhl_precompiles(...).
Design reflection
Three load-bearing decisions encoded here:
-
The factory pattern matches Reth's "many EVM instances" reality. Reth doesn't construct one EVM and reuse it — it creates a fresh EVM for every RPC call, every block validation, every payload build. The
EvmFactorytrait is how we hook into "every EVM creation" in one place. One factory, many EVMs, consistent precompile registration everywhere. -
OnceLockper spec is the right caching shape. Building aPrecompilesset is non-trivial (hashing addresses, inserting fns). Doing it percreate_evmcall would burn cycles. Per-spec caching means each hardfork tier (Prague, Cancun, fallback) is constructed once. TheOnceLockensures thread-safe lazy init. -
The passthrough
openhl_precompilesstub keeps Lesson 1 isolated. The function exists with the right signature; it does nothing yet. Lesson 2 fills the body. A stub with the right signature is a contract: callers (the factory) can be wired now, and the implementation can land later without changing the call site. This is incremental construction with no rewrites required.
Answer key
cd ~/code/openhl-reference
git checkout 1761d4d
diff -u ~/code/my-openhl/crates/evm/src/openhl_evm.rs ./crates/evm/src/openhl_evm.rs
diff -u ~/code/my-openhl/crates/evm/Cargo.toml ./crates/evm/Cargo.toml
diff -u ~/code/my-openhl/Cargo.toml ./Cargo.toml
The reference at 1761d4d has the full precompiles/mod.rs (Stage 9a's read precompile). Your stubbed version differs:
diff -u ~/code/my-openhl/crates/evm/src/precompiles/mod.rs ./crates/evm/src/precompiles/mod.rs
# Expected: your stub is much shorter than the reference; you'll see the
# difference as a big addition in the reference. Lesson 2 adds the missing content.
openhl_evm.rs should match closely (the factory structure is identical; only doc-comment wording may differ).
Return:
git checkout main
Common questions
Q: Why is the precompile module mod precompiles (private) but pub mod openhl_evm?
Because OpenHlEvmFactory is a public API consumers need (Lesson 3's NodeBuilder integration uses it), but openhl_precompiles is implementation detail consumed only by openhl_evm.rs internally. Keeping the precompile module private prevents API leakage; callers shouldn't construct or modify the precompile set themselves.
Q: What's the difference between Precompiles::from_static and Precompiles::default?
from_static takes a &'static Precompiles reference — meaning the precompile set is one we've cached and reuse. default builds a new (empty) Precompiles instance. Our create_evm uses from_static because the OnceLock-cached set is 'static. Caching + static reference = no allocation per EVM creation.
Q: Why is PRAGUE covering OSAKA too?
Osaka (the proposed next hardfork after Prague) doesn't introduce new standard precompiles as of the reference SHA. When Osaka eventually adds a new precompile, this match arm splits into separate OSAKA and PRAGUE branches. Until then, sharing the same OnceLock is correct.
Q: Does OpenHlExecutorBuilder need to be Clone?
The trait doesn't require Clone, but #[derive(Clone, Copy)] is cheap (it's a unit struct, zero-sized) and matches Reth's pattern. If you later add fields to the struct, you'd want to keep Clone for ergonomic API.
Next lesson (Lesson 2)
The factory is wired but the precompile module is a passthrough — Reth's standard precompiles are installed, no extra ones. Lesson 2 adds the first real precompile: clob_read_best_bid at address 0x...0c1b. It returns hardcoded values for now (the same approach as openhl Stage 9a). Lessons 4–5 will wire it to live CLOB state; this lesson just gets the function defined, registered, and reachable via the registry.
Summary (3 lines)
OpenHlEvmFactory= the hook for injecting precompiles + Database wrapper into every Reth EVM creation. ~100-line scaffold.NodeBuilder::with_evm_factoryregisters it. Reth's default EthEvmFactory is replaced.- Holds CLOB matching engine + fill sink refs (cheap Arc clones). Foundation for all subsequent precompiles. Next: first real precompile.