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

レッスン6 — Foundry スタイルのカスタム cheatcode を Rust で作る

問い

vm.deal(alice, 100 ether) は EVM opcode でも Solidity contract でもない。Foundry がマジックアドレス 0x7109709E... に precompile(EVM エンジンに組み込まれた「コードがチェーン上にない」コントラクト)をインストールし、Vm.sol インターフェースで Solidity から見えるようにしている。自前 cheatcode(例: cheats.measureGas(target, data))をどう出荷するか?

原理(最小モデル)

  • precompile は executor に組み込まれ、プロトコル本体には入らない。 mainnet Revm はあなたの precompile を持たない、テストランナー Revm だけが持つ。コンセンサスを壊さず Rust のフルパワーを使える(普通の Solidity contract は EVM op しか呼べない、新 opcode はコンセンサス fork)。
  • セレクタ dispatch + ABI decode で Solidity と同じ呼び出し感。 Solidity は「アドレスへの呼び出しを ABI エンコードする方法」を既に知っているので、precompile が input[..4] をセレクタとして分岐し sol! 生成コードで型安全に decode すれば、テスト作者から見て普通の contract と区別がつかない。
  • 入れ子 EVM 実行でガスを測る。 同じ world state(または独立 DB)に対して fresh Context を立ち上げ、ワンショット tx を走らせ gas_used を返す。Success/Revert/Halt 全部で消費ガスは取れる。
  • 戻り値は Ok(EthPrecompileOutput)Err(PrecompileHalt::*) 前者は結果 bytes、後者は呼び出し停止。

データ経路を 1 枚で(cheatcode の呼び出しから測定まで):

flowchart TB
    Test["Solidity test"] -->|call| Cheats["0x7110... precompile"]
    Cheats -->|nested EVM call| Inner["Revm sub-EVM<br/>target.data を実行"]
    Inner -->|gas_used| Cheats
    Cheats -->|abi-encoded uint256| Test

具体例

precompile エントリーポイント(セレクタ dispatch):

use alloy_primitives::{Address, U256};
use alloy_sol_types::{sol, SolValue};
use revm::{
    context::TxEnv,
    context_interface::result::ExecutionResult,
    primitives::TxKind,
    Context, ExecuteEvm, MainBuilder, MainContext,
};
use revm_precompile::{
    EthPrecompileOutput, EthPrecompileResult, Precompile, PrecompileHalt, PrecompileId,
};

pub const CHEATS_ADDRESS: Address = alloy_primitives::address!("7110000000000000000000000000000000000000");

sol! {
    function measureGas(address target, bytes calldata data) external returns (uint256 gasUsed);
}

/// precompile エントリーポイント
pub fn cheats_run(input: &[u8], gas_limit: u64) -> EthPrecompileResult {
    // 最初の 4 byte が関数セレクタ — Solidity contract と同じディスパッチモデル
    if input.len() < 4 {
        return Err(PrecompileHalt::OutOfGas); // 本当は "bad input"; production は適切なエラーへマップ
    }

    let selector = &input[..4];
    let calldata = &input[4..];

    if selector == measureGasCall::SELECTOR {
        let decoded = measureGasCall::abi_decode_raw(calldata, true)
            .map_err(|_| PrecompileHalt::OutOfGas)?;

        let gas_used = run_measure_gas(decoded.target, decoded.data, gas_limit)?;
        return Ok(EthPrecompileOutput::new(
            21_000, // cheatcode 自体の呼び出しの flat コスト
            U256::from(gas_used).abi_encode().into(),
        ));
    }

    Err(PrecompileHalt::OutOfGas)
}

cheatcode ロジック(入れ子 EVM で測る、demo は独立 DB、本物は Inspector で親 state を共有):

fn run_measure_gas(target: Address, data: Vec<u8>, gas_limit: u64) -> Result<u64, PrecompileHalt> {
    // 本物の cheatcode では、custom Inspector 経由で親 EVM の state にアクセスする。
    // レッスン明瞭化のため、ここでは empty in-memory DB に対する独立 EVM を立ち上げる —
    // gas 数学のデモには十分。
    let mut db = revm::database::CacheDB::new(revm::database::EmptyDB::default());

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

    let tx = TxEnv::builder()
        .caller(Address::ZERO)
        .kind(TxKind::Call(target))
        .data(data.into())
        .gas_limit(gas_limit)
        .build()
        .map_err(|_| PrecompileHalt::OutOfGas)?;

    let result = evm.transact_one(tx).map_err(|_| PrecompileHalt::OutOfGas)?;

    match result.result {
        ExecutionResult::Success { gas_used, .. } => Ok(gas_used),
        ExecutionResult::Revert { gas_used, .. } => Ok(gas_used),
        ExecutionResult::Halt { gas_used, .. } => Ok(gas_used),
    }
}

ハーネスに登録(標準集合から拡張):

use revm::Context;
use revm_precompile::{Precompiles, PrecompileSpecId};

// (標準 precompile インターフェースから)
revm_precompile::eth_precompile_fn!(cheats_precompile_fn, cheats_run);

const CHEATS_PRECOMPILE: Precompile = Precompile::new(
    PrecompileId::Custom(std::borrow::Cow::Borrowed("cheats")),
    CHEATS_ADDRESS,
    cheats_precompile_fn,
);

fn build_test_evm_context<'db, DB>(db: &'db mut DB) -> impl ExecuteEvm + 'db
where
    DB: revm::Database<Error: std::fmt::Debug>,
{
    // mainnet precompile から開始、こちらを extend
    let mut precompiles = Precompiles::new(PrecompileSpecId::OSAKA).clone();
    precompiles.extend([CHEATS_PRECOMPILE]);

    Context::mainnet()
        .with_db(db)
        .with_precompiles(precompiles)
        .build_mainnet()
}

fn run_test_contract<DB>(db: &mut DB, test_contract_bytecode: Vec<u8>, test_calldata: Vec<u8>)
    -> eyre::Result<bool>
where
    DB: revm::Database<Error: std::fmt::Debug>,
{
    // 1. テスト contract をデプロイ
    let mut evm = build_test_evm_context(db);
    let deploy_tx = TxEnv::builder()
        .caller(Address::from([0xAB; 20]))
        .kind(TxKind::Create)
        .data(test_contract_bytecode.into())
        .gas_limit(10_000_000)
        .build()?;
    let deploy_result = evm.transact_one(deploy_tx)?;

    let test_addr = match deploy_result.result {
        ExecutionResult::Success { output: revm::context_interface::result::Output::Create(_, Some(a)), .. } => a,
        _ => eyre::bail!("test contract deploy failed"),
    };

    // 2. テストメソッドを呼ぶ
    let test_tx = TxEnv::builder()
        .caller(Address::from([0xAB; 20]))
        .kind(TxKind::Call(test_addr))
        .data(test_calldata.into())
        .gas_limit(10_000_000)
        .build()?;
    let test_result = evm.transact_one(test_tx)?;

    Ok(matches!(test_result.result, ExecutionResult::Success { .. }))
}

Solidity 側からは vm.deal と同じ呼び方:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface Cheats {
    function measureGas(address target, bytes calldata data) external returns (uint256);
}

contract Counter {
    uint256 public count;
    function increment() public { count++; }
}

contract CounterTest {
    Cheats constant cheats = Cheats(0x7110000000000000000000000000000000000000);

    function test_increment_gas_under_25k() public {
        Counter c = new Counter();
        bytes memory data = abi.encodeWithSignature("increment()");
        uint256 gas = cheats.measureGas(address(c), data);
        require(gas < 25_000, "increment too expensive");
    }
}

失敗例(誤解)

vm.deal 系の機能は普通の Solidity contract で実装できる」は誤り — Solidity contract は EVM op しか呼べず、Revm 内部(同じ world state での入れ子実行など)にアクセスできない。「新 EVM opcode を足せばいい」も誤り — opcode は 即コンセンサスを fork する(mainnet が拒否)。precompile は あなたの Revm ビルドにだけ存在し、mainnet には影響しない(~50 行で全 Rust パワー)。


ここまでで「precompile = executor 拡張、セレクタ dispatch で Solidity contract に見える」は着地した。ここから 4 ステップで組み立てる。コードは抜粋(実行時は補助コードが必要)。

🛑 予測。 なぜ precompile で実装するのか、precompile でしかできない点は?(答え: Revm 内部(fresh Context、入れ子 EVM 実行、Inspector 経由の親 state 共有)にフル Rust でアクセスできる。普通の Solidity contract は EVM op しか持たず、新 opcode はコンセンサスを fork する。precompile は mainnet 影響なしで Rust 関数を VM から呼び出せる唯一の道。)

ステップで組み立てる

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

# Cargo.toml
[package]
name = "rust-cheatcode"
version = "0.1.0"
edition = "2021"

[dependencies]
revm                = { version = "38" }
revm-precompile     = { version = "34" }
alloy-primitives    = "1.5"
alloy-sol-types     = "1.5"
eyre                = "0.6"

Step 1-4

上の 4 ブロック: ① precompile エントリ(セレクタ dispatch + ABI decode)→ ② cheatcode ロジック(入れ子 EVM、Success/Revert/Halt 全部で gas_used を返す、demo は EmptyDB、本物は Inspector で親 state 共有)→ ③ ハーネス登録(Precompiles::new(OSAKA).extend([CHEATS_PRECOMPILE])Context::mainnet().with_precompiles(...))→ ④ Solidity テスト(普通の interface call、solc でコンパイルし bytecode + selector を run_test_contract に渡す)。

production gap: Solidity コンパイル(forge が solc を呼ぶ、ユーザに事前コンパイルさせる方が普通)/ 親 state 共有(custom Inspector)/ 並列性(テストごと独立 DB の tokio タスク)/ 失敗レポート(stack trace、decoded revert、fuzz shrink)/ 呼び出し間 state 永続化(vm.expectRevert は次の call にだけ状態を設定、Inspector に保存)。

Step 5: 実行

$ cargo test --release -- --nocapture
running 2 tests
test tests::measures_gas_for_simple_call ... ok
test tests::reports_zero_on_revert ... ok

参照実装との differential テスト(dual-use: Rust で速度、Solidity で正しさ担保):

// test/MeasureGasDifferential.t.sol
import "forge-std/Test.sol";
import {Cheats} from "src/Cheats.sol";

contract MeasureGasDifferential is Test {
    Cheats cheats = Cheats(0x7109709ECfa91a80626fF3989D68f67F5b1DD12E);  // 自前のアドレス
    Target target = new Target();

    function testMatches_referenceForKnownInput() public {
        bytes memory data = abi.encodeCall(target.work, (42));

        // (a) Rust precompile
        uint256 viaPrecompile = cheats.measureGas(address(target), data);

        // (b) Solidity-only リファレンス
        uint256 before = gasleft();
        (bool ok,) = address(target).call(data);
        uint256 referenceGas = before - gasleft();
        require(ok);

        assertApproxEqAbs(viaPrecompile, referenceGas, 5);  // 計測オーバーヘッド分の小さな ε
    }

    function testFuzz_alwaysAgreesWithReference(uint256 input) public {
        input = bound(input, 0, 10_000);
        bytes memory data = abi.encodeCall(target.work, (input));

        uint256 viaPrecompile = cheats.measureGas(address(target), data);
        uint256 before = gasleft();
        (bool ok,) = address(target).call(data);
        uint256 referenceGas = before - gasleft();
        require(ok);

        assertApproxEqAbs(viaPrecompile, referenceGas, 5);
    }
}

forge test --match-test testFuzz_ -vvv を既定 256 fuzz iteration で実行。256 入力すべてで一致(数 gas の ε 内)まで未完了。1 件でも差分が出たら、その入力で cheatcode は誤計測する。

合格基準

  • 上記 2 テスト(固定入力 + 256 fuzz)が green(参照と数 gas 以内)。
  • なぜ precompile が contract/opcode より優れているか(コンセンサス壊さず Rust フルアクセス)を 1 文で言える。
  • セレクタ dispatch + ABI decode が「Solidity 既知の呼び出し規約」とどう噛むかを即答できる。

Drill

  1. balanceOf(address) セレクタを追加(evm.db.basic(addr).balance)(15 分)。
  2. measureGasvalue 引数を足し payable 化(30 分)。
  3. cheats.deal(address, uint256) を custom Inspector で親 state mutate(3 時間)。
  4. ディレクトリの .sol を solc でコンパイル → test_ 関数を発見・実行・pass/fail 出力する最小ランナー(4 時間)。
  5. Counter increment × 1000 で自前 vs forge test のレイテンシ差を測る(1 時間)。

まとめ(3行)

  • cheatcode = executor に登録した precompile = mainnet コンセンサスに影響なし + フル Rust パワー(contract は EVM op のみ、新 opcode は fork を招く — precompile が唯一の道)。
  • セレクタ dispatch + sol! 生成 ABI decode + 入れ子 EVM で gas_used 計測。Solidity からは普通の interface call にしか見えない(ABI 互換性が「騙し」を可能にする)。
  • Test gate: 256 fuzz iteration で Rust precompile と Solidity-only gasleft() リファレンスが数 gas 以内で一致。次は DB 層 consistent snapshot の swap aggregator。

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

Swap Aggregator を作る(DEX state を fork して Rust で)。Revm fork で全 quote が同じ atomic state を読み、Uniswap V2 + Sushi + V3 から reserve を引いて出力を計算しベストを選ぶ。QuoterV2 differential で 5 bps まで担保。