レッスン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 に書き換える(clob が state 内部を借りるので state を book.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 してはいけない — clob(state への参照)を clob.lock() まで有効に保つ必要がある。submit は Vec<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_bid は place_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...0c1cwrite が0x...0c1bread から見える)= 書き込みマイルストーン。 - 副作用テストは
book.clone()で handle を保持してdepth_bid()を検証 —submitが返すVec<Fill>は_resultに捨てる(レッスン9 で route)。