FABRKNT
Step 2. CLOB — マッチングエンジンの追加とステートマシンの統合
Bridge 統合
レッスン 12 / 13·CONTENT30 分70 XP
コース
Step 2. CLOB — マッチングエンジンの追加とステートマシンの統合
レッスンの役割
CONTENT
順序
12 / 13

レッスン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_idnext_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_idnext_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.rs428cc26機能的に完全一致(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 なポイント。