自分自身の owner フィールドだけを変更した実装コントラクトが、プロキシコントラクトの管理者アドレスをゴミ値で上書きしてしまうことがあります。こうした事故の根本原因は、通常 Solidity が状態変数を格納する方法にあります。コントラクトには「変数名からストレージ位置へ」の実行時インデックスが存在せず、コンパイラが宣言順に変数を 32 バイト(32 bytes)のスロット(slot)へ順番に配置します。delegatecall は実装コントラクトのコードをプロキシのストレージ上で実行させるため、両者のスロット割り当ての理解が一度でも食い違うと、書き込みは相手のデータに落ちてしまいます。
スロットはストレージのアドレッシング単位であると同時に、実行レイヤーが一度の状態アクセスを記録するときの最小粒度でもあります。変数がどのスロットに落ちるか、パッキング後にスロットを共有するか、配列とマッピングの要素がキーからどう位置を導出するかが、二つの実用的な問いを決めます。一度の呼び出しがプロキシのどのフィールドを壊すのか、そして一つのトランザクションの状態読み書き集合がどれだけの大きさになるのかです。
変数は宣言順に 32 バイトのスロットを線形に占有する
Solidity の公式ドキュメント「Layout of State Variables in Storage and Transient Storage」によれば、動的配列とマッピングを除いて、状態変数は最初の宣言から連続して格納され、最初の変数は slot 0 に落ちます。各変数は型ごとにバイト数が決まり、連続していて合計が 32 バイト未満の変数は同じスロットに詰め込まれます。規則は五つあります。スロット内の最初の項目は低位(lower-order aligned)に置かれる。値型は実際に必要なバイトだけを占める。現在のスロットの残り空間に収まらないときは次のスロットへ移る。構造体と配列のデータは常に新しいスロットから始まる。構造体や配列の後の変数も新しいスロットから始まる。
見落とされやすい二つの例外がスロット番号の計算に影響します。constant 変数はストレージスロットを占有せず、その値は使用箇所にインライン展開されます。immutable 変数はデプロイ時のバイトコードにエンコードされ、実行時にストレージを読みません。公式ドキュメントの例示コントラクト C を例にすると、定数 c と不変量 d はどちらもレイアウトに参加せず、これらを宣言番号に数えると以降のすべての変数のスロット番号がずれます。一時ストレージ(transient storage)はもう一つの独立したレイアウトで、規則は同じですが空間は完全に分離しているため、同じコントラクト内で通常の状態変数と一時変数を自由に交互に置いても互いに影響しません。
パッキングが節約するのはスロットであり、必ずしも gas ではない
小さい型が同じスロットを共有することをパッキング(packing)と呼びます。次の二つの宣言は変数の並び順だけが異なり、占有するスロット数が一つ違います。
// 3 つのスロットを占有する
uint128 a; // slot 0, offset 0
uint256 b; // 32 バイトは slot 0 の残り空間に収まらないため、slot 1 に落ちる
uint128 c; // slot 1 はすでに満杯なので、slot 2 に落ちる
// 2 つのスロットを占有する
uint128 a; // slot 0, offset 0
uint128 b; // slot 0, offset 16
uint256 c; // slot 1
公式ドキュメントは、32 バイト未満の要素を使うと gas 消費が増える可能性があると明確に述べています。EVM は 32 バイト単位で演算するため、小さい型を扱うには追加の切り詰めやシフト操作が必要になります。パッキングの実際の利点は「一度のアクセスで一つのスロットから複数の値を取れる」ことにあり、読み書きの回数はスロット単位で課金されます。その裏返しも同じように成り立ちます。あるロジックがそのうち一つの変数だけを書く場合、EVM はまずスロット全体を読み出し、該当するバイトを変更してから全体を書き戻さなければならず、そうしなければ同じスロットの他の変数を上書きしてしまいます。同時にアクセスされることの少ない変数を同じスロットに詰め込むと、一度の書き込みが読み取り一回と書き込み一回になります。
ここにはすでに並行性に関わる観察点が一つ埋まっています。スロット単位で見ると、パッキングは論理的には無関係な複数の変数を同じ書き込み可能なオブジェクトに束ねているのです。
定長配列はインライン、動的配列とマッピングはハッシュで位置を導出する
定長配列(たとえば uint256[3])の要素は順番にインラインでスロットに格納され、一つずつ宣言した変数と区別がありません。合計バイト数が 32 を超える場合は自然に複数のスロットにまたがります。構造体も同様ですが、構造体とその後の変数は必ず新しいスロットから始まります。
動的配列とマッピングはサイズが予測できないため、インライン化できません。そこで採る方法は、レイアウト上では一つのスロット p だけを占有し、スロット自体にはデータを格納せず、データ位置を keccak256 で計算するというものです。
動的配列のスロット p には配列の長さが格納され、要素は keccak256(p) から始まり、定長配列の規則に従って連続して並びます。要素が 16 バイト以下の場合は依然としてスロットを共有できます。ネストした動的配列は同じ規則を再帰的に適用します。型が uint24[][] でスロット p に宣言された x について、要素 x[i][j] のあるスロットは keccak256(keccak256(p) + i) + floor(j / floor(256 / 24)) です。
マッピングのスロット p は空のままですが、必ず確保しておく必要があります。この予約スロットがあるからこそ、隣り合う二つのマッピングのデータが重ならないのです。キー k に対応する値は keccak256(h(k) . p) にあり、ここで . は連結を表し、h はキーに型依存の処理を施します。値型はメモリ格納と同じ方法で 32 バイトにパディングされ、string 型と bytes 型のキーはパディングされません。ドキュメントが示すネストの例はこの点を直接検証できます。uint x; mapping(uint => mapping(uint => S)) data; に対して、data[4][9].c のスロットは keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))) + 1 で、末尾の +1 は構造体メンバー c の S 内でのスロットオフセットに由来し、a、b という二つの uint16 はすでに同じスロットにパッキングされています。
bytes と string のエンコードは別途説明が必要です。これらは bytes1[] の単純なラッパーではないからです。データが 31 バイト以下のときは、データ本体と長さが同じスロット内に格納されます。データは左寄せで高位バイトに置かれ、最下位バイトに length * 2 が格納されます。データが 32 バイト以上になると、スロット p に length * 2 + 1 が格納され、実際のデータは keccak256(p) から始まる領域に置かれます。両者は最下位ビットで区別されます。短いデータではこのビットが 0、長いデータでは 1 です。
複合型の位置決定も同じ再帰に従います。上記の規則から導くと、uint8[4] を動的配列の要素とする場合、4 つの 1 バイト値はちょうど一つのスロットに収まります。uint[3][] の各要素は 3 スロットを占める定長配列で、要素同士は 3 スロット間隔で順に並び、x[i] の起点は keccak256(p) + 3 * i です。この二つの例は規則からの特殊ケースの導出であり、公式ドキュメントは uint24[][] のネスト例しか示しておらず、この二つを逐語的に示してはいません。
これらすべてには直接的な帰結があります。配列要素とマッピング値のスロット位置は実行時のキーや添字に依存し、コンパイル時に列挙できず、ソースを読むだけの静的解析でも完全な集合を導出できません。
レイアウトはコンパイラの出力であり、エクスポートして比較できる
スロット番号を推測に頼る必要はありません。Solidity の標準 JSON インターフェースはコントラクトのストレージレイアウトをエクスポートでき、出力には storage と types という二つのキーが含まれます。storage 配列の各項目は astId、contract、label、offset、slot、type を与え、types は各型のエンコード方法を記述します。値 inplace はインライン配置、mapping と dynamic_array は keccak256 に基づく導出、bytes は長さに応じて単一スロットとハッシュ領域のどちらかを選ぶことを表します。slot の値は非常に大きくなることがあり、JSON では文字列として表現されます。ドキュメントは同時に、この出力形式が依然として実験的と見なされており、Solidity の非破壊的バージョンで変化しうると注意を促しています。したがって一度限りの照合ツールには向いていますが、長期的に依存するインターフェースには適しません。
継承順序とスロット境界がアップグレードの実現可能性を決める
継承を使うコントラクトでは、状態変数の順序はコントラクトの C3 線形化(C3-linearized)順で決まり、継承チェーンの最も基底にあるコントラクトから並びます。パッキングが許される場合、異なるコントラクトに由来する変数も同じスロットを共有し、基底クラスと派生クラスの変数が同じスロットに同居できます。
この規則が二種類のアップグレード事故を説明します。一つは基底クラスへの変数追加です。サブクラスがすでに自身の変数を宣言していれば、基底クラスに追加された変数がサブクラスの既存変数のスロットを押しのけ、古いデータを新しい変数として読み出してしまいます。もう一つは既存変数の前に変数を挿入する、あるいは変数の型を変える場合で、効果は同じです。OpenZeppelin のアップグレードドキュメントはこの制約をきわめて率直に述べています。新しい変数は末尾にしか追加できません。末尾から変数を削除してもストレージは消去されず、その後同じ位置に追加された変数は過去の残留値を読み出します。
レイアウトを能動的に制御したい場面のために、Solidity はコントラクト上でカスタムのレイアウト起点を宣言できます。公式ドキュメントの例は pragma solidity ^0.8.29; と contract C is A, B layout at 42 と書かれ、継承ツリー内のすべての静的変数のスロット番号が全体としてずれます。ドキュメントはこの機能がどのコンパイラバージョンから導入されたかを説明しておらず、ここでもバージョンについて断定しません。この宣言はその継承ツリーにのみ作用し、A、B を単独でデプロイするときのレイアウトは依然として slot 0 から始まります。注意すべきはこれが起点の移動にすぎず、動的配列とマッピングのデータ位置は基準スロットの変化に連動して変わることです。
プロキシのアップグレード:storage collision がなぜ必然的に起きるのか、予約スロットでどう回避するか
アップグレード可能なプロキシ(proxy)の仕組みはこうです。プロキシがストレージと残高を保持し、delegatecall を通じて実装コントラクトのコードを実行します。delegatecall は呼び出し元のストレージコンテキストを保持するため、実装コントラクトから見た slot 0、slot 1 はプロキシの slot 0、slot 1 です。プロキシ自身が address public admin; を宣言すればそれが slot 0 を占め、実装コントラクトの最初の状態変数も同じく slot 0 を占めるため、どちらかが書き込めばもう一方を上書きします。これが storage collision(ストレージ衝突)です。問題はどちらかがコードを書き間違えたことではありません。ストレージの共有と、両者をそれぞれ独立にコンパイルすることの組み合わせ自体が衝突を生みます。
EIP-1967 の解法は、コンパイラが割り当てるスロットを使わず、取り決めた固定のスロット群を使うことです。
| 用途 | スロット位置 | 導出方法 |
|---|---|---|
| 実装コントラクトのアドレス | 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc | bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) |
| ビーコンコントラクトのアドレス | 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 | bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1) |
| 管理者アドレス | 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 | bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) |
これらの数値のパラメータ選択には明確な理由があります。スロット位置はある文字列の keccak256 から取られますが、その文字列はストレージ添字で始まらないため、コンパイラが 0 から昇順に割り当てるスロットと重なりません。値自体も非常に大きく、通常の変数領域からさらに離れています。末尾で 1 を引くことでハッシュの原像が不明になり、誰かがマッピングのキーを構成して keccak256(h(k) . p) でちょうどそのスロットへ書き込むことを避けられます。EIP-1967 は同時に、これらのスロットを書き換える関数はすべて対応するイベントを発行することを推奨しています。オンチェーンの監視で任意のスロットの変化を直接追跡するのは難しいからです。
EIP-1967 以外には二つの一般的な方法があります。より古い方法はストレージギャップ(storage gap)で、基底クラスの末尾に定長配列、たとえば uint256[49] __gap; を宣言し、将来の変数用のスロットを予約します。基底クラスに変数を追加するときは __gap を同時に縮小します。OpenZeppelin のドキュメントはこれが gas 消費を増やさないと指摘していますが、その破綻の仕方も具体的です。ギャップの縮小を忘れる、あるいはサブクラスにすでに変数がある状態で基底クラスに変数を追加すると、どちらも衝突を再び招きます。より新しい方法は ERC-7201 の名前空間ストレージレイアウトで、変数の一群を構造体に入れ、@custom:storage-location erc7201:<NAMESPACE_ID> で注釈を付け、位置は keccak256(keccak256(id) - 1) & ~0xff で計算します。末尾の & ~0xff は名前空間を 256 スロットに整列させ、ドキュメントが挙げる理由はこれが将来の最適化になり得るというものです。Verkle 状態ツリーへ移行した後は 256 スロットがまとめて熱くなる gas 規則が現れる可能性があります。OpenZeppelin Contracts 5.0 以降のアップグレード可能バージョンはこの取り決めを採用しています。
最後に一つの前提を補います。Solidity のドキュメントはストレージレイアウトを言語の外部インターフェースの一部と見なしています。理由は、ストレージポインタをライブラリ関数へ渡せるため、レイアウト規則のいかなる変更も破壊的変更と見なされるからです。スロットに固定アドレスを書き込むプロキシ方式が成立するのは、レイアウト規則が長期的に安定するという約束の上に成り立っています。
読み書き集合の最小単位はスロット
ここまでの規則をまとめて見ると、アクセスの課金という観点では、実行レイヤーが観測できる最小の状態アクセス単位はアドレスとスロットです。EIP-2929 が最も直接的な証拠です。これは各トランザクションについて accessed_addresses と accessed_storage_keys の二つの集合を維持し、後者の要素型は Set[Tuple[Address, Bytes32]] で、その粒度で課金します。あるストレージスロットへの初回アクセスには COLD_SLOAD_COST、すなわち 2100 gas を課します。アクセス済みのスロットには WARM_STORAGE_READ_COST、すなわち 100 gas を課します。あるアカウントアドレスへの初回アクセスには COLD_ACCOUNT_ACCESS_COST、すなわち 2600 gas を課します。コールド/ウォームの課金単位はスロットであり、実行レイヤー自体がスロットをアクセス対象にしていることを示します。
ここから並行性に関わる三つのエンジニアリング上の推論が導けます。
EIP-2929 が定めるのはアクセス課金の最小単位であり、並列実行時に衝突判定をどの粒度で行うべきかは規範が定めていません。次の点は課金粒度から出発した推論です。パッキングは小さい変数を同じスロットに束ねるため、スロット単位で読み書き集合を記録すると、同じスロット内の異なる変数に書き込む二つのトランザクションは依然として衝突します。こうした偽の衝突をなくすには、検出をスロット内オフセットとバイト差まで細かくする必要があり、代償は比較ごとのオーバーヘッドの増加です。
動的配列とマッピングのスロット位置はキー値に依存し、読み書き集合はコンパイル時には分からず、実行中に記録し、実行後に検証するしかありません。楽観的並行制御(optimistic concurrency control、OCC)が読み集合と書き集合を必要とし、データベースのように開発者が事前宣言する方式をそのまま流用できない理由もここにあります(本サイトの「楽観的並行制御(OCC)入門:データベースからオンチェーン実行へ」と「コンフリクトのホットスポットとワークロード:並列 EVM が実際に効くとき」を参照)。
スロット単位の読み書き集合は正確な集合ではなく上界です。トランザクションがあるスロットの 1 バイトだけを変更しても、スロット全体に書き込んだと記録されることがあります。あるスロットにアクセスしても、その後のパスがリバートしたため実際には効力を持たないこともあります。読み書き集合をそのまま衝突判定の基準にすると、衝突率は高めに出るため、リバートと再実行で吸収する必要があります。
境界も明確に述べておきます。実装コントラクトが完全に ERC-7201 の名前空間レイアウトを採用していれば、原則としてプロキシのスロットとは衝突しなくなりますが、代償として任意のスロット番号をソースの順序から読み出せなくなり、ソース順序だけを頼りにするツール(一部の静的解析器、ブロックエクスプローラー)は機能しなくなります。さらに、インラインアセンブリの sstore はコンパイラのレイアウト外の任意のスロットへ書き込めるため、AST を解析するだけのレイアウトツールはこうした書き込みをカバーできません。これもコントラクトのストレージ挙動を分析する際に繰り返し現れる不確実性の源です。
出典
- Solidity ドキュメント、Layout of State Variables in Storage and Transient Storage:https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
- EIP-1967、Proxy Storage Slots:https://eips.ethereum.org/EIPS/eip-1967
- ERC-7201、Namespaced Storage Layout:https://eips.ethereum.org/EIPS/eip-7201
- EIP-2929、Gas cost increases for state access opcodes:https://eips.ethereum.org/EIPS/eip-2929
- OpenZeppelin ドキュメント、Writing Upgradeable Contracts:https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable