ERC-7201 Namespaced Storage入門:upgrade時のstorage collisionを検証する
ERC-7201のnamespace IDとroot計算、storage-location annotation、安全な末尾追加、危険な並べ替え・型変更・ID typo、upgrade validationの境界をlocalで検証します。

Upgradeable contractでは、proxyのaddressとstorageを残したまま、実行するimplementation codeを差し替えます。新しいimplementationが同じslotを別の意味で読むと、owner、残高、limitなどが壊れます。これがstorage collisionです。
ERC-7201 Namespaced Storageは、moduleごとのstateを固有のnamespaceへ分ける規約です。継承したcontractの変数を1本の連続したlayoutへ積み重ねる代わりに、account、rewards、governanceのようなstorage structを、それぞれ離れたrootから始められます。
ただし、namespaceを導入しただけでupgradeが安全になるわけではありません。同じnamespace内のfield順・型・packingは引き継ぐ必要があり、namespace IDのtypoは別の空領域を読む原因になります。annotationとassembly slotが一致しているか、validatorが何を見ているかも分けて確認します。
この記事はupgradeable contractを実装・reviewする開発者と、proxyのstorage layoutを調べる人向けです。完了条件を chain → proxy → old implementation → new implementation → compiler settings → namespace ID → root → field layout → raw slots → migration → authority の順に固定します。
検証にはSolidity 0.8.36、Foundry / Anvil 1.8.0、OpenZeppelin Upgrades Core 1.46.0、viem 2.56.0を使いました。EVM versionはCancun、optimizerは200 runs、local chain IDは31347です。public RPC、mainnet fork、browser wallet、実資産は使っていません。
まず3種類のlayoutを分ける
Solidityの通常storage、__gap、ERC-7201 namespaceは、目的と壊れ方が異なります。
| 方法 | stateの置き方 | 得意なこと | 残る注意点 |
|---|---|---|---|
| 通常storage | 継承順を含む連続layout | 単純なcontract、末尾追加 | base contractへの途中追加、並べ替え、型変更で後続slotが動く |
__gap |
将来用の連続slotを予約 | base contractへ変数を追加する余地を残す | 追加分だけgapを正しく縮める必要があり、module同士は分離されない |
| ERC-7201 | namespaceごとに別rootを持つstruct | module・継承tree間の衝突を分離 | 同じnamespace内の互換性、IDとrootの一致、tool coverageは別確認 |
Solidityの通常layoutでは、値型は可能なら同じ32-byte slotへpackingされます。継承がある場合も、C3 linearized orderに従ってbase側から配置され、異なるcontractで宣言したfieldが同じslotを共有する場合があります。
固定したV1では、address ownerとuint96 limitがslot 0へ収まり、uint256 pointsがslot 1へ置かれました。
| field | slot | byte offset | type |
|---|---|---|---|
owner |
0 | 0 | address |
limit |
0 | 20 | uint96 |
points |
1 | 0 | uint256 |
V2でnonceを末尾へ追加すると既存3 fieldは動かず、nonceはslot 2です。一方、limitとownerを並べ替えると同じslot 0でもoffsetが変わります。ownerをbytes32へ変えると、後続fieldはslot 1、2へ押し出されます。変数名が残っていても、raw 32 bytesの意味は同じではありません。
__gapは予約を使った分だけ縮める
OpenZeppelinの従来型upgradeable contractでは、base contractに固定長配列の__gapを置き、将来の変数追加に使う方法があります。
contract GapV1 {
uint256 public value;
uint256[49] private __gap;
}
contract GapV2Safe {
uint256 public value;
uint256 public added;
uint256[48] private __gap;
}
ここではV1のgapがslot 1から49を予約します。V2はslot 1へaddedを入れ、gapを48個へ縮めるため、継承先の開始位置を維持できます。変数を追加したのにgapを49個のまま残すと、後続stateを1 slotずらします。
gapは便利ですが、予約量を人が管理し、継承tree全体の連続layoutを追う必要があります。独立moduleごとのstorageを明示したい場合にERC-7201が選択肢になります。
ERC-7201のrootを式から計算する
ERC-7201のerc7201 formulaは、namespace ID idからrootを次の式で求めます。
keccak256(
abi.encode(uint256(keccak256(bytes(id))) - 1)
) & ~bytes32(uint256(0xff))
今回のnamespace IDはmikan.storage.Mainです。計算結果は次のrootになりました。
0x351e99d536f6929a0d7758896402b97bf95ac2715119a5c4fc2bfc39425ad500
最初のhashから1を引いて再度hashすることで、Solidityの通常storage treeと単純に重なる形を避けます。最後に下位8 bitをzeroへmaskするため、rootは256 slotsの境界に揃います。固定結果も末尾が00で、uint256(root) % 256 == 0でした。
namespace IDは表示名ではなくstorage addressの入力です。大文字・小文字、dot、綴りを1文字変えるだけで別rootになります。organization、package、module名などを含む衝突しにくいIDを決め、定数・annotation・設計記録で同じ値を使います。
annotation・struct・assembly slotを一致させる
基本形は、storage structへ@custom:storage-location erc7201:<ID>を付け、計算済みrootをassemblyでstruct pointerへ割り当てます。
/// @custom:storage-location erc7201:mikan.storage.Main
struct MainStorage {
address owner;
uint96 limit;
uint256 points;
uint256 nonce;
}
bytes32 private constant MAIN_STORAGE_LOCATION =
0x351e99d536f6929a0d7758896402b97bf95ac2715119a5c4fc2bfc39425ad500;
function _getMainStorage() private pure returns (MainStorage storage $) {
assembly ("memory-safe") {
$.slot := MAIN_STORAGE_LOCATION
}
}
Solidity 0.8.20以降はNatSpec annotationをASTへ含めるため、対応toolがnamespace layoutを解析できます。しかしSolidity compiler自身は、annotationのIDからrootを再計算してassembly定数との一致を強制しません。compile成功は次を保証しません。
- namespace IDがupgrade前後で同じ
- 同じIDが別structで重複していない
- struct fieldが互換な順序・型を保つ
- assemblyがannotationに対応するrootを指す
- libraryやcustom assemblyをvalidatorが完全に追跡する
定数は手入力だけにせず、formulaから生成・assertし、upgrade前後のbuild情報をvalidatorへ渡します。
安全な末尾追加では既存fieldを動かさない
V1のnamespace内layoutは次の3 fieldです。relative slotはnamespace rootを0とした相対位置です。
| version | field | relative slot | byte offset | type |
|---|---|---|---|---|
| V1 | owner |
0 | 0 | address |
| V1 | limit |
0 | 20 | uint96 |
| V1 | points |
1 | 0 | uint256 |
| V2 safe | nonce |
2 | 0 | uint256 |
V2 safeは既存3 fieldを同じ順序・型で残し、末尾へnonceを追加します。local proxyをV1からV2 safeへ切り替えると、owner、limit = 12345、points = 987654321は保持され、root + 2へnonce = 77を書けました。
通常layoutの末尾追加、正しく縮めたgap、namespace内の末尾追加は、いずれもUpgrade Core validationを通過しました。ただしvalidator passは、initializer、migration、access control、business logicまで安全という意味ではありません。
同じnamespace内の並べ替えと型変更は危険
namespaceは別moduleとの衝突を分離しますが、その部屋の中の収納順までは自動で守りません。
// V1
address owner; // relative slot 0, offset 0
uint96 limit; // relative slot 0, offset 20
// unsafe V2
uint96 limit; // relative slot 0, offset 0
address owner; // relative slot 0, offset 12
この並べ替えでは、V1が保存した同じ32-byte wordをV2が異なる境界で切り出します。型変更もpackingと後続slotを変えます。namespaceを変えずにfieldをrenameする場合も、対応toolがdelete + insertとして扱う可能性があるため、annotationやrename optionを含むtool固有手順を確認します。
固定validationでは次の結果になりました。
| change | reference | result | 主な検出内容 |
|---|---|---|---|
| 通常layoutの末尾追加 | Traditional V1 | pass | 既存fieldを保持 |
__gapを1つ消費 |
Gap V1 | pass | 追加分だけgapを縮小 |
| namespace内の末尾追加 | Namespaced V1 | pass | 同じIDで既存fieldを保持 |
| 通常layoutの並べ替え | Traditional V1 | fail | ownerのdeleteとして検出 |
| 通常layoutの型変更 | Traditional V1 | fail | address → bytes32を検出 |
| namespace内の並べ替え | Namespaced V1 | fail | namespace内のowner deleteを検出 |
| namespace内の型変更 | Namespaced V1 | fail | namespace内のaddress → bytes32を検出 |
| namespace IDのtypo | Namespaced V1 | fail | 旧namespaceのdeleteを検出 |
| 同一contract treeのID重複 | 単体validation | fail | duplicate namespaceを検出 |
| contract外library struct | 単体validation | pass with warning | namespace structがcontract外で解析対象外 |
この表はOpenZeppelin Upgrades Core 1.46.0へSolidity build infoとreference contractを渡した固定結果です。version、compiler output、annotationの置き場所、CLI optionが違えば、同じ検出範囲だと仮定しません。
namespace IDのtypoは「初期化されたように見えない」状態を作る
mikan.storage.Mainをmikan.storage.Mianと綴ると、rootは次へ変わりました。
0x2a2faa7b8b284411dad3c4b390dfa4621dc55ce9306dc4c5c01e9cdd87c40900
旧rootにはownerとpointsが残っていますが、typo rootの対応slotはzeroでした。V2のgetterは旧stateを失ったのではなく、別の空storage treeを読んでいます。この状態でinitializer guardがowner == address(0)だけなら、再初期化できる設計になる可能性があります。
ID変更が意図的なmigrationなら、新旧root、copy対象field、順番、実行authority、再実行防止、完了marker、rollback可否を設計します。単に定数を差し替えて「upgrade完了」としません。
duplicate IDとlibrary boundaryを別に確認する
同じ継承treeで2つのstructが同じnamespace IDを使うと、両者は同じrootを指します。名前の異なるmoduleでもraw slotがaliasし、片方のwriteがもう片方のstateを変えます。固定したcontract内重複はvalidatorがrejectしました。
一方、contract外のlibraryに置いたnamespace structと手動root衝突は、今回のvalidationではwarningを出してpassしました。これは衝突が安全という意味ではなく、その定義が解析対象外であるというtool boundaryです。
custom assembly、delegatecall先、diamond storage library、生成codeを含む設計では、validatorだけに依存せず次を追加します。
- 全namespace IDと計算rootの一覧を作る
- 重複IDだけでなく重複rootを検査する
- annotation IDとassembly constantの一致を自動照合する
- proxyをupgradeして旧値をraw slotとgetterの両方で読む
- validation対象外warningを失敗扱いにするか方針を決める
公開した固定検証JSONには、compiler layout、namespace root、validation 10ケース、local proxyのupgrade前後slotを保存しています。生成画像は概念説明であり、transaction、監査、upgrade安全性の証拠には使っていません。
Proxy・UUPS・Beacon・Diamondとの関係
ERC-7201が定めるのはstorage layoutのnamespaceです。implementation addressの保存場所、upgrade function、upgrade authority、call routing、initializerやmigrationは定めません。
| pattern | ERC-7201が助ける部分 | 別に確認する部分 |
|---|---|---|
| UUPS | implementation側stateのmodule分離 | ERC-1967 slot、upgradeToAndCall、_authorizeUpgrade、implementation lock |
| Transparent | implementation側stateのmodule分離 | admin / ProxyAdmin、admin fallback制限、upgrade event |
| Beacon | 各proxyが読むimplementation側layout | beacon address、beacon implementation、beacon owner、全instanceへの影響 |
| Diamond | facet間storageのnamespace設計 | selector routing、diamondCut authority、facet lifecycle、library root衝突 |
| EIP-7702 target | delegated codeが読むaccount storageの整理 | authorization tuple、target code、EOAに残るstate、permission回収 |
ERC-1967のimplementation・admin・beacon slot、call context、upgrade eventはERC-1967 Proxyをraw slotから調べる手順で確認できます。sourceとruntimeをcompiler設定から再構築する場合はverified sourceの再現手順へ進んでください。
compiler設定の変更そのものは、Solidity optimizer・via-IR・EVM versionの比較でcreation、runtime、metadata、gas、正常系・failure系を同じmatrixへ固定しています。
EIP-1167 minimal cloneは通常implementationを固定し、cloneごとにstorageを持ちます。Minimal Proxyのbytecode・initializer・factoryを検証する記事では、45-byte runtimeとclone storageを分けています。
facetごとにDiamond Storage、AppStorage、ERC-7201式のrootが混在し得る構成は、EIP-2535 Diamondのselector routing・loupe・DiamondCutを読む手順で、facet lifecycle、initializer、raw slots、cut authorityまでつなげています。
EIP-7702ではEOA addressにdelegation後のstateが残り得ます。EIP-7702のdelegate切替・解除後に残るstateと合わせると、「実行codeを変えること」と「既存storageを移行・消去すること」が別だと確認できます。
Upgrade前後に残すチェックリスト
Buildとlayout
- old / new implementationのsource commitとartifact hashを固定した
- Solidity完全version、optimizer、runs、EVM、via-IR、remappingを揃えた
- compilerの
storageLayoutとASTをbuild infoへ含めた - reference contractを指定してupgrade validationした
- warning、除外namespace、custom assemblyを記録した
Namespace
- namespace IDを文字列の完全一致で比較した
- formulaからrootを再計算した
- rootの下位8 bitがzeroか確認した
- annotation、constant、assembly pointerが同じID / rootを指す
- contract、base contract、library、facetを横断してID / root重複を検査した
- 同じnamespace内のfield順、slot、offset、型、packingを比較した
- 追加fieldは互換な末尾追加か、明示migrationかを決めた
Chain上の状態
- chain ID、proxy address、fixed block number / hashを記録した
- old / new implementation addressとruntime code hashを読んだ
- upgrade前の重要getterとraw slotsを保存した
- シミュレーションまたはlocal fork相当の隔離環境でupgrade後stateを読んだ
- migration calldata、initializer version、再実行防止を確認した
- upgrade receipt、event、post-stateを同じproxyへ対応させた
- admin、owner、role、multisig、timelockまでauthority chainを追った
本番upgradeでは「validatorがpassした」だけで承認せず、proxy address、old/new implementation、block、raw state、migration、authorityを同じ記録へ揃えます。逆にvalidatorがfailした場合、production dataを見て問題ないと推測して無視せず、layout変更か明示migration設計を見直します。
まとめ
ERC-7201は、moduleごとのstateを固有namespace rootへ分離し、継承順や別moduleによるstorage collisionを減らす規約です。rootはnamespace IDから二段階のhashと256-slot alignmentで計算し、storage structのannotation、定数、assembly pointerを一致させます。
安全なupgradeでは、同じnamespace内の既存fieldを同じ順序・offset・型で残し、新しいfieldを互換な末尾へ追加します。並べ替え、型変更、ID typo、duplicate ID、解析対象外のlibraryは、namespaceを採用しても別のfailureになります。
最終確認は chain → proxy → old implementation → new implementation → compiler settings → namespace ID → root → field layout → raw slots → migration → authority です。namespace、validator、raw chain stateの3つを対応させてからupgrade判断へ進んでください。
確認した一次情報
- ERC-7201 Namespaced Storage Layout確認日: 2026/08/31
- Solidity Layout of State Variables in Storage確認日: 2026/08/31
- OpenZeppelin Writing Upgradeable Contracts確認日: 2026/08/31
- OpenZeppelin Upgrades Core API確認日: 2026/08/31
- OpenZeppelin Contracts Upgradeable確認日: 2026/08/31
- ERC-1967 Proxy Storage Slots確認日: 2026/08/31



