3MIKAN
仮想通貨直コン

ERC-7201 Namespaced Storage入門:upgrade時のstorage collisionを検証する

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

3MIKANのブランドキャラクターが独立した複数の保管室と各室の収納順を点検するERC-7201 Namespaced Storageの記事画像

Upgradeable contractでは、proxyのaddressとstorageを残したまま、実行するimplementation codeを差し替えます。新しいimplementationが同じslotを別の意味で読むと、owner、残高、limitなどが壊れます。これがstorage collisionです。

ERC-7201 Namespaced Storageは、moduleごとのstateを固有のnamespaceへ分ける規約です。継承したcontractの変数を1本の連続したlayoutへ積み重ねる代わりに、accountrewardsgovernanceのような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は別確認
通常storageの連続棚、将来用gapを含む棚、namespaceごとに独立した保管室を並べて比較する概念図
図は3方式の空間関係を示す概念図です。正確なslot、offset、型はcompiler出力と本文の表を正本にします。

Solidityの通常layoutでは、値型は可能なら同じ32-byte slotへpackingされます。継承がある場合も、C3 linearized orderに従ってbase側から配置され、異なるcontractで宣言したfieldが同じslotを共有する場合があります。

固定したV1では、address owneruint96 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です。一方、limitownerを並べ替えると同じslot 0でもoffsetが変わります。ownerbytes32へ変えると、後続fieldはslot 12へ押し出されます。変数名が残っていても、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 1addedを入れ、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を材料に二段階の変換と整列を経て独立したstorage roomの開始地点を得る概念図
図はIDから独立rootへ進む関係だけを示します。式、ID、root値は本文とraw observationで確認してください。

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へ切り替えると、ownerlimit = 12345points = 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.Mainmikan.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だけに依存せず次を追加します。

  1. 全namespace IDと計算rootの一覧を作る
  2. 重複IDだけでなく重複rootを検査する
  3. annotation IDとassembly constantの一致を自動照合する
  4. proxyをupgradeして旧値をraw slotとgetterの両方で読む
  5. 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判断へ進んでください。

確認した一次情報