レッスン11 — workspace で live Reth EthereumNode を boot する
問い
Reth(~600 crate のツリー)と Malachite を同じ workspace に同居させたとき、依存が衝突せず両方が同じ tokio runtime で boot するか? そして、integration コードを書く 前 にそれをどう検証するか?
原理(最小モデル)
- bootstrap-only test も一級の成果物。 node を spin up して chain ID を読むだけ。ビジネスロジックゼロの段階で依存解決と runtime bootstrap の regression を捕まえる。これが落ちたら L12–15 は何も動かない。
- production-dep は薄く、dev-dep は厚く。
crates/evm/Cargo.tomlの production dep は 6 個のまま、dev-dep を 11 個に増やす。openhl-evmを使う下流 crate は libp2p/MDBX/rpc を引かず、テストバイナリだけが引く。 NodeConfig::test().dev()。test()= ephemeral tempdir +:0port + peer discovery なし。dev()= single block producer、mempool gossip なし。組合せで CI 上で再現可能な完全 isolated 環境。- chain ID 2600。 Reth 上流
custom-dev-nodeexample と一致(diff 用)、public chain と衝突しない。OpenHL 的な意味はない。
具体例
共有 tokio runtime(worker_threads=4)上の task 配置:
[側A: Malachite] Engine actors / libp2p / WAL / run_engine_app loop
[側B: Live Reth] TaskExecutor / MDBX / Payload Builder / Mempool / RPC stub
↑ L11 はこの 2 世界が同一 runtime で衝突せず立ち上がる「ハンドシェイク」だけを検証
(A↔B の直接通信線は L12–15 で接続)
失敗例(誤解)
「integration コードを先に書けばいい」は誤り。大インフラ crate 2 つの衝突は integration を書いて初めて判明し、その時点で「動くはずなのにコンパイルできない」コードに大量投資済みになる。両方を同時に exercise する最小 test を先に書く — 失敗の blast radius が小さい。
ここまでで「共存検証パターン・薄い prod / 厚い dev」は着地した。ここから reth_node.rs(test module のみ)を組み立てる。コードは完全形。
🛑 予測。 なぜ bootstrap test を
--releaseで走らせるのか? ヒント: compile time の支配要因と、test 自体が何をするか。Reth の MDBX/libp2p/alloy スタックは巨大(~600 crate、debug 初回 ~2:34〜)。test は bootstrap + chain-ID チェックだけ → 初回コンパイル後は fast compile より fast runtime が欲しい。
ステップで組み立てる
Step 1: root Cargo.toml に Reth 依存を追加(全て v2.2.0 SHA 88505c7f... に pin)
reth-node-builder = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-node-ethereum = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-node-core = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-tasks = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-chainspec = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-evm = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-evm-ethereum = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-ethereum-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-engine-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-payload-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-payload-builder = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-provider = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
alloy-genesis = { version = "2.0", default-features = false }
新規 4 個の用途: reth-node-core(NodeConfig 型)/ reth-tasks(Runtime/TaskExecutor)/ reth-provider(L12 が保持する BlockchainProvider)/ alloy-genesis(Genesis JSON → ChainSpec)。main HEAD でなく release-tag SHA に pin が不変条件(crates.io は GitHub tag より数週〜数ヶ月遅れる)。
Step 2: crates/evm/Cargo.toml の [dev-dependencies](production scope 不変)
[dev-dependencies]
tokio = { workspace = true }
reth-node-builder = { workspace = true, features = ["test-utils"] }
reth-node-ethereum = { workspace = true, features = ["test-utils"] }
reth-node-core = { workspace = true }
reth-tasks = { workspace = true }
reth-chainspec = { workspace = true }
reth-provider = { workspace = true }
alloy-genesis = { workspace = true }
serde_json = { workspace = true }
eyre = { workspace = true }
tempfile = "3"
test-utils feature が NodeBuilder::testing_node(runtime)(tempdir MDBX + debug + ephemeral port)を提供する。全て dev-dep なので、#[cfg(test)] 外で使うと compile が落ちる(test-only dep がガードレール)。
Step 3: crates/evm/src/reth_node.rs — doc + imports
//! Live Reth node bootstrap — Stage 7a.
//!
//! Demonstrates that a full `EthereumNode` can be spun up in our workspace
//! via `NodeBuilder::testing_node`. Stage 7b will wire `RethEvmBridge` to
//! consume this node's provider + payload builder; for now this module is a
//! validated bootstrap recipe (the smoke test confirms it works) and a
//! placeholder for the future `live_node()` constructor.
//!
//! ```text
//! +----------------------+ Stage 7a (this commit)
//! | NodeBuilder |--+
//! | .testing_node | | EthereumNode spins up with MDBX in tempdir,
//! | .node(Ethereum) | | payload builder, mempool, RPC stub, etc.
//! | .launch_with_dbg() |--+
//! +----------------------+
//!
//! +----------------------+ Stage 7b (next)
//! | RethEvmBridge | Bridge methods (build_payload, payload_ready,
//! | ::with_live_node() | validate_payload, commit) route through the
//! +----------------------+ live node's services instead of in-process maps.
//! ```
#[cfg(test)]
mod tests {
use alloy_genesis::Genesis;
use eyre::Result;
use reth_chainspec::ChainSpec;
use reth_node_builder::{NodeBuilder, NodeHandle};
use reth_node_core::node_config::NodeConfig;
use reth_node_ethereum::EthereumNode;
use reth_tasks::Runtime;
use std::sync::Arc;
fn dev_chain_spec() -> Arc<ChainSpec> {
// Minimal post-merge dev genesis. ChainID 2600 mirrors the upstream
// custom-dev-node example so we can compare behaviour 1:1 if needed.
let custom_genesis = r#"{
"nonce": "0x42",
"timestamp": "0x0",
"extraData": "0x5343",
"gasLimit": "0x5208",
"difficulty": "0x400000000",
"mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"coinbase": "0x0000000000000000000000000000000000000000",
"alloc": {},
"number": "0x0",
"gasUsed": "0x0",
"parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"config": {
"ethash": {},
"chainId": 2600,
"homesteadBlock": 0,
"eip150Block": 0,
"eip155Block": 0,
"eip158Block": 0,
"byzantiumBlock": 0,
"constantinopleBlock": 0,
"petersburgBlock": 0,
"istanbulBlock": 0,
"berlinBlock": 0,
"londonBlock": 0,
"terminalTotalDifficulty": 0,
"terminalTotalDifficultyPassed": true,
"shanghaiTime": 0
}
}"#;
let genesis: Genesis =
serde_json::from_str(custom_genesis).expect("dev genesis json parses");
Arc::new(genesis.into())
}
/// Bootstrap a real Reth `EthereumNode` and verify the provider responds.
/// Returns nothing if successful; panics on launch or assertion failure.
async fn launch_and_check() -> Result<()> {
let runtime = Runtime::test();
let chain_spec = dev_chain_spec();
let expected_chain_id = chain_spec.chain.id();
let node_config = NodeConfig::test().dev().with_chain(chain_spec);
let NodeHandle {
node,
node_exit_future: _,
} = NodeBuilder::new(node_config)
.testing_node(runtime)
.node(EthereumNode::default())
.launch_with_debug_capabilities()
.await?;
// The provider should serve canonical chain queries off the genesis state.
let observed_chain_id = node.chain_spec().chain.id();
assert_eq!(observed_chain_id, expected_chain_id);
// NOTE: not awaiting node_exit_future — drop the NodeAdapter and let
// its background tasks tear themselves down when the runtime drops.
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn reth_dev_node_bootstraps() {
if let Err(e) = launch_and_check().await {
panic!("Reth dev node bootstrap failed: {e:?}");
}
}
}
要点:
- chain spec は raw JSON →
Genesis→genesis.into()。ChainSpecbuilder は 50+ フィールドで複雑なので、Reth 自身の deserializer に default/validity を強制させる(JSON は chain の外部 IF でもある)。EIP block は全て 0、terminalTotalDifficultyPassed: true(post-merge から開始)。 NodeConfig::test().dev().with_chain(spec)→NodeBuilder::new(config).testing_node(runtime).node(EthereumNode::default()).launch_with_debug_capabilities()。node_exit_future: _は await しない(await すると shutdown を待ってブロック)。NodeHandleを drop して runtime に tear down させる。worker_threads = 4は Reth 内部 task(MDBX/payload builder/RPC/network)に余裕を持たせる。
Step 4: crates/evm/src/lib.rs に test-cfg で組み込む
#[cfg(test)]
mod reth_node;
#[cfg(test)] がキー — bootstrap モジュールは test-only で consumer から見えず non-test ビルドでコンパイルされない。
答え合わせ
cd ~/code/openhl-reference && git checkout e6b4ebb
diff -u ~/code/my-openhl/Cargo.toml ./Cargo.toml
diff -u ~/code/my-openhl/crates/evm/src/reth_node.rs ./crates/evm/src/reth_node.rs
git checkout main
e6b4ebb に workspace dep update + 11 dev-dep + 105 行の reth_node.rs。genesis JSON / builder chain / test 属性は厳密一致するべき。
合格基準
cargo test -p openhl-evm reth_dev_node_bootstraps --release
→ reth_dev_node_bootstraps 1 個 pass(フル Reth EthereumNode v2.2.0 を ~2.7 秒で spin up、provider に chain ID query)。初回コールド ~2:34。よくあるミス: test-utils feature 忘れ / reth-* 12 個の SHA 不一致(version skew)/ current_thread で hang / 直前 run のゾンビプロセスが socket/MDBX lock 占有(数秒待つ→pgrep→kill)。
まとめ(3行)
- bootstrap-only test(spin up + chain ID 確認)が、ビジネスロジック前にインフラ regression を捕まえる一級成果物。
- production dep は 6 個のまま、test-utils 付き dev-dep 11 個でフル Reth スタックを検証 —
openhl-evmは slim を保つ。 - Reth v2.2.0 と Malachite v0.5.0 が同一 workspace・同一 runtime で衝突なく共存することを build と test の両方で証明。