レッスン2 — clob_read_best_bid — 最初の本物の precompile
問い
最初の本物の precompile をどう書くか? そして Solidity コントラクトがそのまま abi.decode できるよう、wire format をどう設計するか?
原理(最小モデル)
PrecompileFnシグネチャfn(&[u8], u64, u64) -> PrecompileResult。 関数ポインタ(クロージャでない)、3 引数(input / gas_limit / reservoir)固定。registry が関数ポインタを保持するのでこの形に正確に従う。- Solidity ABI の 32-byte slot レイアウト。
(uint256, uint256)は計 64 バイト big-endian、低位バイトは index 31/63。これに合わせれば Solidity がそのまま decode する。 - hardcoded stub が「接続テスト」と「内容テスト」を分割する。
(100, 10)を返す(unimplemented!()でなく)ことで、レッスン3 は precompile の 到達可能性 だけを単独検証できる。 base.clone()で extend-not-replace / address は pub・gas は private。 標準セット(ECDSA/SHA-256)を保つ。caller は address を要る(call するため)、gas は EVM が内部処理(caller は知らなくてよい)。
具体例
64-byte buffer のレイアウト(big-endian u256 ×2、実値は右端に着地):
byte: 0 .. 31 | 32 .. 63
[00..00 64]| [00..00 0a] ← index 31 = price(100=0x64), index 63 = qty(10=0x0a)
第1スロット price (u256) 第2スロット qty (u256)
u256 は 32 バイト固定幅。小さい数でも左(高位)はゼロパディング、右端(低位)に実値。
失敗例(誤解)
「8 バイト(u32×2)返せば十分」は誤り — Solidity の returns(uint256, uint256) は値が小さくても 常に各 32 バイト。8 バイトだと malformed な uint256 として revert する。「address を [u8;20] で」も誤り(Precompile::new は Address を要求)。「unimplemented!() にしておく」も誤り(レッスン3 の接続テストが panic し「呼べるか」と「正しい値か」が切り分けられない)。
ここまでで「PrecompileFn の形・ABI レイアウト・hardcoded の意図」は着地した。ここから precompiles/mod.rs を完成させる。コードは完全形(live state 接続はレッスン4-5)。
🛑 予測。 call は 64 バイト(u256×2)を返す。price も qty も u32 に収まるのに、なぜ 8 バイトでなく 64 バイトか? ヒント: Solidity がネイティブに返す型。(答え: ABI は
uint256を必要 bit 数に関わらず常に 32 バイトに pad する。8 バイトだと Solidity が malformed として revert。wire format は内部表現でなく Solidity ABI に合わせる。)
ステップで組み立てる
Step 1: import 拡張
use alloy_evm::revm::precompile::{
Precompile, PrecompileId, PrecompileOutput, PrecompileResult, Precompiles,
};
use alloy_primitives::{address, Address, Bytes};
Precompile(Address + PrecompileFn の wrapper)/ PrecompileId(識別子、tracing 用)/ PrecompileOutput(成功型、消費 gas + 出力 + reserve)/ PrecompileResult(Result<.., PrecompileError>、v0 は常に Ok)/ address! マクロ / Address・Bytes。
Step 2: 定数(address は pub、gas は private)
/// 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;
CLOB_READ_BEST_BID は pub(テスト/caller が call する)、0x0c1b は「CLB」ニーモニックで標準 precompile(1-9)と衝突しない。CLOB_BASE_GAS_COST は private(EVM が dispatch 中に処理)。
Step 3: read_best_bid 関数
/// 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))
}
vec、out[31]=100/out[63]=10(big-endian の低位バイト)、PrecompileOutput::new(gas, Bytes, reservoir=0)。3 引数すべて _ 接頭辞(v0 は input/gas_limit/reservoir を使わない)。#[allow(clippy::unnecessary_wraps)] は「常に Ok なら unwrap した型を返せ」lint を黙らせる — PrecompileFn が PrecompileResult を要求するので unwrap できない。
Step 4: openhl_precompiles を完成版に
/// 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
}
base.clone()(&Precompiles を所有権付き mutable に)→ extend([Precompile::new(id, address, fn)]) → return。Precompile::new は (PrecompileId, Address, 関数) から作る。レッスン7 で clob_place_order 用に 2 つ目を同じパターンで追加。
答え合わせ
cd ~/code/openhl-reference && git checkout 1761d4d
diff -u ~/code/my-openhl/crates/evm/src/precompiles/mod.rs ./crates/evm/src/precompiles/mod.rs
git checkout main
本レッスン後、precompiles/mod.rs は 1761d4d と機能的に同一。
合格基準
cargo check -p openhl-evm
grep -r "CLOB_READ_BEST_BID" crates/evm/src/ # const 宣言を確認
precompile は callable だが dumb(book 状態に関わらず同じ答え)。callable 証明はレッスン3、smart 化はレッスン4-5。よくあるミス: import パスのタイポ / address! マクロ(小文字)を import 忘れ / index 31/63 の取り違え。
まとめ(3行)
PrecompileFnはfn(&[u8], u64, u64) -> PrecompileResult固定 — v0 は引数を使わないので_接頭辞。- wire format は Solidity ABI に合わせる(
(u256,u256)=64 バイト、実値は index 31/63)— 内部表現でなく ABI shape。 - hardcoded stub が「接続テスト(レッスン3)」と「内容テスト(レッスン4-6)」を分割する。
base.clone()で extend-not-replace。