FABRKNT
Step 3. Precompiles — EVM 拡張による CLOB ステートのスマートコントラクト連携
Custom EVM bootstrap
レッスン 3 / 12·CONTENT30 分60 XP
コース
Step 3. Precompiles — EVM 拡張による CLOB ステートのスマートコントラクト連携
レッスンの役割
CONTENT
順序
3 / 12

レッスン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::newAddress を要求)。「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)/ PrecompileResultResult<.., PrecompileError>、v0 は常に Ok)/ address! マクロ / AddressBytes

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_BIDpub(テスト/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![0u8; 64](u256×2 の ABI shape)、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 を黙らせる — PrecompileFnPrecompileResult を要求するので 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.rs1761d4d と機能的に同一。

合格基準

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行)

  • PrecompileFnfn(&[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。