レッスン11 — clob_fills_flow_into_payload — マイルストーンテスト
問い
submit → buffer → drain の全パイプラインが、実 Reth node に対して end-to-end で動くことをどう証明するか?
原理(最小モデル)
- 実 Reth node に対する e2e。
EthereumNodeを bootstrap しLiveRethEvmBridgeを組み、submit→buffer→drain の全 pipeline を exercise。レッスン1-10 の連鎖が個別コンポーネントを越えて成立する証明。 - bootstrap が高価なら 1 本の徹底テスト > 3 本の narrow テスト。 実 node 起動に数秒。3 回 bootstrap すればコスト 3 倍。1 シナリオで submit/drain/forward-only の 3 不変条件をまとめて検証。
- forward-only assertion が 本物の integration test にする。「submit が約定を生成」「build が drain」は unit でも自明。以前の payload が遡及更新されないことを check するから bridge の payload ごと snapshot を真に検証できる。
- fill 価格 = maker の価格、統合境界を越えても。 maker bid@100、taker sell@100、fill@100。
launch_with_debug_capabilities()(provider のみ)を使う(engine handle は不要、forkchoice を駆動しない)。
具体例
8 step の時系列(assertion 順序が time-invariance を証明):
bridge::new build_payload(empty_id) submit(maker)+submit(taker) build_payload(next_id)
count==0 count==0 count==1 count==0(drain 後)
empty_id→Some([]) ① next_id →Some([fill])
② empty_id→Some([]) ← forward-only!
empty_id を保持し、next_id の drain 後に re-read して「遡及更新なし」を能動的に証明する。
失敗例(誤解)
「empty_id を next_id より先に check すればいい」は誤り — それでは「空 payload に fill がない」(Step 5 で既知)しか言えない。drain の後に empty_id を check するから「以前の payload は後の drain が起きても空のまま」= time-invariance を証明できる。
ここまでで「e2e・徹底テスト・forward-only」は着地した。ここから test を組み立てる。コードは完全形。
🛑 予測。 maker bid@100 qty10、taker sell@100 qty10。fill 価格は 100? 違う?(答え: maker の価格 = 100。fill は常に resting order の価格。仮に taker が sell@95 で来ても、板に bid@100 が rest していれば約定は 100 で起きる(price improvement)— price-time priority は resting 側に決定権がある、という規律が統合境界を越えても揺るがない。)
ステップで組み立てる
#[cfg(test)] mod tests に追加(既存テストの後)
/// Stage 8d end-to-end: CLOB → bridge → payload.
/// A maker rests, a taker crosses it, the fill flows into the next
/// `build_payload`'s stored fills. The empty-fill `build_payload` that
/// preceded the orders proves the drain semantics — fills accumulate
/// AFTER they're built, not retroactively included.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn clob_fills_flow_into_payload() {
use openhl_clob::{AccountId, OrderId, OrderType, Price, Qty, Side};
let runtime = Runtime::test();
let chain_spec = dev_chain_spec();
let node_config = NodeConfig::test().dev().with_chain(chain_spec.clone());
let NodeHandle {
node,
node_exit_future: _,
} = NodeBuilder::new(node_config)
.testing_node(runtime)
.node(EthereumNode::default())
.launch_with_debug_capabilities()
.await
.expect("launch failed");
let genesis_hash_b256 = node
.provider
.block_hash(0)
.expect("provider call failed")
.expect("provider has no genesis");
let bridge = LiveRethEvmBridge::new(node.provider.clone(), chain_spec);
// Empty initial state — no orders submitted, no fills pending.
assert_eq!(bridge.pending_fill_count(), 0);
// First payload built with no orders → no fills attached.
let attrs = PayloadAttrs {
timestamp: 1,
fee_recipient: [0u8; 20],
prev_randao: [0u8; 32],
};
let empty_id = bridge
.build_payload(BlockHash(genesis_hash_b256.0), attrs.clone())
.await
.expect("build_payload failed");
let empty_fills = bridge
.payload_fills(empty_id)
.expect("payload exists");
assert!(empty_fills.is_empty(), "no orders submitted yet, fills must be empty");
// Submit a resting limit BID @ 100 from account 1, then a crossing
// SELL @ 100 from account 2. This produces exactly one fill.
let maker = Order {
id: OrderId(1),
account: AccountId(1),
side: Side::Buy,
qty: Qty(10),
order_type: OrderType::Limit { price: Price(100) },
};
let taker = Order {
id: OrderId(2),
account: AccountId(2),
side: Side::Sell,
qty: Qty(10),
order_type: OrderType::Limit { price: Price(100) },
};
let maker_result = bridge.submit_order(maker);
assert!(maker_result.fills.is_empty(), "maker rests, no immediate fill");
assert_eq!(bridge.pending_fill_count(), 0);
let taker_result = bridge.submit_order(taker);
assert_eq!(taker_result.fills.len(), 1, "taker should cross the maker");
assert_eq!(bridge.pending_fill_count(), 1, "fill buffered in pending");
// Build the NEXT payload — it should drain the buffered fill.
let next_id = bridge
.build_payload(BlockHash(genesis_hash_b256.0), attrs)
.await
.expect("build_payload failed");
let next_fills = bridge
.payload_fills(next_id)
.expect("payload exists");
assert_eq!(next_fills.len(), 1, "fill must be attached to the payload");
assert_eq!(next_fills[0].price, Price(100));
assert_eq!(next_fills[0].qty, Qty(10));
assert_eq!(next_fills[0].maker_order_id, OrderId(1));
assert_eq!(next_fills[0].taker_order_id, OrderId(2));
// After draining, pending fills must be empty.
assert_eq!(bridge.pending_fill_count(), 0);
// The earlier (empty) payload's fills must still be empty —
// draining is forward-only, never retroactive.
let empty_fills_again = bridge
.payload_fills(empty_id)
.expect("earlier payload exists");
assert!(empty_fills_again.is_empty(), "earlier payload not retroactively filled");
}
launch_with_debug_capabilities() を使うのは engine handle が不要だから(CLOB→payload を test、commit→forkchoice でない)。bridge は .with_engine_handle(...) なしで construct。fill 価格 = 100(maker、予測の答え)、maker_order_id=1 / taker_order_id=2。3 番目の assertion セット(empty_id を next_id の drain 後に re-read)が load-bearing — drain が forward-only で以前の payload を遡及更新しないことを証明(assertion の時間順序が time-invariance を証明する)。
答え合わせ
cd ~/code/openhl-reference && git checkout 428cc26
diff -u ~/code/my-openhl/crates/evm/src/live_node.rs ./crates/evm/src/live_node.rs
git checkout main
本レッスン後、live_node.rs は 428cc26 と 機能的に完全一致(doc 以外)。
合格基準
cargo test -p openhl-evm clob_fills_flow_into_payload --release
cargo test -p openhl-evm --release # 39 個(Consensus 38 + 本レッスン 1)
→ pass(~2.5 秒、大半は Reth bootstrap)。これが Step 2 のマイルストーン — matching engine の実約定が submit_order → pending_fills → build_payload drain → payload に流れる。よくあるミス: 両 order が Side::Buy(cross しない、fills.len()==0)/ drain 未実装(next_fills 0)/ pending_fills.clone() で original を mutate(forward-only assertion が落ちる)。
まとめ(3行)
- 実 Reth node を bootstrap し、submit→buffer→drain の全 pipeline を 1 本の徹底テストで e2e 検証する。
- fill 価格 = maker の価格(price-time priority が統合境界を越えても成立)。
- forward-only assertion(drain 後に以前の payload を re-read)が、bridge の payload ごと snapshot を真に検証する load-bearing なポイント。