レッスン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_order を pub(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 setup(uninstall_* で 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_orderをpub(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 拒否)を名指し、次に出荷できる拡張を複雑度順に並べる。