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

レッスン7 — clob_place_order — calldata デコード scaffold

問い

read precompile(0x...0c1b)でコントラクトは CLOB を 読める ようになった。では 書く には? write 側 precompile を、挙動より先に「呼べる schema」として固定するにはどうするか?

原理(最小モデル)

  • schema-first:calldata layout は public な契約。挙動より先にロックする。 0x...0c1c に露出した瞬間からコントラクトが呼ぶ。レッスン7 で入力 layout を固定すれば、レッスン8 で挙動を足しても caller を壊さない。
  • 128-byte ABI 入力 = 32-byte slot 4 個。 Solidity ABI は scalar を 32-byte word に詰める。u64 は右端 8 byte([0;24] + [u64 BE])。4 word = account_id / side / price / qty
  • precompile は panic でなく soft fail。 4 つの rejection path(長さ不足・無効 side byte・qty==0・CLOB 未 install)は sentinel 0 を返し tx を revert しない。caller は EVM-level error でなく分岐可能な値を受け取る。
  • AtomicU64::fetch_add(1, Relaxed) で ID 採番。 ID に必要なのは一意性だけ(atomic が保証)。他 state との同期不変条件は不要(Book が自前 Mutex を持つ)→ 最弱の ordering を選ぶ。Mutex<u64> だと order 発注が 1 クリティカルセクションに直列化される。
  • sentinel 0 には 1 始まりが必須。 ID が 0 始まりだと最初の成功 order も 0 を返し「rejected」と区別できない。1 始まりなら割当 ID は必ず > 00 は曖昧さなく rejection。

具体例

128-byte calldata のメモリ配置(4 × 32-byte slot、値は各 slot 右端に右寄せ):

  slot 0 (input[ 0.. 32])  account_id   bytes  24..32  = u64 BE  ← u64_from_be_chunk(&input[0..32])
  slot 1 (input[32.. 64])  side         byte   63      = u8      ← side_byte = input[63]
  slot 2 (input[64.. 96])  price        bytes  88..96  = u64 BE  ← u64_from_be_chunk(&input[64..96])
  slot 3 (input[96..128])  qty          bytes 120..128 = u64 BE  ← u64_from_be_chunk(&input[96..128])

u64 を載せる slot は絶対 byte 位置 [32×N + 24 .. 32×N + 32]。side は slot 1 の最右端 1 byte(32×1 + 31 = 63)。パーサが [24..32] を拾う理由も、テストヘルパーが buf[88..96] を price に使う理由も、すべてこの zero pad / value 境界 — マジックでなく ABI 規約 + 算数。

失敗例(誤解)

「まだ使わない account_id / price を parse する必要はない」は誤り — レッスン7 の仕事は schema を確定する こと。全フィールドを parse する形がそのまま契約になり、レッスン8 で parse 対象を変えると間にビルドされた全コントラクトが壊れる(未使用 binding は _ 接頭辞で許容する)。「malformed input は panic でよい」も誤り — panic は precompile error として tx revert に伝播する。caller にハンドリング余地(ログ・リトライ・表示)を残すには sentinel 0 を返す。


ここまでで schema-first・soft fail・atomic 採番は着地した。ここから precompile を組み立てる(read_best_bid と read 側のテストには手を入れない — 純粋に追加)。ただし book.submit(...) はまだ呼ばない(それはレッスン8)。コードは完全形。レッスン7 は書き込みパスの scaffold レッスン — read 側の レッスン2 に相当する。

🛑 予測。 read precompile は空入力(&[])で 64 byte を返した。place_order は 128 byte 入力で 32 byte を返す。なぜ Solidity は u64 を 32 byte に pad するか?(答え: ABI は 1 slot = 固定 32 byte。f(uint64,uint8,uint64,uint64) は pack せず 4×32=128 byte を割当て、各値は slot 内で右寄せ。precompile は通常の call opcode で呼ばれ同じ規約に従う。パーサは各 slot の意味ある 8 ないし 1 byte だけ読む。)

ステップで組み立てる

Step 1: import 拡張

openhl_clob import を拡張してマッチングエンジン型を引き込み、std::sync に atomic を追加する(型はレッスン8 で Order を組み立てるのに使う — import は今のうちに入れて diff をレッスン7 の関心事に絞る):

use alloy_evm::revm::precompile::{
    Precompile, PrecompileId, PrecompileOutput, PrecompileResult, Precompiles,
};
use alloy_primitives::{address, Address, Bytes};
use openhl_clob::{AccountId, Book, Order, OrderId, OrderType, Price, Qty, Side};
use std::sync::{
    atomic::{AtomicU64, Ordering},
    Arc, Mutex, RwLock,
};

Step 2: アドレス定数 + 原子カウンタ

CLOB_READ_BEST_BID の後ろに(ニーモニック 0c1c = 「CL[ob] [pla]C[e]」、0c1b の隣):

/// Address of the "place order" precompile (write path — Stage 9c).
///
/// Solidity call shape (ABI-aligned 128-byte input):
/// `call(gas, 0x...0c1c, calldata=(uint64 account, uint8 side, uint64 price, uint64 qty), ...) → uint256 order_id`
///
/// `side` encoding: 0 = Buy, 1 = Sell. Any other value → call returns 0
/// (rejected, no state change). Order type is hardcoded to Limit at v0.
///
/// Return: 32 bytes; the last 8 are a big-endian u64 `order_id`. A return
/// of 0 means the order was rejected (no CLOB installed, malformed input,
/// or invalid side byte) — distinguishable from "placed" because allocated
/// IDs start at 1.
pub const CLOB_PLACE_ORDER: Address = address!("0x0000000000000000000000000000000000000c1c");

CLOB_BASE_GAS_COST の後ろに:

/// Monotonic order-ID counter for orders placed via the EVM. Starts at 1
/// so the sentinel value 0 (returned on rejection) is distinguishable from
/// a successfully placed order.
///
/// **Single-validator caveat:** This is a process-global counter. For
/// multi-validator deployments, order IDs must come from consensus —
/// each validator's precompile must allocate the same ID for the same
/// EVM-side call, which means the counter has to be either deterministic
/// from input or read from a shared block-scoped state. Out of scope at v0.
static NEXT_ORDER_ID: AtomicU64 = AtomicU64::new(1);

焼き込んだ決定 2 つ: ① 0 でなく 1 始まり0 を rejection sentinel に使うため。0 始まりだと最初の成功 order が 0 を返して区別できない。② Mutex<u64> でなく AtomicU64fetch_add は wait-free、Mutex::lock はブロックする。ID 採番は発注の hot path に乗るので、mutex だと全発注が 1 クリティカルセクションに直列化される。multi-validator では ID を consensus から採るべき(doc コメントに silent な chain-divergence 失敗モードを明記しておく)— v0 スコープ外。

Step 3: u64_from_be_chunk ヘルパー

read_best_bid の下、openhl_precompiles の上に:

/// Read a big-endian u64 from the last 8 bytes of a 32-byte ABI chunk.
fn u64_from_be_chunk(chunk: &[u8]) -> u64 {
    debug_assert!(chunk.len() == 32);
    let mut buf = [0u8; 8];
    buf.copy_from_slice(&chunk[24..32]);
    u64::from_be_bytes(buf)
}

debug_assert! で長さチェック(release では消える)、from_be_bytes[u8; 8] を要求するので 8 byte をスタックバッファにコピー、外から使わないので private fntry_into().unwrap() でも release では同一命令にコンパイルされる — 名前付きヘルパーが節約するのは認知負荷。

Step 4: place_order 関数

read_best_bid の下、u64_from_be_chunk の上に:

/// Place a limit order on the installed CLOB. The write counterpart to
/// `read_best_bid` — completes the EVM ↔ CLOB bidirectional surface.
///
/// Calldata layout (ABI-aligned, 128 bytes):
/// ```text
///   [  0.. 32]  account_id  (u64 in last 8 bytes)
///   [ 32.. 64]  side        (u8 in last byte: 0 = Buy, 1 = Sell)
///   [ 64.. 96]  price       (u64 in last 8 bytes)
///   [ 96..128]  qty         (u64 in last 8 bytes)
/// ```
///
/// Returns 32 bytes: the allocated `order_id` in the last 8 bytes, or zero
/// on rejection (no CLOB installed, malformed input, invalid side byte).
/// Allocated IDs start at 1, so zero is unambiguously "rejected".
///
/// NOTE: this scaffold parses + validates + allocates an order_id, but does
/// NOT yet submit the order to the book — that one line lands next.
#[allow(clippy::unnecessary_wraps)]
fn place_order(input: &[u8], _gas_limit: u64, _reservoir: u64) -> PrecompileResult {
    let mut out = vec![0u8; 32];

    // Need exactly 128 bytes of input (4 × ABI-padded fields).
    if input.len() < 128 {
        return Ok(PrecompileOutput::new(CLOB_BASE_GAS_COST, Bytes::from(out), 0));
    }

    let _account_id = u64_from_be_chunk(&input[0..32]);
    let side_byte = input[63];
    let _price_value = u64_from_be_chunk(&input[64..96]);
    let qty_value = u64_from_be_chunk(&input[96..128]);

    let _side = match side_byte {
        0 => Side::Buy,
        1 => Side::Sell,
        _ => return Ok(PrecompileOutput::new(CLOB_BASE_GAS_COST, Bytes::from(out), 0)),
    };

    // Reject orders with zero quantity outright — the book accepts them
    // technically, but a zero-qty order is always a bug from the caller.
    if qty_value == 0 {
        return Ok(PrecompileOutput::new(CLOB_BASE_GAS_COST, Bytes::from(out), 0));
    }

    let state = CLOB_STATE.read().expect("CLOB_STATE rwlock poisoned");
    if state.as_ref().is_none() {
        // No CLOB installed → 0 sentinel.
        return Ok(PrecompileOutput::new(CLOB_BASE_GAS_COST, Bytes::from(out), 0));
    }
    drop(state); // released early — the submit line lands next lesson

    let order_id_val = NEXT_ORDER_ID.fetch_add(1, Ordering::Relaxed);

    // Scaffold stops here. Next: clob.lock().submit(Order { ... }).

    out[24..32].copy_from_slice(&order_id_val.to_be_bytes());
    Ok(PrecompileOutput::new(CLOB_BASE_GAS_COST, Bytes::from(out), 0))
}

5 つの逐次ステップ、rejection はそれぞれ 早期 return(ネストした if にせず happy path を線形に保つ)。_account_id/_price_value/_side_ は「parse 済み、まだ使わない」マーカー(レッスン8 で外す)。冒頭 < 128 の長さチェックは guard — 以降の input[X] を provably safe にする(アクセスごとの bounds-check も panic リスクもなし)。drop(state) を ID 採番前に置くのは read lock 保持窓を縮めるため(read lock を握ったまま実行すると、その間ずっと他主体の install_clob をブロックする)。

🛑 やりがちな勘違い。 「なぜ Ordering::RelaxedSeqCst でないのか?」 — ID は他 state と ordering 依存を持たないから。Relaxed は atomicity(2 スレッドが同じ ID を得ない)は保証するが他メモリ操作との同期はしない。ID を book への書き込みと順序づける必要はない(book は自前 mutex を持ち、それが可視性順序を提供する)。SeqCst は increment ごとにフェンスを足すだけで得るものがない。必要な不変条件のうち最弱の ordering を選ぶ。

Step 5: openhl_precompiles を両方登録

extend に precompile を 2 つ(要素 2 つの配列)渡す:

#[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,
        ),
        Precompile::new(
            PrecompileId::custom("clob_place_order"),
            CLOB_PLACE_ORDER,
            place_order,
        ),
    ]);
    precompiles
}

doc コメントも「CLOB-reading additions」→「CLOB-reading + CLOB-writing additions」に更新(今やらないとコードと乖離する)。

Step 6: 3 テスト + ヘルパー

#[cfg(test)] mod tests 内、レッスン6 のラウンドトリップテストの後に:

    /// Helper: build a 128-byte ABI-aligned `place_order` calldata buffer.
    fn place_order_calldata(account: u64, side: u8, price: u64, qty: u64) -> Vec<u8> {
        let mut buf = vec![0u8; 128];
        buf[24..32].copy_from_slice(&account.to_be_bytes());
        buf[63] = side;
        buf[88..96].copy_from_slice(&price.to_be_bytes());
        buf[120..128].copy_from_slice(&qty.to_be_bytes());
        buf
    }

    /// With no CLOB installed, `place_order` rejects (returns sentinel 0).
    #[test]
    fn place_order_returns_zero_when_no_clob_installed() {
        let _g = TEST_SERIALIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
        uninstall_clob();

        let calldata = place_order_calldata(42, 0, 100, 5);
        let result = place_order(&calldata, 100_000, 0).expect("precompile must not error");
        let order_id = U256::from_be_slice(&result.bytes[0..32]);
        assert_eq!(order_id, U256::ZERO);
    }

    /// `place_order` with bad input (too short, invalid side byte, zero qty)
    /// rejects — returns the sentinel 0.
    ///
    /// NOTE: this test only checks the return value. A `book.depth_bid() == 0`
    /// side-effect assertion lands once submit is wired in.
    #[test]
    fn place_order_rejects_malformed_input() {
        let _g = TEST_SERIALIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
        install_clob(Arc::new(Mutex::new(Book::new())));

        // Too short.
        let r = place_order(&[0u8; 64], 100_000, 0).unwrap();
        assert_eq!(U256::from_be_slice(&r.bytes[0..32]), U256::ZERO, "short input rejects");

        // Invalid side byte.
        let bad_side = place_order_calldata(42, 7, 100, 5);
        let r = place_order(&bad_side, 100_000, 0).unwrap();
        assert_eq!(U256::from_be_slice(&r.bytes[0..32]), U256::ZERO, "bad side byte rejects");

        // Zero qty.
        let zero_qty = place_order_calldata(42, 0, 100, 0);
        let r = place_order(&zero_qty, 100_000, 0).unwrap();
        assert_eq!(U256::from_be_slice(&r.bytes[0..32]), U256::ZERO, "zero qty rejects");

        uninstall_clob();
    }

    /// `place_order` on the happy path returns a non-zero order ID.
    ///
    /// NOTE: this only proves we **return** a non-zero ID; coverage extends to
    /// prove the order is actually visible on the book in the round-trip test.
    #[test]
    fn place_order_returns_nonzero_id_on_valid_input() {
        let _g = TEST_SERIALIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
        install_clob(Arc::new(Mutex::new(Book::new())));

        let calldata = place_order_calldata(0xABCD, 0, 175, 12);
        let result = place_order(&calldata, 100_000, 0).expect("precompile must not error");
        let order_id = U256::from_be_slice(&result.bytes[0..32]);
        assert!(order_id > U256::ZERO, "allocated id must be > 0 sentinel");

        uninstall_clob();
    }

ヘルパーは 4 つの論理値から 128-byte バッファを組み立て、ABI パディングを各テストから隠す。1 テスト 1 関心事(未 install→0 / malformed→0 の 3 path / valid→nonzero ID)— fail したテスト名そのものが原因を指す。malformed テストは戻り値だけ見る(depth_bid==0 の side-effect チェックは submit 接続後に追加)、valid テストも ID が 返る ことだけ(book に乗ったかは次レッスン)。

答え合わせ

cd ~/code/openhl-reference
git checkout a8823a1
diff -u ~/code/my-openhl/crates/evm/src/precompiles/mod.rs ./crates/evm/src/precompiles/mod.rs
git checkout main

このレッスン後、あなたのコードは Stage 9c に 近いが特定地点で止まっている — diff は空にならない。Stage 9c は ① ID 採番と encoding の間に book.submit(...) を呼ぶ ② place_order_rejects_malformed_inputdepth_bid()==0 assertion を持つ ③ place_order_then_read_best_bid_round_trips テストを持つ — どれもこの scaffold にはまだない。次レッスンで全部足して 9c を閉じる。

合格基準

cargo test -p openhl-evm --release            # 46 個(既存 43 + 本レッスン 3)
cargo test -p openhl-evm --release place_order # 3 個 pass

→ pass。read_best_bid と read 側 43 テストは無変更(純粋追加)。よくあるミス: AccountId/Order/... を import したが未使用で warning(レッスン8 で全部使うので消さない・#[allow(unused_imports)] か warning 許容)/ _side の underscore を外して unused-variable warning / 個別 pass・スイート fail(TEST_SERIALIZER lock が最初の文でない)。

まとめ(3行)

  • schema-first:0x...0c1c の 128-byte calldata layout と 32-byte 戻り値を 挙動より先に 確定 — 公開した日から契約が安定する。
  • 4 つの rejection path は sentinel 0 を返し panic しない(1 始まり ID が 0=rejection を曖昧さなくする)。happy path は ID を採番して返すが まだ book に書かない
  • ID 採番は AtomicU64::fetch_add(1, Relaxed) — 一意性だけ要る/Book が同期を担う/hot path を直列化しない。