レッスン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)は sentinel0を返し 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 は必ず
> 0、0は曖昧さなく 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> でなく AtomicU64 — fetch_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 fn。try_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::RelaxedでSeqCstでないのか?」 — 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_input に depth_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 を直列化しない。