FABRKNT
スタックを読む — 中級への橋渡し
EVM をバイト単位で
レッスン 3 / 10·CONTENT12 分25 XP
コース
スタックを読む — 中級への橋渡し
レッスンの役割
CONTENT
順序
3 / 10

レッスン2 — ガス機構の深掘りとコールフレーム

問い

EVM は gas で実行を計測 + 課金する。tx には gas limit、超えると停止。コントラクトが別コントラクトを呼ぶ(CALL / DELEGATECALL / STATICCALL)と コールフレーム が積まれる。ガス課金の仕組みとフレーム間の親子関係を理解する。

原理(最小モデル)

  • Intrinsic gas. Tx 発火時の固定コスト(21K + calldata bytes 4/16 gas)+ access list の事前購入、これが消えた後 bytecode 実行。
  • Per-opcode gas. 各 opcode 固定 or 動的コスト、ADD = 3、SLOAD cold = 2100、SSTORE 新規 = 20K、EIP-2929 で cold/warm 概念導入。
  • EIP-2929 cold/warm. 同じスロット 2 回目以降は warm(100 gas)、初回は cold(2100 gas)= 状態アクセスの実コスト反映。
  • Gas refund. SSTORE で値を 0 に戻す等で refund、tx 終了時に gas_used の 1/5 まで返却、cap 制限あり。
  • コールフレーム. CALL / DELEGATECALL / STATICCALL で新フレーム積む、親フレームから子フレームへ gas 渡し(63/64 ルール)、子のリターン値が親のスタックに。
  • CALL vs DELEGATECALL vs STATICCALL. CALL = 新 context(msg.sender 変わる)、DELEGATECALL = 親 context(library パターン)、STATICCALL = 読み専用(state 変更不可)。
  • Out-of-gas. 子フレームで gas 枯渇 → REVERT で巻き戻し、親フレームは続行可能(CALL のリターン値が 0 = 失敗)。
  • msg.sender / tx.origin の違い. msg.sender = 直接呼び出し元(フレーム単位)、tx.origin = tx 発火 EOA(ずっと同じ)、攻撃面で重要。

具体例 + ステップで組み立てる

ガス機構の深掘りとコールフレーム

「ガスはお金がかかる」は知っているはず。本レッスンではもう 1 段掘り下げる — ガスが実際どこへ消えるか、そして 1 つのトランザクションがどう コールフレーム のツリーを生成するか、各フレームが独自のコンテキストを持つこと。

両トピックは、カスタム opcode・precompile・ExEx の中級レッスンで前提知識として扱う。

ガス — 3 カテゴリ

各トランザクションのガス予算は 3 通りに消費される:

1. 内在ガス (intrinsic) — opcode 実行前に支払う

ただ トランザクションである だけで:

  • 21,000 ガス の固定 tx 料
  • calldata のゼロバイトあたり +4 ガス
  • calldata の非ゼロバイトあたり +16 ガス
  • コントラクト作成 tx なら +32,000 ガス

これは最初の opcode が実行される に支払う。tx に内在ガスを賄うガスもないなら、ブロックに入れない。

2. opcode ごとのガス — 固定と動的

ほとんどの opcode は 固定コスト: ADD = 3、MUL = 5、JUMP = 8、MLOAD = 3。

少数は コンテキスト依存の動的コスト

Opcode動的の理由
SLOADCold (2100) vs warm (100) — その tx でスロットが触れられたかに依存
SSTORECold-write、warm-write、ゼロからの書き、ゼロへの書き、すべて別価格
CALL / CALLCODE / DELEGATECALL / STATICCALL呼び先アカウントの cold/warm、value 転送、アカウント作成に依存
EXP大きな指数ほど高い
KECCAK256大きな入力ほど高い
CALLDATACOPY / CODECOPY / MCOPY大きなコピーほど高い
メモリに触る opcodeメモリを伸ばすなら拡張ガスを払う

3. リファンド — 戻ってくるガス

一部の操作は リファンド される:

  • スロットをクリアする SSTORE (非ゼロスロットにゼロを書く): 4800 ガスリファンド
  • SELFDESTRUCT (レガシー、EIP-6780 でほぼ削除): 24,000 ガスリファンド

リファンドは gas_used / 5 にキャップされる (EIP-3529)。小さい tx で千個のスロットをクリアしてもシステムを欺けない。

OOG vs revert — 似て非なるもの

両方とも実行を halt するが、違いが重要:

Out-Of-GasREVERT
残りガス全部消費呼び出し元に返す
状態変更すべて巻き戻しすべて巻き戻し
ReturndataREVERT オペランドからのデータ
呼び出し元が見る「call 失敗、データなし」「call 失敗、データあり」

Solidity の require(x, "msg") で、EVM はエンコード済み "msg" を returndata として REVERT を吐く。Solidity ≥ 0.8 の算術オーバーフローも同様 — REVERT に Panic(uint256) エラーコード。

OOG は別物。フレームが実行中にガス枯渇すると起きる。フレームはすべて (状態 + 残りガス) を失い、呼び出し元は汎用失敗を見る。

中級レッスン 1 で Revm の PrecompileHalt::OutOfGas と他の halt が出てくる時、この区別がモデル化されている。

コールフレーム — EVM のコールスタック

トランザクションは 1 フレーム で始まる — EOA からコントラクトへのトップレベル call (もしくはコントラクト作成)。

そのフレームが CALL (もしくは DELEGATECALL / STATICCALL / CREATE) を実行すると、新しいフレームが生まれる。新フレームは独自に持つ:

  • スタック (新規)
  • メモリ (新規、空)
  • Calldata (call opcode からの入力バイト)
  • PC (呼ばれたコントラクトのコードのオフセット 0 から開始)
  • ガス予算 (呼び出し元の残りガスのサブセット)

内側フレームが halt すると、制御が外側フレームに戻る。外側は以下を見る:

  • 成功 / 失敗フラグ
  • Returndata バッファ
  • 残りガス (外側の予算に戻される)

このネストは 1024 レベル まで可能。それを超えるとコールスタックオーバーフロー。

4 つの call 系 opcode — 何を所有するか

Solidity 開発者を最も混乱させる表:

Opcode内側の address(this)内側の msg.sender触るストレージ走るコード
CALL呼び先コントラクト呼び出し元呼び先のストレージ呼び先のコード
STATICCALLCALL と同じCALL と同じ同じ — ただし書き込みは revertCALL と同じ
DELEGATECALL呼び出し元呼び出し元の呼び出し元呼び出し元の ストレージ呼び先のコード
CALLCODE(廃止)(廃止)(廃止)(廃止)

3 つがモダン Ethereum で使われる。表を 2 度読む。バグ理解で重要なのは次の点である:

CALL

最も一般的。コントラクト X から A.foo(): 新フレームが A のコードを走らせ、A のストレージを見て、msg.sender = X。A が書くものは A のストレージへ。ストレージとコードが揃う

STATICCALL

CALL と同じ形だが、内側フレームは 状態書き込み禁止 (SSTORE、LOG、CREATE、SELFDESTRUCT、value 付き CALL すべて revert で halt)。「view」call 用 — Solidity が view 関数呼び出しで STATICCALL を吐く。

DELEGATECALL

危険なやつ。A のコードが X のコンテキストで走る。つまり: address(this) は X。msg.sender は X の呼び出し元。ストレージ読み書きは X のストレージへ、A のではない。コードは A から読む。

これがプロキシパターン (UUPS、Transparent Proxy、Diamond) の動作原理: プロキシコントラクト (X) が実装コントラクト (A) に DELEGATECALL し、X のストレージが A のロジックで変更される。

これが多くの著名な hack の元凶でもある — Wormhole、Parity マルチシグ、等 — A のストレージレイアウトが X と一致しないと、書き込みが X の予期しないスロットを破壊する。

中級レッスン 6 (ExEx) で「この tx がこれらのアカウントのこれらのストレージスロットに触れた」と出た時、CALL vs DELEGATECALL を知っていることが理解の鍵。

実コールグラフ

EOA がコントラクト X に tx 送信
│
├── X のコードが走る (frame 1)
│    │
│    ├── X.transfer() → コントラクト Y へ CALL
│    │    │
│    │    └── Y のコードが走る (frame 2、新規メモリ/スタック)
│    │          │
│    │          └── Y は Y.storage から読む (SLOAD で)
│    │
│    ├── X がコントラクト Z に STATICCALL (view 関数)
│    │    │
│    │    └── Z のコードが走る (frame 3、新規、書き込みロック)
│    │
│    └── X がコントラクト W に DELEGATECALL (実装)
│         │
│         └── W のコードが走る (frame 4) — でも書き込みは X のストレージへ!
│
└── tx 完了; receipt が全フレームのログを発行

各フレームが独自のメモリ/スタック。Returndata は各 return で上に流れる。ガス会計はフレームごとに消費を追跡。

読み物リスト

  1. evm.codes で CALL・DELEGATECALL・STATICCALL を読む。パラメータ (gas, address, value, argsOffset, argsSize, retOffset, retSize) を見る — DELEGATECALL/STATICCALL が value を落とす以外は同じ。
  2. Foundry で 小さいプロキシ + 実装ペアをデプロイ。forge test -vvvv でコールトレースを見る — 各 CALL と DELEGATECALL がフレームネスト付きで表示される。
  3. Wormhole hack postmortem を読む — バグは DELEGATECALL セマンティクスの誤解。本レッスンが頭に入っていれば、exploit の正体が一目でわかる。

このレッスンで持ち帰るもの

  • ガスは 3 つの形 で来る: 内在 (per-tx)、opcode ごと (固定 + 動的)、リファンド (キャップ付き)。
  • OOG と REVERT は似ているが returndata と残りガスで違う。
  • tx は コールフレームのツリー。各フレームが独自の stack/memory/PC/gas を持つ。
  • DELEGATECALL は呼び先のコードを呼び出し元のコンテキストで走らせる call スタイル — プロキシパターンの基盤、多くのバグの元凶。

中級レッスン 5 (カスタム precompile) でガス価格モデル、もしくは lesson 6 (ExEx) で複数のコミット済みトランザクションを跨いで再構成する話が出た時、コールフレームとガスモデルはすでに頭の中にある — 抽象概念ではなく、具体的な機械として。

まとめ(3行)

  • Gas = intrinsic(21K + calldata)+ per-opcode(cold/warm)+ refund(cap 制限)、tx 終了時 gas_used の 1/5 まで返却。
  • コールフレーム = CALL / DELEGATECALL / STATICCALL で積む、63/64 ルールで子へ gas 渡し、CALL は新 context、DELEGATECALL は親 context(library)、STATICCALL は読み専用。
  • msg.sender はフレーム単位、tx.origin は tx 発火 EOA、Out-of-gas は子だけ REVERT、次モジュールでブロック・レシート・reorg。