レッスン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
balanceOf(address)セレクタを追加(evm.db.basic(addr).balance)(15 分)。measureGasにvalue引数を足し payable 化(30 分)。cheats.deal(address, uint256)を customInspectorで親 state mutate(3 時間)。- ディレクトリの
.solを solc でコンパイル →test_関数を発見・実行・pass/fail 出力する最小ランナー(4 時間)。 - 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 まで担保。