FABRKNT
Step 3. Precompiles — EVM 拡張による CLOB ステートのスマートコントラクト連携
Custom EVM bootstrap
レッスン 4 / 12·CONTENT35 分70 XP
コース
Step 3. Precompiles — EVM 拡張による CLOB ステートのスマートコントラクト連携
レッスンの役割
CONTENT
順序
4 / 12

レッスン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_executormod 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 実行から到達可能と証明された。 よくあるミス: EthereumAddOnsnode:: なしで 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。