Skip to content
← Build OpenHL Precompiles — connecting CLOB state to smart contracts
Custom EVM bootstrap
Topic: Reth ChainSpec →
Lesson 2 of 12·CONTENT40 min80 XP
Course
Build OpenHL Precompiles — connecting CLOB state to smart contracts
Lesson role
CONTENT
Sequence
2 / 12

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 custom precompiles into every EVM creation. Build it as a ~115-line scaffold.

Principle (minimum model)

  • EvmFactory trait. Reth's extension point: fn create_evm<DB: Database>(&self, db: DB, input: EvmEnv) -> Self::Evm<DB, NoOpInspector>. Implement once; Reth calls it everywhere.
  • OpenHlEvmFactory struct. A field-less unit struct (#[non_exhaustive]). It holds no CLOB or fill-sink references — the precompiles reach CLOB state through process-global storage that later lessons add.
  • Precompile injection. Inside create_evm, build the EVM and call .with_precompiles(PrecompilesMap::from_static(precompiles_for(spec))). The set is the hardfork's standard precompiles extended with the custom CLOB ones.
  • No custom Database. create_evm passes Reth's db straight through; the factory only changes the precompile set.
  • NodeBuilder integration. OpenHlExecutorBuilder returns EthEvmConfig::new_with_evm_factory(ctx.chain_spec(), OpenHlEvmFactory); Lesson 3 plugs it in with EthereumNode::components().executor(OpenHlExecutorBuilder).
  • Tests. None in this lesson (cargo check only). Lesson 3 adds the Reth dev-node launch test and the registry unit tests.
  • 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 + ExecutorBuilder as 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) vs reth-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 OnceLock caching of Precompiles — building a precompile set is expensive (hashing addresses), create_evm is 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) -> Precompiles lets 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's EvmFactory slot) + OpenHlExecutorBuilder (Reth's ExecutorBuilder slot) + per-hardfork precompile dispatch via OnceLock. About 115 lines (doc comments included).
  • precompiles/mod.rs — a stub openhl_precompiles(base) -> Precompiles that passes through unchanged. Lesson 2 fills in the actual read precompile.

You also add 6 dependency lines (1 workspace-level alloy-evm; 5 in crates/evm [dependencies] — 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 the openhl CLOB course, crates/evm/src/ has:

crates/evm/src/
├── lib.rs
├── engine.rs                   consensus course: RethEvmBridge
├── in_memory.rs                consensus course: InMemoryEvmBridge
├── reth_node.rs                consensus course: bootstrap proof (tests only)
└── live_node.rs                consensus + CLOB courses: 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:

  1. Add alloy-evm = "0.34" to the workspace Cargo.toml. This is the public alloy-evm crate (not git-pinned to Reth) that provides EvmFactory, Database, EvmEnv, etc.
  2. Add 5 deps to crates/evm/Cargo.toml [dependencies]: reth-evm, reth-evm-ethereum, reth-node-api (new git dep — same SHA as the rest), reth-node-builder (until now only a dev-dep), and alloy-evm.
  3. Create crates/evm/src/openhl_evm.rs with OpenHlEvmFactory + OpenHlExecutorBuilder + precompiles_for(spec).
  4. Create crates/evm/src/precompiles/mod.rs as a passthrough stub.
  5. Wire pub mod openhl_evm; pub mod precompiles; into crates/evm/src/lib.rs.
  6. Re-export OpenHlEvmFactory and OpenHlExecutorBuilder at crate root for Lesson 3's NodeBuilder integration.
  7. cargo check -p openhl-evm — clean.

This is the dependency-heaviest lesson in this course. 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 Reth block (after the consensus course's live-Reth boot lesson and the end of the CLOB course) ends with:

reth-primitives-traits    = "0.3"
alloy-genesis             = { version = "2.0", default-features = false }

Add one line right after alloy-genesis:

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.

Step 2: Update crates/evm/Cargo.toml

Open crates/evm/Cargo.toml. After the CLOB course's bridge-fields lesson + the consensus course's live-bridge lesson, the [dependencies] section has 13 entries. We add 5 more:

[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 }
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-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 (also stays a dev-dep)
alloy-evm                = { workspace = true }                                                                                          # NEW

reth-node-builder is added to [dependencies] — production code (OpenHlExecutorBuilder) now uses it. Leave the [dev-dependencies] entry in place: the reference keeps reth-node-builder = { workspace = true, features = ["test-utils"] } there, because the dev-node tests need the test-utils feature (testing_node). [dev-dependencies] is unchanged in this lesson.

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 rev must match the rest of the reth-* crates exactly. When you inline rev = "88505c7...", the commit hash has to be identical to the one already pinned by the other reth-* 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 like expected ChainSpec, found ChainSpec. The temptation to "just bump reth-node-api to a newer rev" is strong; resist it unless you're upgrading every reth-* rev together. Operationally, confirm after cargo check -p openhl-evm that root Cargo.lock resolves 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's create_evm whenever it needs an EVM instance.
  • ExecutorBuilder — the trait we'll implement for OpenHlExecutorBuilder. Reth's NodeBuilder uses 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:

  1. Context::mainnet() — REVM's "Ethereum mainnet" preset (gas constants, etc.).
  2. .with_db(db) + .with_cfg(input.cfg_env) + .with_block(input.block_env) — install the database, config, and block env passed in.
  3. .build_mainnet_with_inspector(NoOpInspector {}) — construct the EVM with a no-op inspector (no tracing).
  4. .with_precompiles(PrecompilesMap::from_static(precompiles_for(spec))) — install our precompiles. precompiles_for(spec) returns the right precompile set for the current Ethereum hardfork.
  5. 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, using EthPrecompiles::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 engine, in_memory, live_node and reth_node modules from the earlier openhl courses. Add three lines:

//! ... existing module doc ...

pub mod engine;      // existing
pub mod in_memory;   // existing
pub mod live_node;   // existing (consensus course)
pub mod openhl_evm;  // NEW
pub mod precompiles; // NEW
pub mod reth_node;   // existing (its contents are tests only)

pub use engine::RethEvmBridge;               // existing
pub use in_memory::InMemoryEvmBridge;        // existing
pub use live_node::LiveRethEvmBridge;        // existing
pub use openhl_evm::{OpenHlEvmFactory, OpenHlExecutorBuilder};  // NEW

Two changes:

  • pub mod openhl_evm — visible to consumers.
  • pub mod precompiles — public, as in the reference. Smart contracts call precompiles by address, but the module also carries the address constants and (from Lesson 4) the install_clob / current_best_bid helpers that bridge code and tests call.

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 12 openhl-evm tests carried over from prior courses (the consensus course's bridges / live_node and the CLOB course's CLOB-bridge integration):

cargo test -p openhl-evm --release

12 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 tests for the new modules land in Lesson 3 (reth_dev_node_with_openhl_executor and registered_precompile_is_invokable_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 SHA 1761d4d. Most common: type Spec = SpecId vs type Spec = u64 or similar.
  • error[E0282]: type annotations needed for 'PrecompilesMap' — PrecompilesMap::from_static returns a generic; the call site needs to know the type. In our case, the with_precompiles(...) call provides the inference. If the compiler complains, double-check the imports.
  • unused import: 'openhl_precompiles' — the function is referenced in precompiles_for's closures. If you see this warning, you may have written Precompiles::prague() directly instead of openhl_precompiles(Precompiles::prague()). Wrap each base set in openhl_precompiles(...).

Design reflection

Three load-bearing decisions encoded here:

  1. 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 EvmFactory trait is how we hook into "every EVM creation" in one place. One factory, many EVMs, consistent precompile registration everywhere.

  2. OnceLock per spec is the right caching shape. Building a Precompiles set is non-trivial (hashing addresses, inserting fns). Doing it per create_evm call would burn cycles. Per-spec caching means each hardfork tier (Prague, Cancun, fallback) is constructed once. The OnceLock ensures thread-safe lazy init.

  3. The passthrough openhl_precompiles stub 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 are both pub mod openhl_evm and pub mod precompiles public? OpenHlEvmFactory / OpenHlExecutorBuilder are the API consumers plug into NodeBuilder (Lesson 3). precompiles is public in the reference too: it carries the address constants (CLOB_READ_BEST_BID, later CLOB_PLACE_ORDER) and, from Lesson 4, install_clob / uninstall_clob / current_best_bid, which bridge code and tests call. The precompile functions themselves (read_best_bid, place_order) stay non-pub.

Q: What's the difference between PrecompilesMap::from_static and building a fresh set? PrecompilesMap::from_static takes a &'static Precompiles reference — meaning the precompile set is one we've cached and reuse. Precompiles::default() would build a new (empty) 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? Because the reference maps SpecId::OSAKA to the Prague set (Precompiles::prague()). If Osaka-specific precompiles need to be active, this match arm would split into separate OSAKA and PRAGUE branches.

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 custom precompiles into every Reth EVM creation (per-spec sets cached in OnceLock). ~115-line scaffold.
  • OpenHlExecutorBuilder wraps it in EthEvmConfig::new_with_evm_factory; Lesson 3 plugs it in via EthereumNode::components().executor(OpenHlExecutorBuilder).
  • The factory is a field-less unit struct; openhl_precompiles(base: &Precompiles) -> Precompiles is the stable stub contract. Next: first real precompile.