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

レッスン8 — book.submit(...) — 書き込みパスが live になる

問い

place_order は parse・検証・ID 採番までやるが、まだ book に書かない。read 側はレッスン6 で live になった。書き込みパスを閉じ、EVM 実行が CLOB state を mutate する 最初の瞬間を作るには?

原理(最小モデル)

  • precompile は on-chain caller を代表する。 テストが book.lock().submit(...) を直接呼ぶのは bridge(off-chain)の模倣。place_order が書くのは EVM transaction(on-chain)の模倣。ここが EVM 実行が CLOB を mutate し始める瞬間。
  • precompile 2 + Arc 1 + 共有 state = ラウンドトリップ成立。 両 precompile が CLOB_STATE 経由で read/write するため 0x...0c1c の write が即 0x...0c1b の read から見える。レッスン4 のアーキはこの瞬間のため。
  • schema-first だから behavior-second は小さい。 レッスン7 は ~70 行(定数・atomic・parser・登録・tests)。本レッスンは ~7 行(submit + binding rename + テスト拡張)だけ。契約を先に固めたから挙動追加が圧縮される。
  • 副作用テストには handle 保持が要る。 レッスン7 の malformed テストは Arc を捨てて book を inspect できなかった。let book = Arc::new(...); install_clob(book.clone()); で直す — clone(refcount++)が「戻り値テスト」と「state テスト」を分ける。
  • _result は future-intent マーカー。 _result=「値はあるが今は使わない、将来使う」、_=「明示的に使わない」。submit が返す Vec<Fill>_result に捨て、レッスン9 で fills に改名して route する。

具体例

place_order への変更は実質: ① ID 採番と encoding の間に clob.lock().submit(Order{...}) を挟む ② _account_id/_price_value/_side_ を外す(今度こそ使う)③ None チェックを let-else に書き換える(clobstate 内部を借りるので statebook.submit() まで延命)。テストは malformed テストに depth_bid()==0 を足し、happy-path テストをラウンドトリップに置換。

失敗例(誤解)

book.clone() でなく book を渡せばいい」は誤り — install_clob(Arc<Mutex<Book>>) は Arc を move し、inspect 用ローカル handle を失う。Arc::clone は関数呼び出しをまたいで所有権を共有する安価な手段(atomic increment 1 回)。「drop(book) は NLL があるので不要」は機械的には正しいが、encoding と Ok() 構築の間ロックを握り続けない宣言として明示する(hot path のロック窓を縮める/後段に別ロックが増えても安全性を読み取りやすい)。


ここまでで「precompile = on-chain caller」「Arc 1 つがラウンドトリップを成立させる」は着地した。ここから ~7 行を足す。コードは完全形。このラウンドトリップが書き込みマイルストーン — EVM ↔ CLOB のサーフェスが双方向になる瞬間だ。

🛑 予測。 Book を install し place_order で Buy を発注、read_best_bid で読む。もし read と write の precompile が 別々の Arc<Mutex<Book>> を持っていたら?(答え: テストは fail。read 側は空 book を見て 0 を返す。ラウンドトリップが成立する唯一の理由は、両 precompile が同じ CLOB_STATE から読み、その global が 1 つの Arc を保持し、その Arc が 1 つの Book を指すから。precompile ごとに private state を持てば機能的に切り離され、同じ CLOB に話せない。)

ステップで組み立てる

Step 1: place_order に submit を追加

qty チェックの後ろのロックセクションを次に置き換える(drop(state) が消え、is_none チェックが let-else になる):

    let state = CLOB_STATE.read().expect("CLOB_STATE rwlock poisoned");
    let Some(clob) = state.as_ref() else {
        // No CLOB installed → 0 sentinel.
        return Ok(PrecompileOutput::new(CLOB_BASE_GAS_COST, Bytes::from(out), 0));
    };

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

    let mut book = clob.lock().expect("clob mutex poisoned");
    let _result = book.submit(Order {
        id: OrderId(order_id_val),
        account: AccountId(account_id),
        side,
        qty: Qty(qty_value),
        order_type: OrderType::Limit {
            price: Price(price_value),
        },
    });
    drop(book);

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

Some で bind したあとは state を drop してはいけないclobstate への参照)を clob.lock() まで有効に保つ必要がある。submitVec<Fill>(生じた約定)を返すが、ここでは _result に捨てる(レッスン9 で route)。drop(book) で encoding/return の前に Book ロックを手放す。

binding の _ 接頭辞も外す(今度は実際に使う):

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

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

レッスン7 で parse 済みのデータが、そのまま Order 構造体に流れ込む。doc コメントの「submit はまだ呼ばない」NOTE は削除し、代わりに「submit が返す fills を捨てている」Side note を残す(レッスン9 で埋める 既知の ギャップだと将来の読者に伝わり、見落としと誤解されない)。

🛑 やりがちな勘違い。 「どうせスコープ末尾で release されるのに、なぜ drop(book) を明示するか?」 — encoding(out[24..32])と Ok() 構築がまだ残っているから。どちらもロックを必要としない。明示 drop は「このロックは用済み」という宣言で、hot path のロック保持窓を目に見えて縮める。Rust の NLL で機械的には省略可能だが、後段に FILL_SINK など別ロック取得が増えても安全性を読み取りやすいガードレールにもなる。

Step 2: place_order_rejects_malformed_input を depth_bid check で拡張

レッスン7 のテストは Book を install しても Arc を捨てて state を確認できなかった。次に置き換える:

    /// `place_order` with bad input (too short, invalid side byte, zero qty)
    /// rejects without mutating state.
    #[test]
    fn place_order_rejects_malformed_input() {
        let _g = TEST_SERIALIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
        let book = Arc::new(Mutex::new(Book::new()));
        install_clob(book.clone());

        // Too short.
        let r = place_order(&[0u8; 64], 100_000, 0).unwrap();
        assert_eq!(U256::from_be_slice(&r.bytes[0..32]), U256::ZERO);
        assert_eq!(book.lock().unwrap().depth_bid(), 0, "no order on book after short input");

        // 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);
        assert_eq!(book.lock().unwrap().depth_bid(), 0, "no order on book after bad side");

        // 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);
        assert_eq!(book.lock().unwrap().depth_bid(), 0, "no order on book after zero qty");

        uninstall_clob();
    }

let book = Arc::new(...); install_clob(book.clone()); で Arc をローカルに残す。追加した 3 つの depth_bid() == 0 が side-effect 側の証明 — レッスン7 の U256::ZERO assertion は「sentinel を 返す」しか見なかったが、ここで「何も書き込んでいない」も確かめる。

🛑 やりがちな勘違い。 「なぜ book でなく book.clone()?」 — install_clob(book.clone()) のあと、global が 1 つの Arc、このスコープの book がもう 1 つを保持する(どちらも同じ Book)。install_clob(book) だと .lock().depth_bid() を呼ぶローカル handle を失う。

Step 3: happy-path テストをラウンドトリップに置換

place_order_returns_nonzero_id_on_valid_input を削除し、これを追加:

    /// **Stage 9c end-to-end (write side)**: place a Buy via the precompile,
    /// then read the best bid via the read precompile. The two-precompile
    /// round-trip is the moment the EVM ↔ CLOB surface becomes bidirectional.
    #[test]
    fn place_order_then_read_best_bid_round_trips() {
        let _g = TEST_SERIALIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
        let book = Arc::new(Mutex::new(Book::new()));
        install_clob(book);

        // EVM call: place Buy @ 175 with qty 12, account 0xABCD.
        let calldata = place_order_calldata(0xABCD, 0, 175, 12);
        let result = place_order(&calldata, 100_000, 0).expect("precompile must not error");
        let returned_id = U256::from_be_slice(&result.bytes[0..32]);
        assert!(
            returned_id > U256::ZERO,
            "place_order must return a non-zero order id on success"
        );

        // Now read the best bid via the read precompile. Should see our order.
        let read_result = read_best_bid(&[], 100_000, 0).expect("precompile must not error");
        let price = U256::from_be_slice(&read_result.bytes[0..32]);
        let qty = U256::from_be_slice(&read_result.bytes[32..64]);
        assert_eq!(price, U256::from(175u64), "best bid is the placed order's price");
        assert_eq!(qty, U256::from(12u64), "qty at best level matches placed qty");

        uninstall_clob();
    }

「追加」でなく「置き換え」なのは、レッスン7 の assert!(order_id > ZERO) が新テストの assert!(returned_id > ZERO)包含される から(包含されるテストは coverage を増やさずメンテだけ増える死荷重)。2 つの precompile call は独立 — read_best_bidplace_order を知らないが、両者が CLOB_STATE 経由で同じ Arc を read/write する。Solidity 視点ではこう:

uint256 order_id = call(0x...0c1c, abi.encode(0xABCD, 0, 175, 12));   // ~ id > 0
(uint256 price, uint256 qty) = staticcall(0x...0c1b, "");             // ~ (175, 12)

EVM call は別々、precompile も別々だが、global を共有するので state も共有される。そのグローバルを install するのが bridge で、bridge 自身の submit_order もそこに書く(bridge の pending_fills はまだ何も受け取らない — レッスン9 で直す)。

答え合わせ

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 は (自分で書き換えた doc コメントの言い回しを除けば)。これで Stage 9c が閉じる。

合格基準

cargo test -p openhl-evm --release round_trips  # 1 個 pass = 書き込みマイルストーン
cargo test -p openhl-evm --release              # 46 個(テスト数据え置き:1 置換 + 1 拡張)

→ pass。テスト数はレッスン7 と同じ 46(..._returns_nonzero_id......_round_trips の置換と malformed テストの拡張)。よくあるミス: let Some(clob) = state.as_ref() else {...}; の後に drop(state) を足す(E0382 borrow of moved value)/ Order リテラル側の _ は外したが parse 行が let _account_id のまま(E0425)/ Price(qty_value) のようなフィールド取り違え(round-trip が left=200 right=175)/ depth_bid が Book に無い(Step 2(CLOB)で追加済みのはず)。

まとめ(3行)

  • clob.lock().submit(Order{...}) の 1 行で書き込みパスが live に — schema を先に固めたので挙動追加は ~7 行。
  • precompile 2・Arc 1・共有 state でラウンドトリップ成立(0x...0c1c write が 0x...0c1b read から見える)= 書き込みマイルストーン。
  • 副作用テストは book.clone() で handle を保持して depth_bid() を検証 — submit が返す Vec<Fill>_result に捨てる(レッスン9 で route)。