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

レッスン1 — OpenHlEvmFactory — すべての EVM 生成にフックする

問い

custom precompile を、Reth が EVM を作るすべての経路(payload build / block validation / eth_call RPC / debug RPC)に、一度の登録でどう伝播させるか?

原理(最小モデル)

  • EvmFactory + ExecutorBuilder = Reth の「スロット 1 つ差し替え」継ぎ目。 Reth が構築する全 EVM は単一 factory を経由する → custom precompile は一度登録すれば全経路に伝播。
  • alloy-evm(抽象 trait)と reth-evm(具体実装)の役割分担。 trait 層が EVM の 正体、executor 層が Reth による 駆動方法。両方 import する。
  • spec ごとの PrecompilesOnceLock でキャッシュ。 構築は重く(address の hashing)、create_evm はホットパス → hardfork 層ごとに 1 度生成して &'static で共有。
  • シグネチャ固定の stub で段階的構築。 passthrough の openhl_precompiles(base) -> Precompiles 組み込み可能にし、本体はレッスン2 で埋める(callsite 書き換えなし)。

具体例

Reth が EVM を作る経路はどれも factory を呼ぶ:

build_payload(payload assembly)┐
validate_payload(block valid) ├─► OpenHlEvmFactory::create_evm → openhl_precompiles 登録
eth_call RPC                    │
debug RPC                       ┘

factory は 1 つ、EVM は多数、precompile 登録はどこでも一貫。

失敗例(誤解)

alloy-evmreth-evm は同じで片方選べばいい」は誤り — 別の層(抽象 trait vs Reth の具体実装)、両方 import する。「全 reth dep を workspace dep に」も誤り(1 crate でしか使わない reth-node-api は inline git-pin が clean)。「passthrough stub は無駄だから L1+L2 統合」も誤り(precompile 登録が壊れたとき factory 接続が原因か登録が原因か切り分けられなくなる)。


ここまでで「factory 継ぎ目・2 層・OnceLock・stub」は着地した。ここから scaffold を組み立てる。コードは完全形(precompile 本体はレッスン2)。

🛑 予測。 EvmFactory は trait。なぜ Reth は 1 EVM instance を使い回さず factory を必要とするか? ヒント: EVM tx を実行する経路(validation / payload assembly / eth_call / debug RPC)を思い浮かべる。(答え: それぞれ自分の database snapshot で新 EVM を作る。Reth は EVM を 1 つでなく多数作るから factory が要る。)

ステップで組み立てる

Step 1: workspace Cargo.tomlalloy-evm

alloy-evm                 = { version = "0.34", default-features = false }

public な alloy crate(REVM 抽象を trait レベルで提供、git-pin でなく crates.io stable)。

Step 2: crates/evm/Cargo.toml(3 新規 + 1 昇格)

[dependencies]
# ... 既存 12 entries ...
reth-evm                 = { workspace = true }                                                                              # NEW
reth-evm-ethereum        = { workspace = true }                                                                              # NEW
reth-node-api            = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }  # NEW (1-off git dep)
reth-node-builder        = { workspace = true }                                                                              # NEW (was dev-dep)
alloy-evm                = { workspace = true }                                                                              # NEW

reth-node-builder を dev-dep から昇格(production の OpenHlExecutorBuilder が使う)。reth-node-api の rev は他の reth-* と完全一致させる — Reth は内部 crate 間で型(FullNodeTypes/NodeTypes/BuilderContext)を厳格共有し、rev のズレは「同名だが別型」の難解エラーを大量発生させる。1 crate でしか使わないので workspace でなく inline git-pin(build graph を汚さない)。

Step 3: precompiles/mod.rs(stub)

//! Custom REVM precompiles that expose CLOB state to EVM execution.
//!
//! Stage 9a — scout commit. レッスン 2 adds the first real precompile
//! (`clob_read_best_bid` at 0x...0c1b) that returns a hardcoded best-bid
//! response so smart contracts can prove the precompile is reachable.
//! レッスン 4+ wires it to live CLOB state.

use alloy_evm::revm::precompile::Precompiles;

/// Wraps Reth's spec-default precompile set, adding openhl's CLOB precompiles.
///
/// レッスン 1 (this lesson): passthrough — clones the base unchanged.
/// レッスン 2: registers `clob_read_best_bid`.
/// レッスン 7+: registers `clob_place_order`.
#[must_use]
pub fn openhl_precompiles(base: &Precompiles) -> Precompiles {
    // レッスン 2 will replace this with `let mut precompiles = base.clone();
    // precompiles.extend([...]); precompiles`.
    base.clone()
}

シグネチャ openhl_precompiles(base) -> Precompiles が factory の依存する 安定契約 — 中身はレッスン2-11 で変わるが shape は不変。

Step 4: openhl_evm.rs — imports + factory

//! `OpenHlEvmFactory` + `OpenHlExecutorBuilder` — Reth's `ConfigureEvm` slot,
//! filled with our custom-precompile EVM.
//!
//! Stage 9a (scout commit) — modelled on Reth's `examples/custom-evm/src/main.rs`
//! pattern. The factory's `create_evm` installs `openhl_precompiles(...)` so
//! any EVM execution path (RPC call, payload assembly, validation) sees the
//! CLOB precompile registered at `CLOB_READ_BEST_BID`.

use alloy_evm::{
    eth::EthEvmContext,
    precompiles::PrecompilesMap,
    revm::{
        context::{BlockEnv, Context, TxEnv},
        context_interface::result::{EVMError, HaltReason},
        handler::EthPrecompiles,
        inspector::{Inspector, NoOpInspector},
        interpreter::interpreter::EthInterpreter,
        precompile::Precompiles,
        primitives::hardfork::SpecId,
        MainBuilder, MainContext,
    },
    Database, EvmEnv, EvmFactory,
};
use reth_chainspec::ChainSpec;
use reth_ethereum_primitives::EthPrimitives;
use reth_evm_ethereum::{EthEvm, EthEvmConfig};
use reth_node_api::{FullNodeTypes, NodeTypes};
use reth_node_builder::{components::ExecutorBuilder, BuilderContext};
use std::sync::OnceLock;

use crate::precompiles::openhl_precompiles;

/// EVM factory that registers openhl's custom precompiles on every EVM
/// instance Reth constructs (for payload assembly, block validation, RPC
/// state queries, etc.).
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct OpenHlEvmFactory;

impl EvmFactory for OpenHlEvmFactory {
    type Evm<DB: Database, I: Inspector<EthEvmContext<DB>, EthInterpreter>> =
        EthEvm<DB, I, Self::Precompiles>;
    type Tx = TxEnv;
    type Error<DBError: core::error::Error + Send + Sync + 'static> = EVMError<DBError>;
    type HaltReason = HaltReason;
    type Context<DB: Database> = EthEvmContext<DB>;
    type Spec = SpecId;
    type BlockEnv = BlockEnv;
    type Precompiles = PrecompilesMap;

    fn create_evm<DB: Database>(&self, db: DB, input: EvmEnv) -> Self::Evm<DB, NoOpInspector> {
        let spec = input.cfg_env.spec;
        let evm = Context::mainnet()
            .with_db(db)
            .with_cfg(input.cfg_env)
            .with_block(input.block_env)
            .build_mainnet_with_inspector(NoOpInspector {})
            .with_precompiles(PrecompilesMap::from_static(precompiles_for(spec)));
        EthEvm::new(evm, false)
    }

    fn create_evm_with_inspector<DB: Database, I: Inspector<Self::Context<DB>, EthInterpreter>>(
        &self,
        db: DB,
        input: EvmEnv,
        inspector: I,
    ) -> Self::Evm<DB, I> {
        EthEvm::new(
            self.create_evm(db, input).into_inner().with_inspector(inspector),
            true,
        )
    }
}

8 つの associated type は scaffold(多くは Reth default)。create_evm が core: ① Context::mainnet() ② db/cfg/block を差す ③ no-op inspector で build ④ .with_precompiles(...precompiles_for(spec)) で precompile 登録EthEvm で wrap。db: DB を generic にするのは Reth が経路ごとに別 snapshot 型(MDBX / 履歴 / overlay)を使うから。

Step 5: precompiles_for(spec)(OnceLock キャッシュ)

/// Lazily-initialised per-spec precompile sets. `OnceLock` ensures we build
/// each set once and share the static reference across every `create_evm` call,
/// matching the pattern in Reth's custom-evm example. Shanghai/Paris/London
/// don't add new precompiles, so they fall through to the Berlin set.
fn precompiles_for(spec: SpecId) -> &'static Precompiles {
    static PRAGUE: OnceLock<Precompiles> = OnceLock::new();
    static CANCUN: OnceLock<Precompiles> = OnceLock::new();
    static FALLBACK: OnceLock<Precompiles> = OnceLock::new();

    match spec {
        SpecId::PRAGUE | SpecId::OSAKA => {
            PRAGUE.get_or_init(|| openhl_precompiles(Precompiles::prague()))
        }
        SpecId::CANCUN => CANCUN.get_or_init(|| openhl_precompiles(Precompiles::cancun())),
        // For older hardforks (Berlin/London/Paris/Shanghai), use the Berlin
        // set as the most-recent-additions-cutoff base plus ours.
        _ => FALLBACK.get_or_init(|| {
            let base = EthPrecompiles::new(spec).precompiles;
            openhl_precompiles(base)
        }),
    }
}

hardfork ごとに標準 precompile セットが異なる。OnceLock を 3 つ(PRAGUE+OSAKA / CANCUN / FALLBACK)。Precompiles は HashMap ベースで構築コストが高く、create_evm は毎 eth_call / validation / build で走るので spec ごと 1 回キャッシュ(Reth custom-evm 例の定石)。

Step 6: OpenHlExecutorBuilder

/// Executor builder that swaps in `OpenHlEvmFactory` while keeping all other
/// Reth `EthereumNode` components at default.
#[derive(Debug, Default, Clone, Copy)]
#[non_exhaustive]
pub struct OpenHlExecutorBuilder;

impl<Node> ExecutorBuilder<Node> for OpenHlExecutorBuilder
where
    Node: FullNodeTypes<Types: NodeTypes<ChainSpec = ChainSpec, Primitives = EthPrimitives>>,
{
    type EVM = EthEvmConfig<ChainSpec, OpenHlEvmFactory>;

    async fn build_evm(self, ctx: &BuilderContext<Node>) -> eyre::Result<Self::EVM> {
        Ok(EthEvmConfig::new_with_evm_factory(
            ctx.chain_spec(),
            OpenHlEvmFactory,
        ))
    }
}

ExecutorBuilderEthereumNode の EVM config を差し替える Reth の hook。EVM = EthEvmConfig<ChainSpec, OpenHlEvmFactory>(標準 config をこちらの factory でパラメータ化)。trait bound が Ethereum mainnet primitive + こちらの ChainSpec に制約(Optimism 等は満たさない、意図的)。#[non_exhaustive] は将来 field 追加を破壊的変更にしないため。

Step 7: lib.rs

pub mod openhl_evm;  // NEW
mod precompiles;     // NEW (internal)

pub use openhl_evm::{OpenHlEvmFactory, OpenHlExecutorBuilder};  // NEW

precompiles は内部に留める(スマートコントラクトは address で呼ぶので consumer が openhl_precompiles を直接 import する必要なし)。factory/builder の re-export はレッスン3 の NodeBuilder 統合で使う。

答え合わせ

cd ~/code/openhl-reference && git checkout 1761d4d
diff -u ~/code/my-openhl/crates/evm/src/openhl_evm.rs ./crates/evm/src/openhl_evm.rs
git checkout main

1761d4dprecompiles/mod.rs完全版(Stage 9a の read precompile)を持つので stub とは差が出る(レッスン2 で埋める)。openhl_evm.rs は厳密一致するはず。

合格基準

cargo check -p openhl-evm
cargo test -p openhl-evm --release   # 既存 39 を壊していない回帰チェック

初回 ~30-60 秒(alloy-evm + reth crate 取得)。よくあるミス: reth-node-api git dep 追加忘れ / rev のズレ(型不一致)/ associated type のタイポ / base set を openhl_precompiles(...) で包み忘れ。

まとめ(3行)

  • EvmFactory + ExecutorBuilder で「全 EVM 生成」を 1 箇所フック — precompile を一度登録すれば payload/validation/RPC 全経路に伝播する。
  • spec ごとの PrecompilesOnceLock でキャッシュ(hot path の create_evm で再構築しない)。
  • passthrough stub(openhl_precompiles)が安定契約 — factory は今組み込み、本体はレッスン2 で callsite を変えず埋める。