Lesson 2 — clob_read_best_bid — the first real precompile
Question
Build the first real precompile: clob_read_best_bid returns the best bid for a market. Hardcoded for now (Lesson 5 swaps to live CLOB). Sets the pattern: address + decode calldata + return ABI-encoded data.
Principle (minimum model)
- Precompile address.
0xCL000000000000000000000000000000000000B1. The "CL" prefix marks it as openhl. Address is fixed; can't collide with deployed contracts. - Calldata schema.
(uint8 market_id). Decode with<u8>::abi_decode(&input, true)?. - Hardcoded return. For this lesson, return
Price::from(1_000_000_00)($1.00 in 8 decimals). Real CLOB read comes in Lesson 5. - Return encoding.
Price::abi_encode()→ 32 bytes. Solidity caller reads asuint256. - Gas charge.
precompile_call_cost = 100 + 32 * data_size_in_words. Standard scheme. - Test. Solidity test calls the precompile, asserts returns
1_000_000_00. Foundry forked-anvil harness. - Why hardcoded first. Establishes the pattern (decode + return) before adding the CLOB integration complexity. Three lessons later it goes live.
Worked example + steps
Lesson 2 — clob_read_best_bid — the first real precompile
Goal
Concepts you'll grasp in this lesson:
- REVM's
PrecompileFnsignaturefn(&[u8], u64, u64) -> PrecompileResult— a function pointer (not closure), with three fixed args (input, gas_limit, reservoir); your precompile must conform exactly because the registry stores function pointers. - Solidity ABI's 32-byte slot layout —
(uint256, uint256)is 64 bytes total, big-endian, low-order byte at index 31/63 — matching this at the wire format means a Solidity contract canabi.decodeyour output directly. - Hardcoded stub as a wiring-vs-content split — returning
(100, 10)(notunimplemented!()) lets Lesson 3 test the reachability of the precompile in isolation from "does it return the right data" (Lessons 4–6's job). extend-not-replaceviabase.clone()— wrapping the standard precompile set means ECDSA recovery / SHA-256 / etc. stay registered; a freshPrecompiles::default()would silently delete them.pubconst for the address, private const for gas cost — callers need to call the precompile (need the address); the EVM dispatches gas internally (callers don't need the cost). Visibility matches API surface.
Verification:
cargo check -p openhl-evm
…still compiles.
Specific changes:
Your precompiles/mod.rs is now the full Stage 9a version:
- A constant
CLOB_READ_BEST_BID: Address = 0x...0c1b— the precompile's address. - A constant
CLOB_BASE_GAS_COST: u64 = 500— minimum gas charged per precompile call. - A function
read_best_bid(input, gas_limit, reservoir) -> PrecompileResultreturning a hardcoded(price=100, qty=10)as 64 bytes. - The
openhl_precompilesfunction (no longer passthrough) extends the base set with our new precompile.
About 40 LOC added. The precompile is registered but not yet wired to live CLOB state — it returns hardcoded values. That's intentional: Lesson 3 tests that the precompile is reachable from EVM execution; Lessons 4–5 swap the hardcoded values for live CLOB reads. Function first, content later — the same incremental pattern as Lesson 1's passthrough.
Recap
After Lesson 1:
// crates/evm/src/precompiles/mod.rs (passthrough stub)
pub fn openhl_precompiles(base: &Precompiles) -> Precompiles {
base.clone()
}
The function signature is fixed (Lesson 1 set the contract); the body just clones the input. Lesson 2 changes the body — same signature, more work inside.
Plan
Four things, all in crates/evm/src/precompiles/mod.rs:
- Expand the imports — add
Precompile,PrecompileId,PrecompileOutput,PrecompileResultfromalloy_evm::revm::precompile, andaddress,Address,Bytesfromalloy_primitives. - Add the address constant —
CLOB_READ_BEST_BID: Address = 0x000...0c1b. Public, so consumers (and tests) can call this precompile by name. - Add the gas-cost constant + the
read_best_bidfunction — private. The function returns a hardcoded(price=100, qty=10)ABI-encoded as 64 bytes. - Replace the passthrough —
openhl_precompilesclones the base set, thenextends with the new precompile registration.
The precompile is callable after this lesson but dumb — returns the same answer regardless of book state. Lesson 3 proves it's callable; Lessons 4–5 make it smart.
(Answer: Solidity's ABI encoding for returns(uint256, uint256) is 64 bytes — each value is always 32 bytes regardless of how many bits it actually needs. Our u64 price fits in 8 bytes but the ABI pads it to 32. If we returned 8 bytes, Solidity would interpret it as a malformed uint256 and likely revert. The wire format matches Solidity's ABI, not our internal representation.)
Walk-through
Step 1: Expand the imports
Open crates/evm/src/precompiles/mod.rs. The current imports (from Lesson 1) are:
use alloy_evm::revm::precompile::Precompiles;
Replace with:
use alloy_evm::revm::precompile::{
Precompile, PrecompileId, PrecompileOutput, PrecompileResult, Precompiles,
};
use alloy_primitives::{address, Address, Bytes};
Six new types/macros:
Precompile— the wrapper that pairs anAddresswith aPrecompileFn. The Precompiles set stores these.PrecompileId— an identifier (mainly for debugging / tracing). UsePrecompileId::custom("clob_read_best_bid").PrecompileOutput— the success type returned from a precompile. Carries gas spent + output bytes + remaining gas reserve.PrecompileResult—Result<PrecompileOutput, PrecompileError>. Our v0 never errors so we always returnOk(...).addressmacro —address!("0x...")creates a constAddressat compile time.Address,Bytes— the two byte-array types used everywhere in EVM code.
Step 2: Add the precompile address constant
After the imports, before any functions, add:
/// Address of the "read best bid" precompile.
///
/// Solidity call shape: `staticcall(gas, 0x...0c1b, calldata=empty, ...) → (price: u256, qty: u256)`
pub const CLOB_READ_BEST_BID: Address = address!("0x0000000000000000000000000000000000000c1b");
/// The minimum gas charge for invoking a CLOB precompile. Tuned later.
const CLOB_BASE_GAS_COST: u64 = 500;
Two constants:
CLOB_READ_BEST_BID—pub, because tests (Lesson 3) and downstream callers need to call this address. The0x...0c1bis a mnemonic for "CLB" (CLOB). Conventions:- addresses
1-9are Ethereum's standard precompiles (ECDSA recovery, SHA-256, etc.) - we stay at 0x0c1b+ to avoid collisions
- addresses
CLOB_BASE_GAS_COST— private, an internal cost number. 500 gas is the per-call charge for any CLOB precompile. The real EVM math also charges memory expansion + per-byte cost; this is just the base.
The pub vs private split is intentional. Outside callers care about the address (to call the precompile); they don't care about the gas cost (the EVM handles that during dispatch).
Step 3: Write the read_best_bid function
Below the constants:
/// Stage 9a stub: returns a hardcoded best bid so the precompile is callable
/// without requiring live CLOB state injection. Stage 9b replaces this with
/// an `Arc<Mutex<Book>>`-aware closure captured into the precompile.
///
/// `PrecompileFn` signature is `fn(&[u8], u64, u64) -> PrecompileResult`;
/// the third arg is a `reservoir` value (extra gas budget) that we ignore
/// at v0.
///
/// Encoding: 64 bytes total
/// bytes 0..32 big-endian u256 price (hardcoded 100)
/// bytes 32..64 big-endian u256 qty (hardcoded 10)
// `PrecompileFn` signature mandates the `PrecompileResult` (i.e. `Result`)
// return type. Our v0 stub never errors — gas accounting is the EVM's
// responsibility — but the wrapper is structurally required.
#[allow(clippy::unnecessary_wraps)]
fn read_best_bid(_input: &[u8], _gas_limit: u64, _reservoir: u64) -> PrecompileResult {
let mut out = vec![0u8; 64];
// price = 100 (big-endian u256, rightmost byte holds the value)
out[31] = 100;
// qty = 10
out[63] = 10;
Ok(PrecompileOutput::new(CLOB_BASE_GAS_COST, Bytes::from(out), 0))
}
Walk the body:
-
vec![0u8; 64]— 64 zero bytes. The ABI shape for(uint256, uint256)is two 32-byte blocks. -
out[31] = 100— write the price (100) at the rightmost byte of the first 32-byte block. Big-endian u256 means the high-order bytes are zero and the low-order byte (index 31) holds the actual value. Same for qty at index 63.Drawing the entire 64-byte buffer in one picture makes it visually clear why index 31 and 63 are the "actual write points":
┌───── slot 1: price (u256, big-endian) ─────┐ ┌───── slot 2: qty (u256, big-endian) ─────┐ byte index: 0 1 2 ... 29 30 31 32 33 ... 60 61 62 63 ┌────┬────┬────┬───┬────┬────┬────┐ ┌────┬────┬───┬────┬────┬────┬────┐ value (hex): │ 00 │ 00 │ 00 │...│ 00 │ 00 │ 64 │ │ 00 │ 00 │...│ 00 │ 00 │ 00 │ 0a │ └────┴────┴────┴───┴────┴────┴────┘ └────┴────┴───┴────┴────┴────┴────┘ ↑ ↑ ↑ ↑ ↑ ↑ │ │ │ │ │ │ high ← (zero padding) ← low (LSB) high ← (zero padding) ← low (LSB) 100 = 0x64 10 = 0x0a (price lands here) (qty lands here)The rule: u256 is a fixed 32-byte big-endian width. Even when the actual value fits in
u64(8 bytes) oru32(4 bytes), the high-order side is zero-padded and the actual value lands at the rightmost (least-significant) byte — index 31 and 63 in our buffer. Matching the wire format to Solidity's(uint256, uint256)layout is just following this one picture. -
PrecompileOutput::new(CLOB_BASE_GAS_COST, Bytes::from(out), 0)— build the output:- First arg: gas spent (we charge 500).
- Second arg: output bytes (the 64-byte buffer).
- Third arg: reservoir (extra budget); we use 0.
The three function arguments are all _-prefixed (unused) because the v0 stub:
- Doesn't read input (the call has empty calldata).
- Doesn't respect gas_limit (the EVM handles overflow checking).
- Ignores reservoir (advanced feature we don't need).
#[allow(clippy::unnecessary_wraps)] silences the lint that says "this function always returns Ok(...), just return the unwrapped type." We can't unwrap because the PrecompileFn trait signature requires PrecompileResult. The lint is wrong here; the attribute is the right response.
Step 4: Replace the passthrough openhl_precompiles
Find the current passthrough function:
#[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()
}
Replace with the full implementation:
/// Build a `Precompiles` set that extends Reth's standard precompiles with
/// openhl's CLOB-reading additions. The base set is parameterized over the
/// hardfork's spec id so we inherit Ethereum's evolution (e.g., the
/// BLS-12-381 precompiles activated in Prague).
#[must_use]
pub fn openhl_precompiles(base: &Precompiles) -> Precompiles {
let mut precompiles = base.clone();
precompiles.extend([Precompile::new(
PrecompileId::custom("clob_read_best_bid"),
CLOB_READ_BEST_BID,
read_best_bid,
)]);
precompiles
}
Three lines of body:
let mut precompiles = base.clone()— start with the base set. We can't mutatebasedirectly (it's&Precompiles); cloning is the only way to get an owned, mutable copy.precompiles.extend([Precompile::new(...)])— add our precompile to the set.extendaccepts an iterator ofPrecompiles; passing an array of length 1 works because arrays implementIntoIterator.- Return
precompiles— ownedPrecompileswith our addition included.
The Precompile::new(...) call creates a new entry from three pieces:
- A
PrecompileId(the human-readable name, useful for debugging/tracing). - The
Addressit's registered at. - The function to call.
Lesson 7+ will add a second Precompile::new(...) for clob_place_order. The pattern stays: clone, extend, return.
Test
cargo check -p openhl-evm
Still clean. The precompile is now registered, but no test exercises it yet — that's Lesson 3.
Optionally, you can verify the precompile address is exported correctly:
grep -r "CLOB_READ_BEST_BID" crates/evm/src/
# Should report: precompiles/mod.rs declares the const
Common errors and fixes:
error[E0432]: unresolved import 'alloy_evm::revm::precompile::Precompile'— typo in the import list. The correct path isalloy_evm::revm::precompile::{Precompile, PrecompileId, PrecompileOutput, PrecompileResult, Precompiles}.error: expected struct, found macro 'address'— you importedaddressfrom the wrong place. It's theaddress!macro fromalloy_primitives; make sure the import list includesaddress(lowercase, the macro).out[31] = 100u8overflow lint —100is alreadyi32, the conversion tou8is fine, but if clippy complains, writeout[31] = 100;(no type annotation needed).out[63] = 10not appearing in the assertion — yourread_best_bidis reading from the wrong index. Double-check that index 31 is for price (first 32 bytes) and index 63 is for qty (second 32 bytes).#[allow(clippy::unnecessary_wraps)]clippy still complains — the attribute needs to be on the function, not on a containing block. Place it directly abovefn read_best_bid(...).
Design reflection
Three load-bearing decisions encoded here:
-
The address constant is
pub; the gas-cost constant is private. External callers (tests, smart contracts) need to know where to call the precompile. They don't need to know how much it costs — the EVM handles that internally. Public vs private mapping reflects the API surface. -
The function takes
(&[u8], u64, u64)— all unused at v0. ThePrecompileFntrait fixes the signature; we have to accept those arguments even when we don't use them. The underscore-prefix convention (_input,_gas_limit,_reservoir) tells the compiler "we know they're here, we don't need them yet." Lesson 7+ uses_inputto decode order data. -
The 64-byte output is ABI-shaped, not internally-shaped. A 64-bit price could fit in 8 bytes, but Solidity expects
(uint256, uint256)as 64 bytes total. Matching the ABI at the wire format means we can writeread_best_bid()in Solidity directly. The internalQty(u64)types are an implementation detail.
Answer key
cd ~/code/openhl-reference
git checkout 1761d4d
diff -u ~/code/my-openhl/crates/evm/src/precompiles/mod.rs ./crates/evm/src/precompiles/mod.rs
After Lesson 2, your precompiles/mod.rs should be functionally identical to the reference at 1761d4d. Only doc-comment wording will differ.
Return:
git checkout main
Common questions
Q: Why PrecompileId::custom("clob_read_best_bid") and not just an enum variant?
Because PrecompileId is an opaque identifier mostly used by REVM's logging/tracing layer. Custom precompiles use string names because they're outside the standard set. The string is human-readable; if a precompile call shows up in a trace, you see "clob_read_best_bid" not a numeric variant.
Q: What if I want to add error handling?
Change the return path from Ok(...) to Err(PrecompileError::Other(...)). The trait already supports this; we just don't have failure modes at v0. When the read precompile gains live state (Lessons 4–5), one possible error is "CLOB lock is poisoned" — that would map to PrecompileError.
Q: Why is Bytes::from(out) needed — can I return Vec<u8> directly?
No, the trait wants Bytes (alloy's reference-counted byte buffer, not Rust's std Vec<u8>). Bytes::from(vec) does the conversion. The reason for the wrapper type: Bytes can be cheaply cloned and shared across the EVM internals without re-allocating.
Q: Could a smart contract pass arguments via calldata to read_best_bid?
Yes — calldata is the _input parameter. At v0 the precompile ignores it (returns the best bid regardless), but production code would use calldata to specify which market's best bid to read. The current setup is single-market; multi-market support would add _input decoding.
Next lesson (Lesson 3)
The precompile is registered but untested. Lesson 3 wires the executor builder into NodeBuilder + a smoke test that boots a Reth node with our custom EVM and verifies the precompile is callable at CLOB_READ_BEST_BID. The test is small (~60 LOC) but exercises the full toolchain: custom EVM, executor builder, NodeBuilder integration, EVM call dispatch, precompile registry lookup. After Lesson 3, we have a Reth node where smart contracts can call 0x...0c1b and get back (100, 10).
Summary (3 lines)
clob_read_best_bidat0xCL...B1. Decodeuint8 market_id; return hardcoded Price; ABI-encode response.- Gas charge =
100 + 32 * data_size_in_words. Standard precompile scheme. - Foundry forked-anvil test asserts the precompile returns the hardcoded value. Next: NodeBuilder wiring + registry tests.