レッスン9 — install_fill_sink — 約定を bridge に戻す
問い
レッスン8 で place_order は book に書き、ラウンドトリップも証明した。だが submit が返す Vec<Fill> は _result に捨てている。EVM 経由で発注された order の約定を、bridge の payload(build_payload が drain する pending_fills)まで届けるには?
原理(最小モデル)
- shared-buffer パターンは一般化する。 レッスン4 の「
Arc<Mutex<T>>+ プロセスグローバル」を約定にそのまま再利用。primitive が一度あれば、追加の共有 state は ~20 行で済む。レッスン4 の抽象化が複利で効く。 - 直交した global = 直交したテスト setup。
CLOB_STATEとFILL_SINKを 1 つにまとめると全テストが両方 install する羽目に。分けておけば各テストは触る分だけ install できる(composable、uninstalled なら実行時コスト 0)。 - common case の early-out は free。
if !submit_result.fills.is_empty()で、order が交差せず rest する主流ケースの sink ロック取得をスキップ。hot path に分岐 1 つ足すだけでRwLock取得を節約。 - sink push の前に
drop(book)。 Book と sink を同時保持するとロック順序 hazard が出る。Book guard を明示 drop してロック取得を厳密に逐次化する。 - doc コメント = 借金トラッカー。 レッスン8 の「fills discarded」doc は load-bearing だった(意図的ギャップを明示)。ここでギャップを閉じ doc も更新する — 文書化されたギャップは半分修正済み、未文書化は invisible debt。
具体例
precompile(fn ポインタ)は bridge への参照をキャプチャできない(→ CLOB_STATE と同じ制約)。だから約定を届ける方法は「bridge が所有するバッファを install し、precompile は global 経由で push」一択 — read 側で解いたのと同じ shared-Arc パターンの自然な拡張だ。レッスン9 で order → Book → Fill → payload の loop が初めて閉じ、on-chain(place_order)と off-chain(bridge.submit_order)の 2 writer が同じ pending_fills に合流する。
失敗例(誤解)
「CLOB と fill-sink を 1 つの global にまとめればいい(Option<(Arc<Mutex<Book>>, Arc<Mutex<Vec<Fill>>>)>)」は誤り — install タイミングが違う。read_best_bid だけ exercise するテストは sink 不要。束ねると毎テストで両方準備する羽目になる。global を直交に保てば、各テストは触る分だけ install できる(static が 2 つあるコストは名前空間だけ、利得はテストごとの合成可能性)。
ここまでで「shared-buffer の一般化」「直交 global」は着地した。ここから 1 バッファぶんの配管(precompiles/mod.rs に 5 編集 + live_node.rs に 2 編集)を足す。コードは完全形。
🛑 予測。 約定を bridge に届ける案は 3 つ: (a) precompile が bridge を直接呼ぶ (b) bridge がポーリング (c) precompile が push する共有バッファを install。なぜ (c) がアーキからほぼ強制されるか?(答え: precompile は
fnポインタで&Bridgeをキャプチャできない((a) はCLOB_STATEで解いた「fn ポインタは closure を持てない」問題そのもの)。(b) は bridge が「ポーリングすべき」と知る必要があり関心分離に反する。(c) はCLOB_STATEと同じパターン — 共有 CLOB state がある以上、共有 fill state はその自然な拡張。)
ステップで組み立てる
Step 1: Fill を import
crates/evm/src/precompiles/mod.rs の openhl_clob import に Fill を追加:
use openhl_clob::{AccountId, Book, Fill, Order, OrderId, OrderType, Price, Qty, Side};
Fill は Step 2(CLOB)の値型で price: Price / qty: Qty を持つ(Copy 可能)。live_node.rs は既に import 済みなので変更しない。
Step 2: FILL_SINK + install/uninstall 関数
uninstall_clob の後ろに:
/// Process-global handle to the buffer where the precompile pushes fills.
///
/// Same lifecycle rules as `CLOB_STATE`: installed by `LiveRethEvmBridge::new`,
/// none until set. When set, `place_order` extends this buffer with any fills
/// produced by the matched order, so production-shape EVM-placed orders flow
/// into the next `build_payload`'s drained fills exactly like bridge-side
/// `submit_order` does.
static FILL_SINK: RwLock<Option<Arc<Mutex<Vec<Fill>>>>> = RwLock::new(None);
/// Install the `pending_fills` buffer the precompile should write to.
/// Companion to `install_clob`. Calling this replaces any previously-installed
/// sink.
pub fn install_fill_sink(sink: Arc<Mutex<Vec<Fill>>>) {
*FILL_SINK.write().expect("FILL_SINK rwlock poisoned") = Some(sink);
}
/// Clear the installed fill sink. Test-only typical use; idempotent.
pub fn uninstall_fill_sink() {
*FILL_SINK.write().expect("FILL_SINK rwlock poisoned") = None;
}
CLOB_STATE と構造的に対称(外側 RwLock = 稀な install/uninstall、内側 Mutex = 頻繁な write)。2 関数は CLOB 版のミラー(body 1 行、pub fn、doc でライフサイクルを明示)。
Step 3: place_order を約定 push まで拡張
レッスン8 の let _result = book.submit(...); drop(book); 周辺をこう変える:
let mut book = clob.lock().expect("clob mutex poisoned");
let submit_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);
// Stage 9c+: route any fills produced by this order through the bridge's
// pending_fills buffer so they reach the next `build_payload`. Drops
// silently if no sink is installed (consistent with no-CLOB → return 0).
if !submit_result.fills.is_empty() {
let sink_state = FILL_SINK.read().expect("FILL_SINK rwlock poisoned");
if let Some(sink) = sink_state.as_ref() {
sink.lock()
.expect("fill_sink mutex poisoned")
.extend(submit_result.fills.iter().copied());
}
}
out[24..32].copy_from_slice(&order_id_val.to_be_bytes());
変化 3 つ: _result→submit_result(レッスン8 で予告した「将来」が来た)/ if !...is_empty() early-out(rest だけの主流ケースでロックをスキップ)/ as_ref()→lock()→extend()(read パターンと同形)。Fill は Copy なので .iter().copied()(.into_iter() より安価、submit_result の他フィールドを消費しない)。
🛑 やりがちな勘違い。 「
if !submit_result.fills.is_empty()の guard を外して無条件に FILL_SINK を取っても挙動は同じでは?」 — 挙動は同じだが約定なしケースで性能が落ちる。limit を rest させただけの一般ケースのたびに read ロックを取り「何も push しない」を確認する。guard はそれを短絡する — hot path で一般ケースを早期回避できるならタダの勝ち。
Step 4: place_order doc コメント更新
レッスン8 の「fills are discarded」side note を消し、これに置き換える:
/// Stage 9c+ (this commit): any fills produced by the submit are pushed into
/// the `FILL_SINK` global if installed. This is what makes EVM-placed orders
/// flow into the bridge's `pending_fills` and out via `build_payload`,
/// matching the bridge-side `submit_order` semantics. If no sink is
/// installed the fills are still produced (visible via subsequent
/// `read_best_bid`) but won't reach a payload.
明示 2 点: 「Stage 9c+ (this commit)」で何が変わったか / fallback セマンティクス(sink 未 install でも約定自体は生まれる → 約定を気にしないテスト=レッスン8 のラウンドトリップは sink を install せずに済む)。
Step 5: Unit test
place_order_then_read_best_bid_round_trips の後に:
/// **Stage 9c+**: when a `FILL_SINK` is installed alongside the CLOB,
/// fills produced by a `place_order` call flow into the sink. This is the
/// hook the bridge relies on to surface EVM-placed fills in the next
/// `build_payload`. With no sink installed, fills are still produced but
/// silently dropped — verified by the round-trip test above (which never
/// installs a sink yet still observes book state changes).
#[test]
fn place_order_routes_fills_to_installed_sink() {
let _g = TEST_SERIALIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let book = Arc::new(Mutex::new(Book::new()));
let sink: Arc<Mutex<Vec<Fill>>> = Arc::new(Mutex::new(Vec::new()));
install_clob(book);
install_fill_sink(Arc::clone(&sink));
// Maker: Buy @ 100, qty 10. Rests, no fill.
let maker = place_order_calldata(1, 0, 100, 10);
let r = place_order(&maker, 100_000, 0).unwrap();
assert!(U256::from_be_slice(&r.bytes[0..32]) > U256::ZERO);
assert!(sink.lock().unwrap().is_empty(), "no fills after resting maker");
// Taker: Sell @ 100, qty 10. Crosses the maker → exactly one fill.
let taker = place_order_calldata(2, 1, 100, 10);
let r = place_order(&taker, 100_000, 0).unwrap();
assert!(U256::from_be_slice(&r.bytes[0..32]) > U256::ZERO);
let fills = sink.lock().unwrap().clone();
assert_eq!(fills.len(), 1, "exactly one fill from the crossing taker");
assert_eq!(fills[0].price, Price(100));
assert_eq!(fills[0].qty, Qty(10));
uninstall_fill_sink();
uninstall_clob();
}
maker(rest、約定 0)+ taker(cross、約定 1)が routing をテストする最小データ(空 book への単独 submit は約定 0 個 → routing を exercise できない)。sink は clone() で取り出してから assert(Mutex 握ったまま assert しない)、install と逆順で uninstall。
Step 6: live_node.rs — pending_fills を Arc に
LiveRethEvmBridge<P> struct の pending_fills の型を変える:
/// Same shared-Arc pattern as `clob`: the precompile module's `FILL_SINK`
/// global points at this buffer too, so fills produced by EVM-placed
/// orders (via `clob_place_order`) flow into the same queue the bridge's
/// own `submit_order` writes to (Stage 9c+).
pending_fills: Arc<Mutex<Vec<Fill>>>,
Step 7: LiveRethEvmBridge::new を更新
pub fn new(provider: P, chain_spec: Arc<ChainSpec>) -> Self {
let validator = EthBeaconConsensus::new(Arc::clone(&chain_spec));
let clob = Arc::new(Mutex::new(Book::new()));
let pending_fills = Arc::new(Mutex::new(Vec::new()));
// Make our CLOB visible to the `clob_read_best_bid` precompile so
// smart contracts can query live orderbook state. The bridge writes
// (submit_order), the EVM reads (precompile); they share the same Arc.
crate::precompiles::install_clob(Arc::clone(&clob));
// Route fills produced by the `clob_place_order` precompile into the
// same queue `submit_order` writes to. Without this, EVM-placed orders
// would match but their fills would be silently dropped (Stage 9c+).
crate::precompiles::install_fill_sink(Arc::clone(&pending_fills));
Self {
provider,
chain_spec,
validator,
clob,
pending_fills,
state: Mutex::new(State::default()),
}
}
install_clob のミラーで install_fill_sink(Arc::clone(&pending_fills))。他の call site(pending_fill_count()、build_payload の drain)は Arc<Mutex<T>>→&Mutex<T> の deref coercion でそのまま動く(レッスン4 で clob を Arc にしたときと同じ)。
答え合わせ
cd ~/code/openhl-reference
git checkout d19ba1b
diff -u ~/code/my-openhl/crates/evm/src/precompiles/mod.rs ./crates/evm/src/precompiles/mod.rs
diff -u ~/code/my-openhl/crates/evm/src/live_node.rs ./crates/evm/src/live_node.rs
git checkout main
precompiles/mod.rs の diff は空、live_node.rs も このレッスンでカバーした変更については 空。Stage 9c+ commit は bridge integration test も拡張する(まだ無い — 次レッスンが追加)ので、live_node.rs のテスト region に非空 diff が出るのは想定どおり。
合格基準
cargo test -p openhl-evm --release # 47 個(既存 46 + 本レッスン 1)
cargo test -p openhl-evm --release routes_fills # 1 個 pass
→ pass。よくあるミス: pending_fills を Arc::new+Mutex::new でラップし忘れ(E0277)/ struct literal が Mutex::new(...) を直書き(レッスン4 形の残骸)/ fills.len()==1 が 0(taker が maker とクロスしていない — 同価格を確認)/ 永久ハング(drop(book) が sink push ブロックの 前 にあるか確認)。
まとめ(3行)
- shared-buffer パターンを約定に再利用 —
FILL_SINKstatic + install/uninstall はCLOB_STATEの完全ミラーで ~20 行。レッスン4 の抽象化が複利で効く。 - precompile(
fnポインタ)は bridge をキャプチャできない → bridge が所有するバッファを install して precompile が push する shared-Arc 一択。on-chain と off-chain の 2 writer が同じpending_fillsに合流。 if !fills.is_empty()の early-out で主流ケースのロックを節約、drop(book)を sink push の前に置いてロック順序 hazard を回避。
次のレッスン(レッスン10)
実際の Reth ノードを OpenHlExecutorBuilder で bootstrap し、その provider に LiveRethEvmBridge を構築する integration test。bridge が book に書き precompile が読む、precompile が書き bridge が約定を見る — Custom EVM bootstrap / Read / Write / Bridge統合 の全成果が実プロセス内で噛み合うことを 1 本で証明する。