FABRKNT
Building with the Stack — 実アプリを作る
アプリケーションパターン
レッスン 10 / 11·CONTENT50 分90 XP
コース
Building with the Stack — 実アプリを作る
レッスンの役割
CONTENT
順序
10 / 11

レッスン9 — Revm シミュレーションを Production Provider で検証する

問い

Revm fork が「2.95 WETH 取れる」と予測したのに、実チェーンでは 2.93 しか取れない。この差はそのまま損失になる。同じリスクは L1 searcher / L7 aggregator / L8 router すべてにある(Reth のクライアントシェア ~7-12%、Revm の正しさは Reth でない 88-93% との一致に依存)。Revm vs 本番 EVM クライアントを継続的に検証するハーネスをどう作る?

原理(最小モデル)

  • differential testing が gold standard。 IEEE 754 準拠検証 / TLS interop / POSIX 認証と同じ規律 — 「代表入力で自分の実装が信頼できるリファレンスと一致することを証明」。ここでは Revm vs debug_traceTransaction(Geth/Erigon/Alchemy 等)。
  • 2 種のテストケース。 ① 歴史的 mainnet tx を parent block でリプレイ(receipt が gas_used の正解、provider の eth_call が return data の正解)② 現状 state に対する仮想 call(USDC.balanceOf(holder) を provider eth_call と Revm fork で同 block 実行、provider が正解)。後者の方が始めやすい。
  • 同一ブロックに pin。 provider と Revm を同じ block に固定(BlockId::number(block))して比較条件を揃える。
  • bytes は完全一致、gas は近似比較。 1 byte 違えば下流 decode が変わる(厳密)。eth_estimateGas は安全バッファ含み(許容幅: max(prod_gas/10, 5000))。
  • 不一致の上位 3 原因はチェーン spec / ハードフォーク / precompile。 symptom→原因マッピングを覚える。

データ経路を 1 枚で(同じ tx を 2 経路で実行 → diff):

flowchart LR
    Tx["テストトランザクション<br/>(実 mainnet tx hash<br/>または fabricated call)"] --> Revm["Revm fork<br/>at parent block"]
    Tx --> Prov["Provider<br/>(Infura / QuickNode<br/>= Geth or Reth backend)"]
    Revm --> R1["gas_used + output"]
    Prov --> R2["gas_used + output"]
    R1 --> Diff["Diff"]
    R2 --> Diff
    Diff --> Pass["✅ identical"]
    Diff --> Fail["❌ debug<br/>(hardfork? precompile?<br/>RPC caching?)"]

具体例

テストケース(USDC.balanceOf を pin block で):

use alloy_primitives::{address, Address, Bytes, U256};
use alloy_sol_types::{sol, SolCall};

sol! {
    function balanceOf(address account) external view returns (uint256);
}

const USDC: Address = address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48");
const HOLDER: Address = address!("47ac0fb4f2d84898e4d9e7b4dab3c24507a6d503"); // known whale

fn build_calldata() -> Bytes {
    balanceOfCall { account: HOLDER }.abi_encode().into()
}

Production provider の答え(eth_call + eth_estimateGas、同 block 固定):

use alloy_eips::BlockId;
use alloy_provider::{Provider, ProviderBuilder};
use alloy_rpc_types::TransactionRequest;
use alloy_network::TransactionBuilder;

async fn provider_answer(
    rpc_url: &str,
    block: u64,
    to: Address,
    data: Bytes,
) -> eyre::Result<(u64, Bytes)> {
    let provider = ProviderBuilder::new().connect(rpc_url).await?;

    let tx = TransactionRequest::default()
        .with_to(to)
        .with_input(data);

    // 選んだブロックでの eth_call による出力 ground truth
    let output = provider.call(&tx).block(BlockId::number(block)).await?;

    // 同ブロックでの eth_estimateGas によるガス ground truth
    let gas = provider.estimate_gas(&tx).block(BlockId::number(block)).await?;

    Ok((gas, output))
}

Revm の答え(同じ block を fork):

use alloy_provider::{network::Ethereum, DynProvider};
use revm::{
    context::TxEnv,
    context_interface::result::{ExecutionResult, Output},
    database::{AlloyDB, CacheDB},
    database_interface::WrapDatabaseAsync,
    primitives::TxKind,
    Context, ExecuteEvm, MainBuilder, MainContext,
};

type ForkedDB = CacheDB<WrapDatabaseAsync<AlloyDB<Ethereum, DynProvider>>>;

async fn revm_answer(
    rpc_url: &str,
    block: u64,
    to: Address,
    data: Bytes,
) -> eyre::Result<(u64, Bytes)> {
    let provider = ProviderBuilder::new().connect(rpc_url).await?.erased();
    let alloy_db = WrapDatabaseAsync::new(AlloyDB::new(provider, BlockId::number(block)))
        .ok_or_else(|| eyre::eyre!("AlloyDB init"))?;
    let mut db = CacheDB::new(alloy_db);

    let mut evm = Context::mainnet().with_db(&mut db).build_mainnet();

    let tx = TxEnv::builder()
        .caller(Address::ZERO)
        .kind(TxKind::Call(to))
        .data(data)
        .gas_limit(10_000_000)
        .build()?;

    let result = evm.transact_one(tx)?;
    match result.result {
        ExecutionResult::Success { gas_used, output: Output::Call(out), .. } => {
            Ok((gas_used, out.into()))
        }
        other => eyre::bail!("Revm execution did not succeed: {other:?}"),
    }
}

Diff(bytes 完全一致 / gas 許容幅付き):

async fn validate(rpc_url: &str, block: u64, to: Address, data: Bytes) -> eyre::Result<()> {
    let (prod_gas, prod_out) = provider_answer(rpc_url, block, to, data.clone()).await?;
    let (revm_gas, revm_out) = revm_answer(rpc_url, block, to, data).await?;

    println!("== Output bytes ==");
    println!("  provider: 0x{}", hex::encode(&prod_out));
    println!("  revm:     0x{}", hex::encode(&revm_out));
    if prod_out != revm_out {
        eyre::bail!("OUTPUT MISMATCH");
    }
    println!("  ✅ match");

    println!("== Gas ==");
    println!("  provider: {prod_gas}");
    println!("  revm:     {revm_gas}");
    // eth_estimateGas に buffer (~10%) があるので少しの spread を許容
    let diff = prod_gas.abs_diff(revm_gas);
    let allowance = (prod_gas / 10).max(5_000);
    if diff > allowance {
        eyre::bail!("GAS MISMATCH: diff {diff} > allowance {allowance}");
    }
    println!("  ✅ within allowance");

    Ok(())
}

失敗例(誤解)

「Revm をローカルで走らせば検証になる」は誤り — Revm を Revm でテストするだけ。検証は Revm 以外の provider(Geth/Erigon の debug_traceTransaction)との一致で行う。「直近 block の latest を使えばいい」も誤り — RPC キャッシングで違う block の古い state を返すことがある(確定 block = latest - 32 にピン留めして再現性を確保)。


ここまでで「differential vs production provider」は着地した。ここから 5 ステップで組み立てる。コードは抜粋(実行時は補助コードが必要)。

🛑 予測。 Revm の spec が mainnet とずれている場合、検証ハーネスにはどんな不一致として現れるか?(答え: ① 出力が一貫して 0x/空(spec 違い、例: mainnet 用に作ったが op-mainnet を見ている)② ハードフォーク境界でだけ出力が違う(Revm の有効化 block が実チェーンと不一致)③ 特定 precompile を呼ぶ tx だけ違う(Revm に未実装、例: RIP-7212 secp256r1)。上位 3 原因がチェーン spec / ハードフォーク / precompile の不一致。)

ステップで組み立てる

Step 0: プロジェクトと依存

# Cargo.toml
[package]
name = "revm-cross-validation"
version = "0.1.0"
edition = "2021"

[dependencies]
alloy-eips         = "1.0"
alloy-primitives   = "1.5"
alloy-provider     = "1.0"
alloy-network      = "1.0"
alloy-rpc-types    = "1.0"
alloy-sol-types    = "1.5"
revm               = { version = "38", features = ["alloydb"] }
tokio              = { version = "1", features = ["full"] }
eyre               = "0.6"

Step 1-5

上の 4 ブロック: ① テストケース選定(仮想 call が始めやすい、歴史的 tx は drill)→ ② provider answer(eth_call + eth_estimateGas、block pin)→ ③ Revm answer(AlloyDB + CacheDBContext::mainnet、同 block pin)→ ④ diff(bytes 厳密、gas 許容幅)→ ⑤ 不一致デバッグ分類(spec / hardfork / precompile / RPC cache / intrinsic gas / hot vs cold)。

production gap: サンプリング(直近 7 日のランダム 1000 件 historical tx)/ state-diff 比較(prestate + statediff モード)/ ハードフォーク回帰(Revm 更新後に直近 5 hardfork block 周辺で再 validate)/ カスタム precompile 対応(op-stack 等)/ CI 統合(diff 許容超過で merge fail)/ マルチ provider クロスチェック(QuickNode + Infura + Alchemy)/ キャッシング(変更レッスンだけ再 validate)。

Step 6: 実行

$ ETH_RPC_URL=https://mainnet.infura.io/v3/$KEY \
  cargo run --release
provider: gas=21000  output=0x
revm:     gas=21000  output=0x
✅ match

答え合わせ(Test gate)

最近の小範囲 block での Revm vs Geth/Erigon debug_traceTransaction 一致:

// tests/revm_vs_provider.rs
#[tokio::test]
async fn matches_provider_for_recent_blocks() {
    // debug_traceTransaction を公開しているプロバイダなら何でも:
    //   - Erigon: 組み込み
    //   - Geth: --gcmode=archive --http.api debug
    //   - Alchemy: 有料プラン、レート制限あり
    let reference = ProviderBuilder::new().connect_http(REFERENCE_RPC.parse().unwrap());

    for block_num in (LATEST - 10)..=LATEST {
        let block = reference.get_block_by_number(block_num.into(), true).await.unwrap().unwrap();

        for tx in block.transactions.into_transactions() {
            let our_trace = our_revm_trace(tx.hash).await.unwrap();
            let reference_trace = reference.debug_trace_transaction(tx.hash).await.unwrap();

            assert_traces_equivalent(&our_trace, &reference_trace,
                "tx {:?} がブロック {} で reference と乖離", tx.hash, block_num);
        }
    }
}

#[tokio::test]
async fn coverage_includes_create_and_call_paths() {
    // CREATE、CREATE2、CALL、DELEGATECALL、STATICCALL を行使する既知 tx を選ぶ
    // それぞれが個別に reference と一致する必要がある
}

両方が実 recent-block 範囲で pass まで未完了(延いては L1-L8 で作ったもの全部への信頼も)。1 件でも乖離したら simulation が嘘をついていて、L1-L8 の sim 依存判断のどれが間違いか先に見つけずに知ることができない。

合格基準

  • 上記 2 テスト(直近 10 block × 全 tx の debug_traceTransaction 一致 + CREATE/CALL/DELEGATECALL/STATICCALL カバレッジ)が green。
  • なぜ bytes は厳密で gas は近似か(eth_estimateGas の安全バッファ)を 1 文で言える。
  • 不一致の上位 3 原因(spec / hardfork / precompile)を即答できる。

Drill

  1. 歴史的 tx を receipt.gas_used と Revm 再実行 gas_used で diff(1 時間)。
  2. op-stack precompile(Base/Optimism、L1 block info)を使う tx で validation を試み失敗モード観察(1 時間)。
  3. Uniswap V3 router の直近 100 成功 tx をループ validate、失敗率と pattern を集計(2 時間)。
  4. QuickNode + Alchemy の同 block 同 call で provider 同士の不一致を観察(1.5 時間)。
  5. CI 統合(exit-code 1、GitHub Action で nightly、baseline 比 >0.1% 不一致で fail)(3 時間)。

📺 関連動画

Nh19f_2fWLc | Dragan Rakita — EVM Technical walkthrough — Revm が production と一致するために従う必要のある spec

まとめ(3行)

  • Revm の正しさは Reth でない 88-93% との一致に依存(Reth クライアントシェア ~7-12%)— L1/L7/L8 の sim はすべてこの仮定の上。debug_traceTransaction を gold standard に differential testing。
  • bytes 厳密一致 + gas 許容幅(max(prod_gas/10, 5000)eth_estimateGas の安全バッファぶん)。同 block 固定、確定 block(latest-32)で RPC cache 回避。
  • 不一致の上位 3 原因 = チェーン spec / hardfork 有効化 block / 未実装 precompile。Test gate: 直近 10 block 全 tx の trace 一致 + CREATE/CALL paths カバレッジ。

次のレッスン(レッスン10)

Machine Payments — HTTP 402 と Tempo MPP スタック。本ティアのアプリ(L3 カスタム RPC、L6 cheatcode harness、L7 aggregator、L8 router)を pay-per-call で有料化する protocol layer。replay 防止と支払いなし 402 / 有効支払いで 200 のテストゲートで担保する。