アップグレード可能なコントラクトのストレージ変更|値がずれる原因と安全な移行手順
約14分で読めます
約14分
目次(タップで折りたたみ)
ある開発チームが、アップグレードできるコントラクトの新しい版を用意しました。変えたのは、1つの変数の型を大きくしただけです。ところが本番に出すと、隣にあった別の値が、おかしな数字で読まれるようになりました。
原因は、保存場所の並びです。Solidityでは、小さい値がいくつか同じスロット(変数を入れる番号付きの棚)に詰めて入ります。型を「大きくしただけ」でも、隣の値の位置(オフセット)が変わるのです。mappingの変数の宣言の位置を変えれば、全要素の参照先が変わります。
アップグレードできるコントラクトでは、コードを差し替えても、保存済みのデータはそのまま残ります。新しいコードがそのデータを同じ意味で読めなければ、値が混ざるおそれがあります。読めたとしても、単位や形を変えたデータが自動で変換されることはありません。
そこで、2つのことを分けて確かめます。
- 保存場所の並びが変わっていないか(レイアウトの互換性)
- 既存の値を、新しい形へ移す処理が要るか(値の移行)
移す必要があるなら、初期化、分けての移行、やり直し、失敗したときの立て直し方まで設計します。以下では、EVMのプロキシ型コントラクトを本番で運用しているチームが、この2つの問いに順に答えていく流れで見ていきます。プロキシ方式の選び方や、アップグレードの権限・待ち時間の決め方はスマートコントラクトのアップグレード設計で扱っています。
この記事で使う言葉
- プロキシ/実装コントラクト:利用者が呼ぶ入口でデータを持つ側/処理の中身を持ち、差し替えられる側
- スロット/オフセット:変数を入れる番号付きの棚/棚の中の位置
- ストレージレイアウト:どの変数がどのスロットのどこに入るかの並び
- reinitializer:新しい版の初期化を、版ごとに一度だけ実行させる仕組み
- フォーク環境:本番チェーンの状態を写し取った試験環境
問い1:保存場所の並びは、新しいコードでも同じか
プロキシ方式では、利用者が呼ぶプロキシのデータはそのままで、delegatecallで呼ぶ先の実装コントラクト(implementation)を差し替えます。新しい実装の変数の宣言は、同じデータをどう読むかを決める設計図です。
並びが崩れると、別の変数の値を読んでしまうおそれがあります。見るのは、既存の変数のスロットとオフセットを変えず、新しいコードが古いデータを同じ意味で読めるかです。
OpenZeppelin Upgrades Pluginsは、旧版と新版の並びを比べます。変数の型・順番の変更、削除、継承の順番の変更などを見つけてくれます。そこで、validateUpgradeによるこの検証を、CIを通す条件にします(OpenZeppelin: Modifying Your Contracts)。
正確な規則はSolidity公式のLayout of State Variables in Storageで確かめます。mappingや動的配列の実データの位置は、キーと基準のスロットのハッシュから決まります。並びの出力は、リリースごとに保存しておきます(出力のコマンドは後半)。
問い2:既存の値を、新しい形へ移す必要があるか
並びの検証が確かめるのは、並びが合っているかまでです。「新しい手数料が業務上正しい値か」「全利用者の集計を作れたか」までは保証しません。だから、検証が通っても、それで終わりではありません。
新しい変数の初期値、単位、構造、索引、集計値を、既存の状態から作る必要があるか。これを別に考えます。変更の中身は、5種類に分けると移し方を選べます。
| 変更 | 例 | 基本方式 |
|---|---|---|
| 互換な追加 | 末尾へuint256 feeBpsを追加 | アップグレード後に初期化 |
| 予約領域の利用 | __gapを縮めて変数を追加 | レイアウト検証+初期化 |
| 名前空間ストレージの拡張 | ERC-7201の名前空間内の構造体の末尾へ追加 | 名前空間単位で検証 |
| 既存値の変換 | ベーシスポイントから固定小数点へ単位を変更 | 新しいスロットへ書き、読み取りを切り替え |
| 非互換・大規模な変更 | mappingのキー変更、別のプロキシ体系へ移行 | 遅延移行または新アドレス |
__gapは、将来の変数のために空けておく予約の領域です。種類ごとの主な注意点は次のとおりです。
- 互換な追加:ゼロという値が、正しい値なのか、まだ初期化していないのかを区別する
- 予約領域の利用:継承先も含めて、予約の領域の大きさを確かめる
- 名前空間ストレージの拡張:名前空間のIDを変えない・使い回さない
- 既存値の変換:上書きする前の値と変換の式を、後から確かめられるようにする
- 非互換・大規模な変更:すべてのキーを、チェーン上で数え上げることはできない
少しの初期化なら、アップグレードと同じトランザクションで済ませる
足した設定値が少しなら、実装の差し替えとreinitializerの呼び出しを1つのトランザクションにまとめます。そうすれば、「新しいコードなのに初期化前」という途中の状態を、外に見せずに済みます。
Transparent ProxyやUUPSプロキシでは、OpenZeppelinのアップグレードAPIに呼び出しの中身(calldata)を渡します。内部でupgradeToAndCallに当たる処理が実行されます。
次の例は、版2の初期化で手数料を設定し、上限を超える値を拒むものです。
function initializeV2(uint256 initialFeeBps)
external
reinitializer(2)
{
if (initialFeeBps > 1_000) revert FeeTooHigh();
feeBps = initialFeeBps;
emit StorageMigrated(1, 2, initialFeeBps);
}
reinitializer(2)は2重の実行を防ぎます。ただ、値が正しいかや、誰が呼べるかまでは自動で守ってくれません。
- 初期化の版番号、想定する旧版、変更後の値を検査する
- アップグレードの権限は、マルチシグか、ガバナンス+タイムロックに持たせる
- 実装コントラクトそのものは、第三者に直接初期化されないようにする(方法は後半)
initializerとreinitializerの注意点はOpenZeppelin公式ガイドを参照してください。
大量のmappingは、どう移すか
EVMのmappingは、キーの一覧を持っていません。過去に書かれた全キーを、コントラクトだけで見つけて一度に変換することはできません。だから「アップグレードのときに全件をループで処理する」は、たいてい成り立ちません。
先に次のことを測ります。
- 対象のキーを、イベント・インデクサーや、別の数え上げられる集合から取り戻せるか
- 1件の変換にかかるガスと、全体の件数
- 移している間に入ってくる書き込み
そのうえで、次の4つから選びます。
- 遅延移行(lazy migration):利用者が次に操作したときに古い形を読み、新しい形へ1件だけ変換します。件数が多く、アクセスがばらけている場合に向きます。
- 分割一括移行(batch migration):対象のキーと期待する古い値をチェーンの外で作り、件数の上限付きの関数へ、複数のトランザクションで渡します。処理位置(カーソル)か、完了の印(ビットマップ)を必ず持たせます。
- Merkle証明による請求(Merkle claim):古い状態のスナップショットから作った要約の値(ルート)を保存し、利用者が証明付きで新しい状態を請求します。新しいアドレスへ移る場合や、移し終えるまでの長い期間を許せる場合に使えます。
- 新旧の併用読み書き(dual read/write):移している間は新しい形を先に見て、まだ移していなければ古い形を読みます。書き込みを2重にする期間と、終える条件を限ります。
遅延移行は、1回のトランザクションのガスを抑えられます。その代わり、最初の操作だけ高くなります。読み取りだけの関数では、状態を書き換えられない点にも注意が要ります。
そこで、表示用に古い形も読める関数と、更新のときに移す道を分けます。リレイヤー(利用者の代わりにトランザクションを送る仕組み)やフロントエンドが、「まだ移していない」状態を扱えるようにします。遅延移行のコード例は後半に載せています。
複数のモジュールが別々に変数を足すなら、置き場所を名前で分ける方法がある
複数のモジュールや継承の階層が、それぞれ勝手に状態を足す作りもあります。その場合は、ERC-7201の名前空間ストレージ(namespaced storage)が選択肢になります。モジュールごとに保存場所を名前で分けて、ぶつからないようにする方法です。Solidity 0.8.20以降とOpenZeppelin Upgrades Pluginsで、名前空間の中の互換性も検証できます。
ただし、名前空間ストレージは既存の値を自動で移す仕組みではありません。今までの並びから名前空間へ移るなら、古いスロットを読んで新しい名前空間へ書く移行の処理が要ります。
名前空間のIDにも気をつけます。IDを変えると、別の基準スロットになります。同じIDを関係のないモジュールで使い回すと、ぶつかります。IDとスロットの計算結果は、デプロイの記録(マニフェスト)に固定しておきます。書き方と検証の条件は後半にまとめました。
失敗したら、戻すのではなく、止めて直して再開する
アップグレードのトランザクションが失敗(revert)すれば、実装の差し替えと、同じトランザクションの中の初期化は元に戻ります。
一方、複数のトランザクションに分けた一括移行では、終わった分の書き込みが残ります。古い実装に戻しても、新しい並びで書いた値や、新しい版で受け付けた操作は、自動では元に戻りません。
そこで運用の手順書では、次の状態を分けて扱います。
- 準備完了(PREPARED):新しい実装、並びの差分、calldata、スナップショットのブロック、監査の結果を確定した。
- 実装更新済み(UPGRADED):実装は差し替えたが、大量の移行はまだ。この間に許す機能をはっきり書く。
- 移行中(MIGRATING):処理位置、処理した件数、失敗したキー、新旧の合計値を見張る。同じ一括処理をやり直しても、2重に数えない。
- 確定(FINALIZED):不変条件と対象の件数を照らし合わせ、古い形への書き込みの道を閉じる。確定の操作そのものもタイムロックにかける。
緊急停止を、移行と関係のない資金の回収や、勝手なアップグレードまでできる万能の鍵にはしません。停止、アップグレード、一括移行の実行、確定は、それぞれ別の権限にします。ふだんのアップグレードにはタイムロックを、緊急停止には範囲の狭い即時の権限を与えます。
権限の分け方と解除の条件はスマートコントラクトの緊急停止設計で詳しく扱っています。安全と分かっている実装へ戻せるのは役に立ちます。ただ、「戻せばデータも戻る」とは考えないでください。
本番の前に、並びの差分と状態の不変条件を別々に確かめる
並びが合っていることと、中身の値が正しいことは、別の試験で確かめます。段階ごとの検証は次のとおりです。
| 段階 | 検証 | 合格条件 |
|---|---|---|
| ビルド | コンパイラ設定とストレージレイアウトのJSONを旧リリースと比較 | 全差分を分類し、説明のないスロット・オフセットの変更がない |
| 静的検証 | validateUpgradeまたはFoundry Upgradesでの検証 | 互換性エラーがなく、安全でない操作を許可する場合は根拠がある |
| フォーク環境でのテスト | 本番のスナップショット上で実際のアップグレード用calldataを実行 | 権限、イベント、ガス、主要な状態が期待どおり |
| 不変条件 | 総供給量、残高合計、債務、所有者、ロール、nonce、停止状態 | 変えると定義した値以外は前後で一致 |
| 中断 | 一括処理の途中でのrevert・停止・再開・重複投入 | 欠落や二重計上なく同じ最終状態へ収束 |
| 運用 | アップグレード・移行のイベントと状態を監視 | 未知の実装、処理位置の停止、不一致をアラート |
フォーク環境での試験では、代表的な数件だけでは足りません。境目の状態も入れます。ゼロの値、最大の値、古い版、停止中、権限を変えたアカウント、長い配列などです。
移した後に比べる集計値を、新しいコードだけで計算すると、比べる側も同じバグを抱えているかもしれません。だから、イベント・インデクサーや、スロットの直接の読み出しなど、別の道でも照らし合わせます。
操作の並びを使った検証の組み立て方は不変条件テストの設計、本番に出すまでの変更の管理はスマートコントラクトのリリース管理で扱っています。
新しいアドレスへ移る方がよいのは、どんなときか
互換性のない変更を、プロキシの中で無理に吸収するより、新しいコントラクトへ移す方が安全なことがあります。特に次のような場合です。
- 古い並びを正確に取り戻せない
- 既存のコードに、危険なdelegatecallや自己破棄の道がある
- プロキシの方式や、どこまでを信頼するかを根本から変える
- 新旧2つの形を、長い期間維持できない
新しいアドレスへ移るときは、次のことまでが移行の範囲です。
- スナップショットのブロック、残高・権利の2重の行使の防止
- 古いコントラクトを止める条件、承認・承認額(allowance)
- 外部のプロトコル・取引所・インデクサー・フロントエンドの参照先の更新
ethereum.orgも、別のインスタンスへコントラクトを移すときの注意を挙げています。データと残高を移すだけでなく、連携先と利用者を新しいアドレスへ切り替える必要がある、という点です(ethereum.org: Contract migration)。アドレスを保つために、確かめようのないスロットの書き換えを選ばないことが大切です。
実行前のレビューで固めておくもの
- 新旧の実装のコードハッシュ、コンパイラ設定、ストレージレイアウトのJSONと差分
- 変更の分類、変換の式、対象の件数、スナップショットのブロック、ガスの見積もり
- アップグレード用のcalldata、移行の一括処理、権限を持つ人、タイムロック、実行の順番
- 移行中に許す・止める機能、処理位置、何度実行しても同じ結果になること(冪等性)、再開・確定の条件
- フォーク環境での試験と不変条件の結果、監視のクエリ、アラート、事故対応の手順
採用する方式は、対象のコントラクトの実装、資産、連携先、運用の権限に合わせて、個別に確かめます。レビューの工程と費用の考え方はスマートコントラクト監査の工程と費用目安で整理しています。
実装する人向けの詳細
ここからは、移行のコードや検証を実際に書く開発者向けの補足です。
並びの出力を残す
solc --storage-layoutかforge inspect <Contract> storage-layoutの出力を、リリースごとに保存します。
実装コントラクトを直接初期化させない
実装コントラクトのコンストラクタで_disableInitializers()を呼びます。
遅延移行のコード例
利用者のデータを最初に読むときに、古い形から新しい形へ1件だけ移す例です。
function _loadAccount(address user) internal returns (AccountV2 storage a) {
a = accountsV2[user];
if (!a.migrated) {
AccountV1 storage old = accountsV1[user];
a.balance = old.balance;
a.limit = uint128(old.dailyLimit);
a.migrated = true;
emit AccountMigrated(user);
}
}
この変換のコードは、考え方を示すものです。実装では新旧の名前空間を分け、次のことを1つずつ確かめます。
- 再入の可能性、停止状態、権限
- 整数の範囲
- 既存の値がゼロの場合
ERC-7201の書き方と検証の条件
名前空間ごとの構造体に@custom:storage-location erc7201:<NAMESPACE_ID>を付け、標準の式で基準スロットを導きます。Solidity 0.8.20以降は、この注釈をAST(コンパイラが作る構文の木)に出力します。OpenZeppelin Upgrades Pluginsは、それを使って名前空間の中の互換性を検証します。
0.8.20より古いコンパイラで注釈を使うと、プラグインはエラーを出します。検証に要る情報が足りないためです(ERC-7201仕様、OpenZeppelin: Writing Upgradeable Contracts)。
XTELAができること
私たちは、アップグレード可能なコントラクトの構成、ストレージレイアウトの差分、権限とタイムロック、フォーク環境でのテスト、移行スクリプトと監視を一続きの設計として組み立て、開発まで進めます。貴社の本番状態のスナップショットを使って移行をリハーサルするPoCから始めることもできます。移行方式の選定や手順の確認はお問い合わせフォームからご連絡ください。
主要参考資料
- OpenZeppelin Docs: Writing Upgradeable Contracts(名前空間ストレージの検証とSolidity 0.8.20の要件。2026年9月24日確認)
- OpenZeppelin Hardhat Upgrades API: validateUpgrade(2026年8月12日確認)
- Solidity Docs: Layout of State Variables in Storage(2026年8月12日確認)
- ERC-1967: Proxy Storage Slots
- ERC-7201: Namespaced Storage Layout
- ethereum.org: Upgrading smart contracts(2026年8月12日確認)
資料の確認日と注意
一次資料は2026年8月12日に確認し、OpenZeppelin Upgradesの名前空間ストレージ対応は2026年9月24日に再確認しました。利用するコンパイラ、プロキシ、アップグレード用プラグイン、ガバナンス実装の版と監査範囲は、実行時に公式資料で再確認してください。