レッスン3 — NodeBuilder への組み込み + registry callability test
問い
precompile が「コンパイルできる」だけでなく「EVM 実行から 到達可能」であることを、どう証明するか? そしてテストをどう構成すれば、失敗時にどの層のバグか即わかるか?
原理(最小モデル)
- テストの scope = バグの局在化。 unit test 3 つを段階的 scope(関数本体 → registry 登録 → registry dispatch)で構成 → 失敗すればどの層が壊れているか直接わかる。
- extend-not-replace の dual assertion。
CLOB_READ_BEST_BID登録 と0x...01の ECDSA recover 残存の 両方 を check → 単一 assertion なら見逃す silent-replace バグを捕まえる。 with_components(EthereumNode::components().executor(OpenHlExecutorBuilder))。 explicit-builder 経路。1 スロットだけ差し替え、他 Reth default を継承(「fork しない、configure する」)。- integration test は組み立ての assertion(挙動でない)。 「NodeBuilder + ExecutorBuilder + AddOns が clean に合成」と「precompile が正しいバイトを返す」は別関心事(後者は unit test)。
具体例
4 テストの scope 階層(狭→広):
① read_best_bid_returns_hardcoded_* 関数本体だけ → 失敗=レッスン2 Step3
② openhl_precompiles_registers_clob_* registry 登録の不変条件 → 失敗=レッスン2 Step4 の clone/extend
③ registered_precompile_is_invokable_* registry 経由 dispatch → 失敗=Precompile::new の組み立て
④ reth_dev_node_with_openhl_executor Node 全体の合成(integration) → 失敗=レッスン1 の Factory/Builder 接続
特定 scope だけ落ちればバグ位置が一意に絞られる。
失敗例(誤解)
「executor を closure で inline に書けばいい」は誤り — ComponentsBuilder が受ける契約は ExecutorBuilder trait で、closure を inline で書くのは扱いづらい(struct が存在するのは trait が API surface だから)。「テスト③は冗長(①②が通れば dispatch も動く)」も誤り — registry から引いて dispatch する経路は別物で、Precompile::new の組み立てバグ(関数ポインタ違い等)は①②が通っても③で落ちる。
ここまでで「scope 階層・dual assertion・1 スロット差し替え」は着地した。ここから 4 テストを組み立てる。コードは完全形。これが到達可能性マイルストーン。
🛑 予測。
openhl_precompiles_registers_clob_addressはなぜCLOB_READ_BEST_BIDだけでなく0x...01の ECDSA recover も 存在を assert するか?(答え: extend-not-replace の不変条件を強制するため。base を clone+extend でなく新規 set を作るバグだと CLOB は存在するが標準 precompile が消える。ECDSA がなければ署名検証コントラクトが revert。dual assertion が silent-replace を捕まえる。)
ステップで組み立てる
Step 1: reth_node.rs の import 更新
use reth_node_ethereum::{node::EthereumAddOns, EthereumNode}; // EthereumAddOns 追加
use crate::OpenHlExecutorBuilder;
EthereumAddOns は .with_add_ons(...) で必要(explicit-builder 経路は全 slot を埋める)、OpenHlExecutorBuilder は差し込み対象。
Step 2: integration test reth_dev_node_with_openhl_executor(mod tests 末尾)
/// Stage 9a: prove that `NodeBuilder` accepts `OpenHlExecutorBuilder` in
/// place of Reth's default executor, and that the resulting node still
/// spawns cleanly with our custom precompile registered.
///
/// Doesn't yet invoke the precompile (that requires deploying a
/// Solidity contract); just validates the `EvmFactory` + `ExecutorBuilder`
/// composition compiles, spawns, and tears down.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn reth_dev_node_with_openhl_executor() {
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 result: Result<()> = async {
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?;
// ノードはカスタム EVM とともにクリーンに起動した。これ以上の検査は
// 不要 — もし EvmFactory や ExecutorBuilder が壊れていれば、
// ここまで到達せず launch() の時点で失敗している。
let _ = expected_chain_id;
Ok(())
}
.await;
if let Err(e) = result {
panic!("Reth dev node bootstrap with OpenHl EVM failed: {e:?}");
}
}
load-bearing は .with_components(EthereumNode::components().executor(OpenHlExecutorBuilder)) — components() が default ComponentsBuilder を返し .executor(...) で 1 スロットだけ上書き(network/payload/pool 等は default 継承)。Step 1(Consensus)の .node(...).launch_with_debug_capabilities() shorthand と対比。
Step 3: precompiles/mod.rs の 3 unit test(ファイル末尾)
#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::U256;
/// Direct unit test of the precompile function: invoked with empty input,
/// it returns the hardcoded (price=100, qty=10) as 64 big-endian u256 bytes.
#[test]
fn read_best_bid_returns_hardcoded_price_and_qty() {
let result = read_best_bid(&[], 100_000, 0).expect("precompile must not error");
assert_eq!(result.bytes.len(), 64);
let price = U256::from_be_slice(&result.bytes[0..32]);
let qty = U256::from_be_slice(&result.bytes[32..64]);
assert_eq!(price, U256::from(100u64));
assert_eq!(qty, U256::from(10u64));
assert_eq!(result.gas_used, CLOB_BASE_GAS_COST);
}
/// Registry test: `openhl_precompiles()` extends a base precompile set
/// with our CLOB precompile at the well-known address. This is what the
/// Stage 9a `EvmFactory` plugs into every EVM instance Reth constructs.
#[test]
fn openhl_precompiles_registers_clob_address() {
let base = Precompiles::cancun();
let extended = openhl_precompiles(base);
// The CLOB address must be in the extended set.
assert!(
extended.contains(&CLOB_READ_BEST_BID),
"openhl_precompiles must register the CLOB_READ_BEST_BID address"
);
// The base Ethereum precompiles (e.g. ECDSA recover at 0x...01) must
// still be present — we EXTEND, not replace.
let ecrecover: Address = alloy_primitives::address!("0x0000000000000000000000000000000000000001");
assert!(
extended.contains(&ecrecover),
"extended set must retain base Ethereum precompiles"
);
}
/// Invoke the registered precompile end-to-end through the registry
/// (rather than calling `read_best_bid` directly). This proves the
/// registration is wired such that an EVM dispatch to the address hits
/// our function — the same path Reth's EVM uses on `staticcall` to
/// `CLOB_READ_BEST_BID`.
#[test]
fn registered_precompile_is_invokable_via_registry() {
let extended = openhl_precompiles(Precompiles::cancun());
let precompile = extended
.get(&CLOB_READ_BEST_BID)
.expect("CLOB precompile must be registered");
// Precompile::execute is the public dispatch method — same as what
// the EVM calls internally when a contract STATICCALLs the address.
let result = precompile
.execute(&[], 100_000, 0)
.expect("call must not error");
assert_eq!(result.bytes.len(), 64);
let price = U256::from_be_slice(&result.bytes[0..32]);
let qty = U256::from_be_slice(&result.bytes[32..64]);
assert_eq!(price, U256::from(100u64));
assert_eq!(qty, U256::from(10u64));
}
}
scope を広げる 3 段: ①関数を直接(最狭)②openhl_precompiles の extend-not-replace を dual assertion ③registry から .get().execute()(REVM が STATICCALL で使う dispatch のフル経路)。U256::from_be_slice で 32-byte big-endian を decode。
答え合わせ
cd ~/code/openhl-reference && git checkout 2ba97c6
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/reth_node.rs ./crates/evm/src/reth_node.rs
git checkout main
本レッスン後、2ba97c6(Stage 9a 統合 + 9e の unit test 3 個)と一致。
合格基準
cargo test -p openhl-evm reth_dev_node_with_openhl_executor --release
cargo test -p openhl-evm --lib precompiles # 3 unit test
cargo test -p openhl-evm --release # 42 個(既存 39 + 新規 4 — --lib/integration の名前被りで多少ずれる)
→ pass。到達可能性マイルストーン: custom EVM + precompile が EVM 実行から到達可能と証明された。 よくあるミス: EthereumAddOns を node:: なしで import / openhl_precompiles が新規 set を作って ECDSA assertion が落ちる / Precompile::new の引数順違いで③が panic。
まとめ(3行)
- 4 テストを scope 階層(関数→registry 登録→dispatch→node 合成)で構成 — 特定 scope の失敗がバグ位置を一意に絞る。
- extend-not-replace は dual assertion(CLOB address + ECDSA recover の両方)で守る — 1 assertion は間違った理由で pass しうる。
with_components(...executor(OpenHlExecutorBuilder))で 1 スロットだけ差し替え、他は Reth default 継承 — fork でなく configure。