3MIKAN
仮想通貨直コン

Diamond Proxyの読み方:EIP-2535 facet・selector・storage・upgradeを検証する

EIP-2535 Diamondのselector routing、facet、loupe、DiamondCut、initializer、storage pattern、upgrade authorityをローカル環境で検証します。

3MIKANのブランドキャラクターが1つの中央入口から複数の機能室へ振り分ける経路と共有基盤を点検するEIP-2535 Diamondの記事画像

EIP-2535 Diamondは、1つのcontract addressへ複数の実装contractを組み合わせる仕組みです。機能ごとの実装をfacetと呼び、diamondのfallbackがcalldata先頭4 bytesのfunction selectorを見て、対応するfacetへdelegatecallします。

これは「implementation slotを1つ読む」proxyとは構造が違います。ERC-1967 Proxyをraw slotから読む方法ではimplementation / beacon / adminを分けますが、Diamondはselectorごとに実行先を分けます。同じdiamond addressでも、transferはFacet A、mintはFacet B、loupeはFacet Cというように実行codeが分かれます。stateはfacet側ではなくdiamond側に残るため、routing、facet code、storage layout、upgrade authorityを一緒に読まなければ現在の動作を説明できません。

この記事はDiamondを実装・reviewする開発者と、既存diamondをread-onlyで調べる人向けです。完了条件を chain → diamond → selector → facet → source and runtime → loupe → DiamondCut logs → initializer → storage roots → authority の順に固定します。

検証にはSolidity 0.8.36、Foundry / Anvil 1.8.0、viem 2.56.0を使いました。EVM versionはCancun、optimizerは200 runs、local chain IDは31348です。public RPC、mainnet fork、browser wallet、user key、public transaction、実資産は使っていません。

Diamondはselectorごとにfacetを選ぶ

通常のsingle-implementation proxyは、fallbackから1つのimplementationへほぼすべてのcallを転送します。Diamondでは、diamondがbytes4 selector => address facetに相当するrouting tableを持ちます。

calldata
  └─ first 4 bytes = function selector
       └─ diamond routing table
            └─ selected facet code
                 └─ delegatecall with diamond address and storage

delegatecallではfacetのcodeを使いますが、address(this)、storage、balanceはdiamondのcontextです。msg.sendermsg.valueも元のcallから引き継がれます。facet contract自身のstorageへ書く通常callとは分けて考えます。

複数の4分割された印が中央入口で振り分けられ、異なる3つの機能室と共通の保管基盤へつながるselector routingの概念図
図はselector routingと共有storageの関係を示す概念図です。正確なselector、facet address、runtime code hash、storage rootは本文と固定検証JSONを正本にします。

今回のローカル再現では、V1 facetのsetValue(17)をdiamond addressへ送ると、diamondのslot 017、slot 1がversion 1になりました。呼び出し元もdiamondのslot 3へ残り、V1 facet contract自身のslot 0はzeroのままです。codeの場所とstateの所有者が違うことをraw slotで確認できます。

function selectorをsignatureから再計算する

function selectorはcanonical function signatureをKeccak-256へ通した先頭4 bytesです。戻り値の型はsignatureへ含めません。

bytes4(keccak256("setValue(uint256)")) = 0x55241077

配列、tuple、uintuint256のcanonical表記を誤ると別の値になります。calldataの手読みとcanonical typeの基本はABI・function selector・calldata・event logsを読む方法で確認できます。

4 bytesは約43億通りなので、異なるsignatureが同じselectorになるcollisionは起こり得ます。selectorだけを検索して関数名を確定せず、対象facetのABI、verified source、runtime bytecode、compiler条件まで照合します。

ローカル再現の最終routingから一部を抜き出すと次の通りです。

signature selector final facet
diamondCut((address,uint8,bytes4[])[],address,bytes) 0x1f931c1c DiamondCut facet
facets() 0x7a0ed627 Loupe facet
facetAddress(bytes4) 0xcdffacc6 Loupe facet
setValue(uint256) 0x55241077 Counter V2 facet
value() 0x3fa4f245 Counter V1 facet
owner() 0x8da5cb5b Ownership facet
note() 0x26d111f5 zero address(remove後)

setValueだけをV2へreplaceしたため、同じCounter機能でもvalue()など7 selectorsはV1に残ります。「facet単位でupgradeされたはず」と推測せず、selectorごとの実際の行き先を一覧にします。

Loupeの4関数で現在のroutingを読む

EIP-2535のloupeは、現在のfacetとselectorを調べるread-only interfaceです。

function 返すもの 主な用途
facets() facet addressとselectorsの全group current inventoryの一括取得
facetFunctionSelectors(address) 指定facetのselectors 1 facetの担当範囲
facetAddresses() current facet address一覧 code確認targetの列挙
facetAddress(bytes4) selectorのcurrent facet 既知selectorの直接確認

IDiamondLoupeのinterface IDは4関数selectorのXORで0x48e2b093です。ローカル再現ではERC-165のsupportsInterfaceがIERC165 0x01ffc9a7、IDiamondCut 0x1f931c1c、IDiamondLoupe 0x48e2b093true0xfffffffffalseを返しました。

ただしloupeは「今」を返します。過去のfacet、変更順、initializer、変更authorityはloupeだけでは復元できません。また、大きなdiamondで全件配列を返す関数はgasやRPC responseの上限へ達する可能性があります。既知selectorならfacetAddress(bytes4)を優先し、一括取得は分割可能な独自indexや複数RPCも検討します。

facet addressのcodeとsourceを個別に照合する

loupeからaddressを得たら、各addressのruntime code有無とhashを同じblockで読みます。diamond addressのsourceだけをverifyしても、delegatecall先のfacet sourceまでは証明しません。

固定したローカル再現のcode hashは次のように分かれました。

facet runtime bytes runtime code hash
DiamondCut 3249 0x9ae07cb18db7121d650f59f685c3c4f4a50d00b577ab5f24acc6ae6e9f4a1fbf
Loupe 2065 0x288e55868ce4c3d982a9152e8307e597298fa20b17406a7dbce50ff9b91830f4
Counter V1 590 0x3f59288ec1ad70dbbfec5a6ec3d3b917fabf97ceedb97fff7d203df941f0a689
Counter V2 234 0x7b273f7f5323d07ab71d4c54be522b611520f1a7fb9240d3f6aded0d2365a00a
Ownership 389 0xa234efe7ddad9e7b48e486e35bd785622305a8c43bebf0f3bd6e944e30b550fb

本番調査ではchain、diamond、fixed block number / hashを先に固定し、diamond fallback、loupe、すべてのcurrent facets、cut / authority facet、initializer targetを別targetとして再構築します。verified sourceをcompiler・constructor・proxyまで確認する手順と同じく、verified badgeを監査や安全保証に置き換えません。

diamondCutのadd・replace・removeをeventとpost-stateで結ぶ

diamondCutは複数のFacetCutと任意のinitializer callを1 transactionで適用します。actionの値は次の通りです。

action enum value facet address selectorの条件と結果
Add 0 codeがあるfacet 未登録selectorを新規routingへ追加
Replace 1 codeがある別facet 登録済みselectorのroutingを変更
Remove 2 zero address 登録済みselectorのroutingを削除

ローカル再現ではnote()をExtra facetへaddし、setValue(uint256)だけをCounter V2へreplaceし、最後にnote()をremoveしました。それぞれdiamond addressからDiamondCut eventが発行され、actionは0 → 1 → 2でした。replace後にsetValue(21)を呼ぶとV2の処理でvalueは42、versionは2になり、V1から引き継いだdiamond storageを読めました。

中央施設へ部屋が追加され、別仕様の部屋へ置き換わり、最後に部屋が取り除かれる3場面でDiamondCutを示す概念図
図はadd・replace・removeの違いだけを示します。enum値、selector配列、facet address、event、post-stateは本文と固定検証JSONで確認してください。

重複selectorのAdd、存在しないselectorのReplace / Remove、codeがないfacet、同じfacetへのReplaceは拒否されるべき条件です。ローカル再現では重複Addとcodeなしfacetを拒否し、Remove後のnote()はroutingなしとしてrevertしました。

DiamondCut eventを見つけただけで変更完了を断定しません。receipt status、emitter、ordered log、cut内容、同じblockのloupe、各facet code hashを対応させます。過去履歴ではdeployment transactionからcurrent fixed headまでeventを集め、初期selectorもconstructor dataとdeployment logsから確認します。

initializerはcutと同じtransactionの任意delegatecall

diamondCut_init addressと_calldataを受け取り、selector変更後の初期化やmigrationを同じtransactionで実行できます。initializerもdiamond contextのdelegatecallなので、diamond storageを書き換えられます。

ローカル再現の初期cutではvalue 41、version 1を設定しました。別のcutでvalue 77、version 3へ更新し、selector追加とstate変更が同時に反映されることも確認しました。

重要なのは、initializerが単なる補助関数ではなく任意code実行点だということです。次を固定します。

  • _init addressのruntime code hashとsource
  • _calldataのselector、引数、復号結果
  • 書き換えるstorage rootとfield
  • initializerの再実行防止とversion
  • msg.senderを使う場合の意味
  • external call、approval、asset移動の有無
  • revertした場合にcut全体が巻き戻ること

revertするinitializerとnote()のAddを組み合わせたローカル再現では、シミュレーションが拒否され、post-stateのfacetAddress(0x26d111f5)はzeroのままでした。原子性は確認できても、成功するinitializerの内容が安全だとは証明しません。

Diamond Storage・AppStorage・ERC-7201を混同しない

EIP-2535はfacet間でstateを共有する必要性を説明しますが、application stateを1種類のlayoutへ固定しません。代表的な設計を分けます。

pattern rootの考え方 利点 注意点
Diamond Storage module固有文字列などのhash slot facet / moduleごとに離れたstructを置ける 定数衝突、ID typo、struct内互換性
AppStorage slot 0などに1つの大きなstruct application stateをまとめて型付きで参照 全facetがfield順・型を厳密に共有する必要
ERC-7201 formula namespace IDから256-slot aligned rootを計算 namespaceの宣言とtoolingを標準化しやすい EIP-2535とは別規格で、自動採用されない

ローカル再現ではAppStorage rootがzero、routing用Diamond Storage rootが0xc8fcad8db84d3cc18b4c41d551ea0ee66dd599cde068d998e57d5e09332c131c、ERC-7201式の例が0x3286117daff9f85013498e4492687a45855d62df0707c718045a4347f7b1d900で、互いに異なりました。

AppStorageのfield順をvalue, versionからversion, valueへ取り違えたfacetは、同じraw slot 0 = 41をversion、slot 1 = 1をvalueとして読みました。facetを差し替えてもstorage bytesは自動移行されません。

namespace rootの式、annotation、同じnamespace内の末尾追加・並べ替え・型変更はERC-7201 Namespaced Storageの検証手順で詳しく比較しています。Diamondでは全facetとlibraryを横断してroot、struct、slot、offset、typeをinventory化します。

upgrade authorityはEIP-2535だけでは決まらない

EIP-2535はdiamondCutの仕組みを定めますが、誰が実行できるかを1つの方式へ固定しません。owner、AccessControl、multisig、timelock、governance、immutable化など、実装ごとに異なります。

ローカル再現はownerだけを許可し、無関係なlocal accountのcutを拒否しました。本番ではowner()が読めるだけで終えず、次のauthority chainを追います。

  1. diamondCut selectorのcurrent facetをloupeで得る
  2. cut facetのruntime sourceとアクセス制御を読む
  3. owner / role / adminのcurrent valueをfixed blockで読む
  4. addressがmultisigやtimelockなら、そのthreshold、members、delay、proposer / executorを追う
  5. ownership、role、governance parameterの変更eventを履歴化する
  6. upgrade functionを削除・置換できる別経路がないか全selectorsとsourceを検索する

EIP-2535ではupgradeability自体は任意です。標準のcut入口がない、または削除されたdiamondもあり得ますが、「upgrade不能」は関数名が見えないことだけでは証明できません。fallback直書き、別selector、外部storage contract、delegatecall先の管理機能を含めて調べます。

固定検証で確認した境界

公開した固定検証JSONには、17 signaturesのselector table、initial / final loupe、4件のDiamondCut event、7 contractのruntime code hash、raw slots、3種類のstorage root、authority拒否を保存しています。

Foundryの12 testsとviem / Anvil lifecycleは、次を固定しました。

  • selectorからfacetへのfallback routing
  • diamond storageとfacet自身のstorageの分離
  • ERC-165と3 interface IDs
  • loupe 4関数のcurrent inventory
  • Add / Replace / Removeとevent
  • duplicate selector、codeなしfacet、unauthorized cutのreject
  • replace後も残るdiamond-owned state
  • remove後のfallback revert
  • initializer成功時のstate変更と失敗時の全体rollback
  • AppStorage、Diamond Storage、ERC-7201例のroot分離
  • 誤ったAppStorage field順によるraw slotの再解釈

これはローカル環境の再現結果です。production diamond、facet、initializer、storage layout、governance、監査、安全性には一般化しません。生成画像も概念説明であり、transaction、event、code、storage、authorityの証拠には使っていません。

Diamond調査チェックリスト

Identityとrouting

  • chain ID、diamond address、fixed block number / hashを固定した
  • diamond runtime code hashとfallback sourceを確認した
  • calldata selectorをcanonical signatureから再計算した
  • facetAddress(selector)と全loupe inventoryを保存した
  • selector collisionや同名関数をABIだけで断定していない
  • 各current facetのcode有無、runtime hash、verified source、compiler条件を照合した

Cutとhistory

  • deploymentからfixed headまでDiamondCut logsをemitter・log index順に集めた
  • Add 0、Replace 1、Remove 2とfacet addressの条件を検査した
  • event直後とcurrent blockのloupe stateを比較した
  • initializer address、calldata、source、storage write、external callを復号した
  • initializer revert時にcut全体がrollbackする実装か確認した
  • 標準外routing mutationやupgrade入口をsourceと全selectorsから探した

Storageとauthority

  • 全facet / libraryのstorage root、struct、slot、offset、typeを一覧化した
  • AppStorageのfield順と型をfacet間で比較した
  • Diamond Storage / ERC-7201のID、root、重複、typoを検査した
  • upgrade前後の重要getterとraw slotsを保存した
  • cutのowner / role / multisig / timelock / governanceを末端まで追った
  • source verification、audit、loupe対応を安全保証と同一視していない

まとめ

Diamondを読む起点はdiamond addressだけではなく、calldata先頭4 bytesのselectorです。loupeでcurrent facetを取得し、facetごとのruntime sourceとcode hash、DiamondCut履歴、initializer、共有storage layout、upgrade authorityへつなげます。

最終確認は chain → diamond → selector → facet → source and runtime → loupe → DiamondCut logs → initializer → storage roots → authority です。routingの現在値、変更履歴、stateの意味、変更できる主体を同じfixed blockの記録へ揃えてから、動作とriskを判断してください。

確認した一次情報