FABRKNT
Step 3. Precompiles — EVM 拡張による CLOB ステートのスマートコントラクト連携
Bridge統合
レッスン 11 / 12·CONTENT45 分90 XP
コース
Step 3. Precompiles — EVM 拡張による CLOB ステートのスマートコントラクト連携
レッスンの役割
CONTENT
順序
11 / 12

レッスン10 — コースマイルストーン — 実際の Reth ノード内でフルスタック

問い

unit test 47 個は各部品を単独で証明した。だが「実際の Reth ノード上で bridge と precompile が 同じ Book / Fill バッファを共有する」ことは未証明。NodeBuilder チェーンのタイポ 1 つで、unit test を green に保ったまま production が壊れうる。これを 1 本の integration test でどう塞ぐ?

原理(最小モデル)

  • integration test は unit test が捕まえない接続バグを捕まえる。 with_components(...executor(OpenHlExecutorBuilder)) のタイポや EthereumAddOns 漏れは unit を green に保ったまま production を壊す。integration 1 本 = 接続全体の assertion。
  • cross-module test には pub(crate) が適切。 pub は API を漏らす、#[cfg(test)] pub(crate) は無意味な ceremony(可視性はコンパイル時のみ・生成コードは同一)。pub(crate)=「crate 内なら誰でも、外は不可」。
  • inline calldata > DRY ヘルパー。 バイト位置コメント付き手書き [u8;128] で ABI レイアウトが callsite から見える。system-level test では各バイトが learnable artifact であるべき。
  • 正典: integration 1 + unit 多数。 失敗の局所化は unit、組み込み全体の保証は integration。
  • 正直な deferred: RPC roundtrip は Reth の責務。 JSON-RPC→eth_call→revm dispatch は openhl でなく Reth の検証。「openhl が Reth に正しく接続する」スコープに「Reth の RPC が動く」は含まない。

具体例

このテストは 4 フェーズ: bootstrap(OpenHlExecutorBuilder 付き Reth)→ bridge 構築 → bridge が book に書く(submit_order)→ precompile が読む(current_best_bid()==Some((200,33))接続証明 #1)→ precompile が書く(place_order で cross する Sell)→ bridge が約定を見る(pending_fill_count()==1接続証明 #2)。production コード変更は place_orderpub(crate) にする 1 つだけ — 価値は新挙動でなく 証明 にある。

失敗例(誤解)

「unit test が全部通るなら integration test は冗長」は誤り — 各 unit は precompile か bridge を 単独 で構築する。NodeBuilder.launch()OpenHlEvmFactory を作り、bridge が その EVM の precompile 経由で 同じ CLOB を見るパスを exercise したものは 1 つもない。「spawn_custom_evm_test_node() ヘルパーに切り出すべき」も誤り — Reth の NodeAdapter は ~5 個の phantom generic で、戻り型を名指すと全 caller が絡め取られる。3 つ目の caller が出るまで inline 合成のままにする。


ここまでで「integration が接続を証明する」「pub(crate) が適切な可視性」は着地した。ここから可視性を 1 語変えて integration test を足す。コードは完全形。この ok 行がコースマイルストーン。

🛑 予測。 unit test(レッスン3/6/9)で部品は証明済み。なのに NodeBuilder を通る integration test が要るのはなぜ?(答え: unit は bridge と Reth executor の 接続ミス を観測できない。各 unit は precompile か bridge を単独構築する。NodeBuilder::launch()OpenHlEvmFactory を作り bridge が その EVM 経由で 同じ CLOB を見るパスを exercise したものはない。with_components チェーンのタイポや EthereumAddOns 漏れは unit を green に保ったまま production を壊す。integration = 接続全体の assertion。)

ステップで組み立てる

Step 1: place_order を pub(crate) に

crates/evm/src/precompiles/mod.rs:

#[allow(clippy::unnecessary_wraps)]
pub(crate) fn place_order(input: &[u8], _gas_limit: u64, _reservoir: u64) -> PrecompileResult {

pub でなく pub(crate) の理由: precompile は registry 経由で呼ぶべき(直接呼びを抑止)/ PrecompileFn シグネチャを外に広く晒さない / integration test は crate 内なので pub(crate) がちょうど。read_best_bid は private のまま(モジュール外から直接呼ぶ予定がない)。可視性はコンパイル時のみの情報なので #[cfg(test)] は不要。

Step 2: integration test を追加

live_node.rs#[cfg(test)] mod tests 末尾に:

    /// **Stage 9d**: bootstrap a Reth node WITH `OpenHlExecutorBuilder` (so its
    /// EVM has our CLOB precompiles registered), construct a `LiveRethEvmBridge`
    /// against that node's provider, submit an order via the bridge — verify
    /// that the precompile module's process-global `CLOB_STATE` now reflects
    /// the order. This proves the full bridge ↔ custom-EVM-node integration:
    /// the same `Arc<Mutex<Book>>` that the bridge's `submit_order` writes to
    /// is the one any smart contract calling `clob_read_best_bid` through this
    /// node's EVM would see.
    ///
    /// Doesn't yet invoke the precompile via RPC `eth_call` — that's deferred
    /// indefinitely (validates Reth's plumbing rather than openhl behavior).
    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    async fn bridge_against_custom_evm_node_shares_clob_with_precompile() {
        use crate::OpenHlExecutorBuilder;
        use crate::precompiles::{
            CLOB_PLACE_ORDER, current_best_bid, uninstall_clob, uninstall_fill_sink,
        };
        use openhl_clob::{AccountId, OrderId, OrderType, Price, Qty, Side};
        use reth_node_ethereum::node::EthereumAddOns;

        // Start from a clean global state — other tests may have left a CLOB
        // or fill sink installed; that's fine for those tests but would mask
        // bugs here (especially the "sink was wired by bridge::new" assertion).
        uninstall_clob();
        uninstall_fill_sink();

        let runtime = Runtime::test();
        let chain_spec = dev_chain_spec();
        let node_config = NodeConfig::test().dev().with_chain(chain_spec.clone());

        let handle = NodeBuilder::new(node_config)
            .testing_node(runtime)
            .with_types::<EthereumNode>()
            .with_components(EthereumNode::components().executor(OpenHlExecutorBuilder))
            .with_add_ons(EthereumAddOns::default())
            .launch()
            .await
            .expect("launch of custom-EVM node failed");

        // Build the bridge against the live custom-EVM node's provider.
        // The bridge installs its CLOB as the precompile's global state
        // (per the install_clob call inside LiveRethEvmBridge::new).
        let bridge = LiveRethEvmBridge::new(handle.node.provider.clone(), chain_spec);

        // Pre-condition: precompile sees an empty book.
        assert_eq!(current_best_bid(), None);

        // Submit a resting bid via the bridge. This goes through Book::submit
        // under the same Arc<Mutex<Book>> the precompile reads from.
        bridge.submit_order(Order {
            id: OrderId(1),
            account: AccountId(42),
            side: Side::Buy,
            qty: Qty(33),
            order_type: OrderType::Limit { price: Price(200) },
        });

        // Post-condition: the precompile's view (which is what a smart
        // contract calling `clob_read_best_bid` through this node would see)
        // now reflects the order.
        let best = current_best_bid().expect("CLOB has bids after submit_order");
        assert_eq!(best.0, Price(200));
        assert_eq!(best.1, Qty(33));

        // === Stage 9c+ ===
        // Now hit the WRITE precompile: place a crossing Sell @ 200 qty 33
        // via `place_order`. The bridge's pending_fills should see the fill
        // even though we never went through bridge.submit_order. This proves
        // the FILL_SINK that LiveRethEvmBridge::new installed is the same
        // Arc<Mutex<Vec<Fill>>> the bridge later drains in build_payload.
        assert_eq!(
            bridge.pending_fill_count(),
            0,
            "fills empty before crossing taker via precompile"
        );

        let mut calldata = [0u8; 128];
        // account_id = 7 (last 8 bytes of slot 0)
        calldata[24..32].copy_from_slice(&7u64.to_be_bytes());
        // side = Sell (1) at byte 63
        calldata[63] = 1;
        // price = 200 (last 8 bytes of slot 2)
        calldata[88..96].copy_from_slice(&200u64.to_be_bytes());
        // qty = 33 (last 8 bytes of slot 3)
        calldata[120..128].copy_from_slice(&33u64.to_be_bytes());

        let r = crate::precompiles::place_order(&calldata, 100_000, 0)
            .expect("place_order must not error");
        let order_id_bytes = &r.bytes[24..32];
        let order_id = u64::from_be_bytes(order_id_bytes.try_into().unwrap());
        assert!(order_id > 0, "successful place_order returns nonzero id");

        // The fill from the cross should have landed in bridge's pending_fills
        // via the FILL_SINK install_fill_sink path inside LiveRethEvmBridge::new.
        assert_eq!(
            bridge.pending_fill_count(),
            1,
            "precompile-placed cross must populate bridge.pending_fills (Stage 9c+)"
        );

        // CLOB_PLACE_ORDER's address constant is part of the public surface
        // (and registered into the precompiles set by `openhl_precompiles`);
        // touch it here so the import resolves and the constant stays load-bearing.
        let _ = CLOB_PLACE_ORDER;

        // Clean up the globals so other tests can start clean.
        uninstall_fill_sink();
        uninstall_clob();

        // Drop the node handle explicitly to make the lifecycle visible
        // in the trace.
        drop(handle);
    }

4 フェーズ: A setupuninstall_* で global を空に — 他テストが残した state は信用しない + NodeBuilder.launch())/ B bridge 構築 + bridge→precompile read(new() が両 global に install、接続証明 #1)/ C precompile→bridge fills(接続証明 #2)/ D cleanup(逆順 uninstall + drop(handle))。

約定が bridge に届くまでの 5 段の間接:

place_order
  → submit_result.fills (Vec<Fill>)
  → FILL_SINK.read() → Some(sink: Arc<Mutex<Vec<Fill>>>)
  → sink.lock().extend(...)
  → bridge.pending_fills と同じ Arc
  → bridge.pending_fill_count() が increment を見る

要点: tokio::test(multi_thread, worker_threads=4)(Reth の async bootstrap が背景タスクを spawn — single-thread だと stall)/ uninstall_* を先頭で(プロセスグローバルなので他テストが残しうる)/ 手書き calldata は明示性のため(ヘルパーへ飛ばず ABI が読める)/ place_order直接呼ぶ(registry 経由はレッスン3 で証明済み、ここは bridge↔precompile 接続に scope を絞る)/ spawn_custom_evm_test_node() ヘルパーは作らない(NodeAdapter の generic 複雑度が戻り型を厄介にする)。

答え合わせ

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

両 diff とも になるはず(Stage 9c+ HEAD と一致)。これで Stage 9 が閉じる — 9a(カスタム EVM bootstrap)/ 9b(live な CLOB read)/ 9c(write path)/ 9c+(約定を bridge に route)/ 9d(bridge integration)の全マイルストーンを再現した。

合格基準

cargo test -p openhl-evm --release bridge_against_custom_evm  # 1 個 pass
cargo test -p openhl-evm --release                            # 48 個(unit 47 + integration 1)

→ pass。よくあるミス: place_order が private(pub(crate) 忘れ → E0603)/ NodeBuilder タイポ(レッスン3 の reth_dev_node_with_openhl_executor と比較)/ worker_threads=1 でハング / .await 抜けで silent skip(Future は lazy)/ calldata[63]=1 が Sell(0 だと Buy でクロスせず pending_fill_count==0)。

まとめ(3行)

  • integration test 1 本が、unit が捕まえない「bridge ↔ Reth executor の接続ミス」を塞ぐ — NodeBuilder チェーンのタイポ 1 つで unit green のまま production が壊れる現実的 regression を 1 本で防ぐ。
  • production 変更は place_orderpub(crate) にする 1 語だけ — cross-module test に必要十分な可視性(pub は API を漏らす、#[cfg(test)] は無意味)。
  • 接続証明 #1(bridge write → precompile read)+ #2(precompile write → bridge fills)で、Custom EVM bootstrap / Read / Write / Bridge統合 の 4 成果が実 Reth プロセス内で同時に噛み合うことを証明 = 48 tests green。

次のレッスン(レッスン11)

capstone。新しいコードはなし。築いたアーキテクチャを記憶から再現できるよう整理し、v0 で 意図的に 先送りした 4 項目(RPC roundtrip / マルチバリデータ OrderId / transaction-scoped rollback / staticcall mutation 拒否)を名指し、次に出荷できる拡張を複雑度順に並べる。