レッスン5 — カスタム Precompile
問い
EVM の中で SHA-256 を使いたい。道は 2 つ: 新しい Opcode を足す(命令ストリームに新しいバイト)、あるいは Precompile を足す(コントラクトが CALL 0x00...02 した瞬間に EVM が呼び出すネイティブ Rust 関数)。カスタム Opcode はコンセンサスを破るがカスタム Precompile は破らない — なぜか?
原理(最小モデル)
- Opcode vs Precompile. Opcode はバイトコード命令 → Solidity / ABI / インデクサ全部に影響、コンセンサス破壊。Precompile は特定アドレスへの
CALL→ ツーリングほぼ透過、レジストリに登録するだけ。 Precompileは(input: &[u8], gas_limit: u64) -> PrecompileResultの関数. アドレスはPrecompile::newの一部、コンパイル時に固定。- ガス計算は
base + per_word × ceil(len / 32). Yellow Paper の標準形。 Precompiles::extendで登録.optimized_access[short_idx]配列にも書く → 短アドレス(0x01-0x0a)はディスパッチが単一インデックス参照。PrecompileHalt::OutOfGasはフレーム全体停止. リファンドなし、通常 revert とは別物。- Foundry の cheatcode は本番カスタム precompile.
vm.deal/vm.warp/vm.prankは全部0x7109709ECfa91a80626fF3989D68f67F5b1DD12Dへの CALL。
具体例
ディスパッチフロー:
sequenceDiagram
participant C as コントラクト bytecode
participant I as Revm interpreter
participant Reg as Precompiles レジストリ
participant Fn as カスタム precompile fn
C->>I: CALL 0x00...ff
I->>Reg: lookup アドレス
Reg-->>I: 発見 — Precompile
I->>Fn: run(input, gas_limit)
Fn-->>I: Ok(gas_used, output)
I->>C: returndata + gas refund
本物の identity precompile(crates/precompile/src/identity.rs):
use super::calc_linear_cost;
use crate::{
eth_precompile_fn, EthPrecompileOutput, EthPrecompileResult, Precompile, PrecompileHalt,
PrecompileId,
};
use primitives::Bytes;
eth_precompile_fn!(identity_precompile, identity_run);
/// Address of the identity precompile.
pub const FUN: Precompile = Precompile::new(
PrecompileId::Identity,
crate::u64_to_address(4),
identity_precompile,
);
/// 操作の基本コスト
pub const IDENTITY_BASE: u64 = 15;
/// ワードあたりのコスト
pub const IDENTITY_PER_WORD: u64 = 3;
/// 入力バイトをコピーして出力として返す。
pub fn identity_run(input: &[u8], gas_limit: u64) -> EthPrecompileResult {
let gas_used = calc_linear_cost(input.len(), IDENTITY_BASE, IDENTITY_PER_WORD);
if gas_used > gas_limit {
return Err(PrecompileHalt::OutOfGas);
}
Ok(EthPrecompileOutput::new(
gas_used,
Bytes::copy_from_slice(input),
))
}
カスタム登録(crates/precompile/src/lib.rs):
pub fn extend(&mut self, other: impl IntoIterator<Item = Precompile>) {
let iter = other.into_iter();
let (lower, _) = iter.size_hint();
self.addresses.reserve(lower);
self.inner.reserve(lower);
for item in iter {
let address = *item.address();
if let Some(short_idx) = short_address(&address) {
self.optimized_access[short_idx] = Some(item.clone());
}
self.addresses.insert(address);
self.inner.insert(address, item);
}
}
登録例:
let my_pre = Precompile::new(
PrecompileId::Custom("my_thing"),
address!("00000000000000000000000000000000000000ff"),
my_function,
);
precompiles.extend([my_pre]);
Foundry の cheatcode アドレス:
address internal constant VM_ADDRESS = 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D;
// = address(uint160(uint256(keccak256("hevm cheat code"))))
失敗例(誤解)
「カスタム Opcode を入れればコンセンサスを破らない」— 間違い。Opcode はバイトコードの 1 バイトを新しい意味に再定義する → Solidity コンパイラ / Wallet / インデクサ / 他クライアントすべてとコンセンサスを破る。Precompile は executor のレジストリにあなたのビルドだけが持つ追加エントリ → mainnet との合意は維持される。
「ガスコストは適当でよい、後で調整」— 間違い。precompile のガスは ChainSpec によって決まる。安すぎる precompile が攻撃者の DoS ベクターになり、後手の価格改定(EIP-2929 のような)はハードフォーク。現実的に最も遅い入力でベンチ → 悪用係数 2-5 倍 → ガス決定 が最初から必要。
「vm.deal のような状態変更は precompile では無理」— 間違い。Foundry はカスタム precompile を Rust で実装、calldata をデコードして Revm DB を直接書き換える。標準 precompile(identity 等)が純粋関数なのは標準仕様の制約で、カスタム precompile はホスト側の状態に何でもできる。
🛑 予測。 カスタム Opcode はメインネットとのコンセンサスを破る(中級で見た)。カスタム Precompile は破らない — 同じく vanilla EVM になかった新コード。なぜ答えが違うか?(答え: Opcode はバイトコードの命令ストリームに新しい意味を導入 → 既存コードに影響 + ツール / インデクサ / コンパイラ全部が知らない命令を見る → コンセンサス分裂。Precompile は 特定アドレスへの CALL という既存メカニズム上の動作 → mainnet がそのアドレスに何もない / 別のものを返すだけ、他のすべては変わらない。あなたのビルドだけが追加挙動を持つ。)
ステップで組み立てる
Step 1: 4 比較項目を即答
呼び出し方 / 追加方法 / ツール影響 / 用途。
Step 2: identity precompile の構造を読める
Precompile::new(id, address, fn) + fn(input, gas_limit) -> Result の 50 行未満。
Step 3: extend で登録
Precompiles::extend([my_pre]) の 1 行。あとは Evm ビルダーに通す。
Step 4: ガス設計
base + per_word × ceil(len / 32) の線形コスト。価格設定は ① 現実的最遅入力でベンチ → ② 悪用係数 2-5 倍 → ③ チェーンのガス / CPU 比率に変換 → ④ 敵対的入力で再ベンチ。
Step 5: Foundry の cheatcode を読む
0x7109... への CALL を Foundry の Rust precompile がインターセプト → cheatcode dispatch(calldata セレクタマッチ)→ Revm DB を Rust から直接書き換え。カスタム precompile の本番ケーススタディ。
答え合わせ
- 1 KB 入力での identity ガス計算:
ceil(1024 / 32) = 32ワード。15 + 3 × 32 = 111ガス。 vm.dealの実装: Foundry の Rust precompile が calldata からdeal(address, uint256)セレクタをマッチ → Revm DB を直接書き換えてアカウントの残高を更新 → 通常実行を継続。標準 precompile が純粋関数なのは「Ethereum 標準仕様」の選択で、ホスト側の Rust 実装は何でもできる。- precompile ガス 100、攻撃者が 10 倍 CPU 時間の入力を発見: 攻撃者は 100 ガスで通常の 10 倍 CPU を消費 → ノード 1 秒あたり 10 倍多くの CPU を払う → DoS。ガス / CPU 比率が崩れる。EIP-2929 が cold/warm reset で同様問題に対処(後手で痛いハードフォーク)。
合格基準
- Opcode vs Precompile の 4 比較を即答できる。
Precompile::new(id, address, fn)の構造を言える。Precompiles::extendで登録、optimized_accessで短アドレスが速い理由を説明できる。- ガス価格設計の 4 ステップを言える。
- Foundry の cheatcode が「カスタム precompile + Rust 側 state 書き換え」と分かる。
まとめ(3行)
- カスタム Precompile = アドレス + Rust 関数(
(input, gas_limit) -> Result)を Revm レジストリに登録、Opcode と違いコンセンサスを破らない。 - ガス計算は
base + per_word × ceil(len / 32)、価格設計は「最遅入力ベンチ → 悪用係数 → 敵対的再ベンチ」の 4 ステップ。 - Foundry の cheatcode(
vm.deal/vm.warp)は本番カスタム precompile の最広デプロイ例 — 同じパターンを自分のチェーンに転用できる。