T8 · EVM を深く理解する
同じ代入で Gas が百倍違う理由は?
- 練習する能力
- BuilderProtocol Literacy
- 実践
- 同じ機能で保存領域の配置だけが異なる関数を 3 つ書き、ガス使用量の差を実測する。
- AI Lab
- AI に delegatecall コードのストレージ衝突リスクを説明させ、そのリスクを示すテストを自分で作る。
現実の問題
アドレス群と数量群を受け取り、順に記帳して総額を更新するバッチ配布を書きます。
function airdrop(address[] memory to, uint256[] memory amounts) external {
for (uint256 i = 0; i < to.length; i++) {
balanceOf[to[i]] += amounts[i];
totalDistributed += amounts[i]; // この行
}
}テストネットでは正常でも、本番で二百アドレスを入れるとブロック上限を超えて送れません。
totalDistributed をループ外で一回更新し、memory を calldata にするだけで、ロジックを変えず成功し料金も下がります。
同じ代入でも一、二桁違います。コンパイラだけの問題でなく、違う資源に支払っているためです。何に払うかを理解します。
思考実験
三つの保存場所だけを持つ機械を考えます。
第一は世界共通の永久書庫です。 紙一枚を入れると全ノードが永遠に複製を保存し、将来同期するノードも取得します。支払いは世界全体の永久保存を買います。
第二は下書き用紙です。 実行中は書け、終了すると破棄します。安価ですが、拡張の限界費用は紙が大きいほど上がり、線形ではありません。
第三は届いた手紙です。 引数のバイト列は取引とともに全体へ伝播するので、読むだけなら追加空間は不要です。最も安く、読み取り専用です。
totalDistributed += amounts[i] をループ内に置くと毎回書庫へ書き、外なら一回です。memory は手紙を下書き用紙へ全部写し、calldata は直接読みます。
価格の原理は、誰の資源をどれだけ長く使うかです。これを覚えれば Gas の差を暗記する必要はありません。
後半全体を説明する第二の実験です。
他のコントラクトのコードを台本として自宅で演じます。台本には引き出し 0 を X に変えるとあります。誰の家の引き出しでしょう。
自宅です。台本は他人でも、家も引き出しも自分のものです。中身の意味は作者の想像で決まり、自分の実際の配置ではありません。作者は管理者名と思い、自分は金庫の鍵を入れていれば事故になります。
あなたが決める
バッチ配布で一か所だけ変えるなら、どこですか。
結果を観察する
四案は同じ表の中で移動します。
| 保存場所 | 寿命 | 費用を負う対象 | 相対価格 |
|---|---|---|---|
| 永続ストレージ | 永久 | 全ノードが永久保存 | 最高 |
| イベントログ | 履歴に永久 | 履歴保存、状態ツリーには入らない | 中程度、storage より安い |
| 実行メモリ | 実行中 | 実行ノード、一瞬 | 低いが量に超線形 |
| 呼び出しデータ | 実行中、読み取り専用 | もともと伝播するバイト | 最低 |
画面に貼っておきたい結論です。
Gas はコードの行数ではなく、誰の資源をどれだけ長く占有するかの関数です。
状態変数が局所変数より高いのは書庫へ触れるため、ゼロ化の払い戻しは空間を空けるため、外部の view 呼び出しが無料なのは合意不要だからです。巨大ループは、データで費用が増えてもブロック上限は固定なため時限爆弾になります。
モデルを作る
三つの空間
| キーワード | 場所 | 書き込み | 寿命 | 用途 |
|---|---|---|---|---|
storage | 状態ツリー | 可 | 永久 | 残高、設定、権限 |
memory | 実行メモリ | 可 | 一回の外部呼び出し | 中間結果、変更する配列 |
calldata | 呼び出しデータ | 不可 | 一回の外部呼び出し | 読む引数、配列、bytes |
実用規則は、外部の参照型引数は calldata を基本にし、本当に変更する時だけ memory にすることです。
保存領域の配置
宣言順に 32 バイトのスロットを使い、連続した小型変数をまとめます。
contract Layout {
uint128 a; // slot 0 の下位 16 バイト
uint128 b; // slot 0 の上位 16 バイト、a と共有
uint256 c; // slot 1
address owner; // slot 2 の下位 20 バイト
bool paused; // slot 2、owner の直後
mapping(address => uint256) bal; // slot 3 は予約するが値を保存しない
}マッピングと動的配列は宣言スロット自体に要素を置かず、その番号をハッシュに使います。mapping(address => uint256) の k は keccak256(abi.encode(k, slotOfMapping)) にあります。
実用的には、入口なしで誰の残高でも直接読めること、そしてテストで任意スロットを変更できることです。T12 の Fork で資金を自分に付ける仕組みです。
書き込みは三段階です。ゼロから非ゼロは永久保存の追加で最高、非ゼロの変更は量が変わらず安く、ゼロ化は解放で一部払い戻しです。数字は更新で変わりますが、三段階は安定しており初回が高い理由になります。
呼び出しの形
入力の先頭 4 バイトはシグネチャのハッシュから得るセレクターです。基本型の引数は 32 バイトへそろえ、動的型はオフセットを先に置き、内容を後へつなぎます。
0xa9059cbb ← セレクター、4 バイト
000000000000000000000000abcdef…0001 ← 第一引数:アドレス、32 バイトへ左ゼロ埋め
00000000000000000000000000000000000000000000000000000000000f4240 ← 第二引数:数量三点が成り立ちます。
- 関数名と引数型で一意に決まります。 同名で別引数なら ABI 上は別関数です。
- 4 バイトなので衝突します。 プロキシで実際の問題になります。
- calldata の量は直接費用です。 32 バイト増えると伝播費が増え、L2 では主要費用になりがちです。
Delegatecall:誰の保存領域かを変える
普通の呼び出しは相手の家、コード、保存を使います。delegatecall は相手のコードを自宅で動かし、自分の保存を使い、msg.sender と msg.value を維持します。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
// 借りる台本
contract Logic {
address public owner; // 自分の slot 0 と想定
function setOwner(address a) external {
owner = a; // 実際は呼び出し側の slot 0 へ書く
}
}
// 自分の家
contract Proxy {
address public implementation; // slot 0 ← Logic の owner と衝突
address public admin; // slot 1
constructor(address impl) {
implementation = impl;
admin = msg.sender;
}
fallback() external payable {
// 本来は戻り値も転送する。インラインアセンブリが必要なためここでは省略
(bool ok, ) = implementation.delegatecall(msg.data);
require(ok, "delegatecall failed");
}
}Proxy に setOwner(0x…) を呼ぶと、implementation が指定先へ変わります。次回は攻撃者のコードを実行します。
バグというより delegatecall の定義です。 コードと保存を分離し、保存の意味はコードが決めます。不一致は災害です。
T28 で詳述する三規則です。
- プロキシ変数は実装と衝突しない場所へ、通常は slot 0 でなく擬似ランダムな固定スロットへ置きます。
- 更新時は末尾に追加するだけで、挿入、型変更、並べ替えをしません。
- 自壊命令や誰でも初期化できる関数を実装へ置きません。
何と呼ぶか
各操作の支払単位です。行数でなくネットワークへの負担を測ります。
使用量と単価の積が料金で、使用量はコード、単価は市場が決めます。最適化できるのは前者だけです。
状態ツリーに保存され全ノードが永久に持つ、最も高価な操作です。
新規、更新、消去の三価格があり、費用最適化の大半はアクセス回数削減です。
一回の外部呼び出しで使い、終了時に解放する一時空間です。
安価でも拡張は超線形で、大配列のコピーは予想より高くなります。
取引と届く読み取り専用の入力です。
変更せず追加空間が不要で、最も安い引数の読み方です。L2 の主な費用でもあります。
4 バイトセレクターと 32 バイト境界の引数による符号化規則です。
コントラクト同士と外部との共通言語で、input が何を呼ぶか読めるようになります。
別コードを現在の保存領域で実行し、呼び出し元と金額を維持します。
更新可能性の基盤で、衝突と初期化乗っ取りの原因です。双方がスロット 0 の意味を同じに理解するかを先に問います。
実践
成果物は自分で測った表です。価格は更新されるため具体値は示しません。桁の差を自分で観察します。
三版を書きます。 配列の合計を状態変数へ足す、同じ機能です。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract GasLab {
uint256 public total;
// A:各反復で永続ストレージへ書く
function sumA(uint256[] memory xs) external {
for (uint256 i = 0; i < xs.length; i++) {
total += xs[i];
}
}
// B:局所変数へ足し、最後に一回書く
function sumB(uint256[] memory xs) external {
uint256 acc = total;
for (uint256 i = 0; i < xs.length; i++) {
acc += xs[i];
}
total = acc;
}
// C:メモリへコピーせず calldata を読む
function sumC(uint256[] calldata xs) external {
uint256 acc = total;
for (uint256 i = 0; i < xs.length; i++) {
acc += xs[i];
}
total = acc;
}
}同じ入力で呼び、実 Gas を記録します。 テストの報告かレシートの gasUsed を読みます。
長さ 5、50、200 で 3 × 3 表を作ります。絶対値でなく三曲線の傾きを見ます。
初回書き込みを比較します。 total が 0 の場合と非ゼロの場合で、同じ関数の費用を比較します。
三価格の違いで、初めて見る人は測定ミスと思いがちです。
配置を検証します。 mapping(address => uint256) public bal; を追加して値を入れ、eth_getStorageAt で読みます。keccak256(abi.encode(アドレス, スロット番号)) を自分で計算します。
合えば入口がなくても任意の内部状態を読む力を得ます。
衝突を自分で起こします。 ローカルに Proxy と Logic を配備し、setOwner を通して implementation を読みます。
指定したアドレスへ変わった瞬間、危険性は暗記事項でなく実体験になります。
すべてローカルかテストネットで、実資産は不要です。
AI Lab
Proxy と Logic をそのまま渡し、三段階で聞きます。
第一段階:
delegatecall で組み合わせた二コードについて、各スロットの意味を
Proxy と Logic ごとに列挙し、全不一致を指摘してください。
第二段階:
不一致から、送る取引、順番、獲得結果を具体的な関数呼び出しで示してください。
第三段階:
現在のコードでは失敗し、修正後に成功するテストを書いてください。第一は表の照合なので通常うまくいきます。
第二ではもっともらしくても実行できない攻撃が出ます。存在しない関数や fallback の起動条件の無視などです。
第三が価値です。悪いコードで失敗するテストだけが実在を証明します。 書けない、または悪いコードで合格するなら、前のどちらかは作り話です。
修正案を適用して再実行します。赤から緑へ変わって修正完了です。コンパイルだけでは分かりません。
AI の回答後、自分で必ず検証すること
- スロット番号を eth_getStorageAt で自分で読んで確認する
- 各リスクに失敗するテストがある。証明できない主張を疑う
- ライブラリ、名前、規格が実在するか自分で調べる
- ライブラリを追加すれば安全というだけでなく、具体的な動作を説明できる
- 修正後、攻撃の成功を示していたテストが失敗する
- コンパイルだけでなく、各修正に対応するテストがある
実例
多くのウォレットが delegatecall で同じライブラリを使い、所有者設定に一度限りやプロキシ専用の制約がありませんでした。
ライブラリ本体を初期化して所有者となり破壊されると、依存先は空になりました。存在しないコードを呼ぶため出金できません。
T7 の入口の話に加え、実装自身も直接呼べるアドレスです。プロキシ経由だけとは限りません。
見た目を整えるため、新版で変数を既存の前へ置きました。
配置が全部ずれ、残高を設定、設定をアドレスとして読みました。盗難がなくても帳簿が壊れ、停止が必要でした。
末尾への追加だけで、挿入、型変更、並べ替え、削除は禁止です。 レイアウト比較を CI に置くほうが人のレビューより確実です。
最初は数十人で速い処理でした。
数千人でブロック Gas 上限を超え、永久に実行不能になりました。少額を持つ新アドレスを増やす攻撃でも到達できます。
配る「プッシュ」でなく、各人が自費で受け取る「プル」が一般的な解決です。
主ネットへデータを戻す L2 では、実行は小部分で、calldata の費用が大部分です。
長い構造体を密な符号化へ変えると費用が下がります。同じ変更でも主ネットでは効果が小さくなります。
資源の占有を最適化するというまとめどおり、チェーンが変われば重点も変わります。
変数を一つ変える
いつも一緒に読めば一回のアクセスを省きます。
別々なら全体の読み取りとシフト、マスクで高くなる場合があります。
前提は型サイズでなくアクセスパターンです。 経路を見てから変更し、実測します。
変更しないなら副作用の小さい最適化で、大配列ほど効きます。
ただし memory を受ける内部関数に渡すとそこでコピーします。全経路を読み取り専用にしないと、費用を移しただけです。
通常経路では動かなくても、呼び方によって破壊対象が変わります。
実装へ直接呼べ、未保護の初期化があれば、2017 年の例を再現します。
実装のコンストラクターで初期化済みとして、他人の初期化を防ぎます。
数字は変わってもモデルは変わりません。永続化が最も高く、メモリが次、読み取り入力が最も安く、長期負担に対応します。
具体値を示さない理由です。数字は古くなっても、自分の測定表はいつでも再測定できます。
持ち帰る問い
Blockchain の必要性を価格が正直に示します。永続書き込みは全ノードの永久保存を要求します。高価さは独立検証の請求書で、設計の欠陥ではありません。
支払者は呼び出し側です。ユーザー入力でループ長が決まれば、未知の請求を渡し、ブロック上限を超えると機能自体も失います。
delegatecall はユーザーが見るプロキシの裏の、交換可能なコードへリスクを移します。 調査ではプロキシか、誰が実装を交換できるかも問います。
章末チェック
永続書き込みは全ノードの保存、局所変数は一時空間だからです。
N 回の永久費用と一回の差は N に比例し、上限超過から送信可能へ変わります。
calldata を基本にし、変更が必要な時だけ memory です。
calldata は届いた読み取りバイト、memory は全コピーなので大配列ほど高価です。
calldata は外部引数用で、内部へ渡すとコピーになる点に注意します。
実装も 0 から始まり、意味が衝突します。実装の最初の変数を書くつもりで、プロキシの変数を変えます。
固定文字列のハッシュによるスロットへ置き、通常宣言と衝突させません。
三つの手掛かりです。
- 慣用の固定スロットに実装アドレスがあるか読む。
- 純プロキシの短いバイトコードを見る。
- 実装変更イベントの履歴を見る。
エクスプローラーも示しますが、ヒューリスティックな推測で非標準を見逃します。資金判断では自分で読みます。
唯一の正解はありません。次を確認します。
- ループ内の永続アクセスを減らす。
- 配列と bytes の外部引数の
memoryを見直す。 - 同時アクセスする小型フィールドをまとめる。
- 外部照会だけのデータをイベントへ変える。
- ユーザー次第の長さは可用性リスクと認識する。
予測してから実測します。 差が大きいほどモデルの理解が浅いと分かり、節約した Gas より価値があります。
要点
Storage は高く、Memory は安く、Calldata が最も安い。Delegatecall は「誰のストレージを書き換えるか」を変える。