3MIKAN
仮想通貨直コン

Proxy contractの読み方:ERC-1967 slot・implementation・admin・upgradeを確認する

ERC-1967のimplementation・admin・beacon slotをraw RPCで読み、Transparent・UUPS・Beacon、initializer、storage collision、upgrade履歴までlocal再現で検証します。

3MIKANのブランドキャラクターが一つの建物の隠れた3区画を開き、実装機構・管理錠・共有beaconを点検するERC-1967 Proxyの記事画像

Proxy contractを読む出発点は、proxy addressがstateと外部callの入口、implementation addressが実行codeの置き場だと分けることです。Explorerにverified sourceや「proxy」と表示されても、現在の実行先、upgrade authority、過去の変更、storage layoutまで同時に安全だとは分かりません。

この記事では、ERC-1967のimplementation、admin、beaconという3つのstorage slotを同じblockで読みます。その後にbytecode、source、ABI、call behavior、authority、events、storage layoutを対応させます。調査はread-onlyとローカル再現だけで、公開RPC、実在wallet、署名、upgrade transaction、資産は使いません。

proxy addressとimplementation addressは役割が違う

通常のdelegatecall型proxyでは、利用者はproxy addressを呼びます。proxyはimplementationのcodeを実行しますが、address(this)、balance、storageはproxy側です。

address 主な役割 単独では分からないこと
proxy user-facing address、state、balance、event emitter、delegatecall入口 現在・過去の実行code、upgrade authorityの全体
implementation function code、ABI候補、storage layoutの宣言 proxyに保存された値、現在そのproxyが本当に参照するか
admin / owner upgradeを開始できるauthority候補 multisig、timelock、governance、owner履歴の健全性
beacon 複数proxyが共有するimplementation参照先 beacon ownerと、各proxyに残る個別state

implementationへ直接Readすると、そのimplementation自身のstorageを読みます。proxy経由の結果とは別です。Writeを送る前の基本確認は直コンでverified contract・ABI・シミュレーションを確認する順番、calldataとlogsの読み方はABI・function selector・event logsの手検算で確認できます。

ERC-1967の3 slotをraw RPCで読む

ERC-1967は、implementation、beacon、adminをimplementation側の通常storageと衝突しにくい固定位置へ置きます。各値は識別文字列のKeccak-256から1を引いた値です。

slot exact position 32-byte wordの読み方
implementation 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc 下位20 bytesがimplementation候補
admin 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 下位20 bytesがadmin候補
beacon 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 下位20 bytesがbeacon候補

raw JSON-RPCでは、address、slot、block tagをeth_getStorageAtへ渡します。latestのまま証拠へ残すのではなく、先にblock numberを固定します。

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "eth_getStorageAt",
  "params": [
    "0xProxyAddress",
    "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
    "0xFixedBlockNumber"
  ]
}

viem 2.56.0では同じRPCを次のように3回読み、32-byte wordの下位20 bytesをchecksum addressへ変換できます。

import { getAddress, toHex } from 'viem'

const slots = {
  implementation: '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc',
  admin: '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103',
  beacon: '0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50',
} as const

const blockNumber = await publicClient.getBlockNumber()
const entries = await Promise.all(Object.entries(slots).map(async ([name, slot]) => {
  const word = await publicClient.getStorageAt({ address: proxy, slot, blockNumber })
  const normalized = word ?? toHex(0, { size: 32 })
  return [name, { slot, word: normalized, address: getAddress(`0x${normalized.slice(-40)}`) }]
}))

console.log({ blockNumber, slots: Object.fromEntries(entries) })

zero wordは「その方式ではない」手掛かりですが、直ちにupgrade不能という意味にはなりません。custom proxyは別slotを使えます。nonzero addressも、getCode、runtime bytecode、source、ABI、call behaviorまで確かめて初めて役割の候補になります。

ERC-1967 raw slotからaddress、bytecodeとsource、upgrade authorityとevent履歴、storage layoutへ進むread-only調査順

Transparent・UUPS・Beaconはcall pathとauthorityが違う

3方式ともdelegatecallを使えますが、「implementationをどこから得るか」と「upgrade functionを誰が呼ぶか」が違います。

Transparent、UUPS、Beacon Proxyのcallerからimplementationまでのcall pathとupgrade authorityの違い
方式 proxyで主に見えるslot normal call upgrade entrypoint / authority
Transparent implementation + admin admin以外をimplementationへdelegatecall ProxyAdminがproxyの専用dispatchを呼ぶ
UUPS implementation。adminは通常zero implementationへdelegatecall implementation内のupgradeToAndCall_authorizeUpgrade
Beacon beacon。implementationは通常zero beaconのimplementation()を読みdelegatecall beacon ownerがbeaconをupgradeし、接続proxy全体へ反映

Transparent Proxyはadminのcallだけ分岐する

OpenZeppelin Contracts 5.6.1TransparentUpgradeableProxyは、構築時に専用ProxyAdminを作ります。normal callerのcallはimplementationへ進みます。一方、実adminであるProxyAdminからのcallはupgrade dispatchだけを受け付け、implementationの通常functionへfallbackしません。

ローカル再現ではimplementation slotがV1、admin slotがcodeを持つProxyAdmin、beacon slotがzeroになりました。ProxyAdmin callerのvalue()ProxyDeniedAdminAccessで失敗し、別callerのvalue()41を返しました。

OpenZeppelin 5.xでは実admin addressをproxyのimmutableにも保持します。untrusted implementationがERC-1967 admin slotを書き換えると、raw slotと実際のadminが不一致になり得ます。したがってadmin slotは重要な手掛かりですが、implementationを信用できない状況で唯一のauthority証拠にはしません。

UUPSはimplementation側にupgrade codeがある

UUPSではproxyは基本的なERC1967Proxyで、upgrade functionはimplementationにあります。proxy経由でupgradeToAndCallを呼ぶとdelegatecallされ、implementation側のonlyProxy context checkとapplication固有の_authorizeUpgradeを通ってimplementation slotを書き換えます。

admin slotがzeroでもupgrade不能とは限りません。owner、role、multisig、timelockなど、_authorizeUpgradeが読むstateと変更履歴を実装sourceから追います。新implementationのproxiableUUID()がERC-1967 implementation slotを返す互換性checkも必要です。

ローカル再現ではowner callだけがV1からV2へ進み、outsiderは拒否されました。implementation addressへ直接送ったupgrade callも、proxy contextではないためUUPSUnauthorizedCallContextで拒否されました。

Beacon Proxyはbeaconを経由して二段階で解決する

Beacon Proxyはimplementation slotではなくbeacon slotを読み、そのbeacon contractのimplementation()をcallして実行先を得ます。1つのUpgradeableBeaconを2つのproxyが共有すれば、beaconの1回のupgradeで両方の実行codeが変わります。ただしvalueやownerなどのstateは各proxyに別々に残ります。

OpenZeppelin 5.xのBeaconProxyは効率のためbeaconをimmutableにも保持し、実際のcall pathはimmutable値を使います。implementation logicがbeacon slotを書き換えても実行先は変わらず、raw slotとの不一致を作ります。beacon slot、proxy bytecode / source、beaconのimplementation()を三点照合します。

3方式を同じローカルchainへ固定して再現した

公開再現データは、Foundry / Anvil 1.8.0、Solidity 0.8.36、OpenZeppelin Contracts 5.6.1、viem 2.56.0、Prague EVM、local chain ID 31337へ固定しています。Foundryは12 passed; 0 failed; 0 skippedです。

path raw slot / behavior local result
Transparent initial implementation=V1、admin=ProxyAdmin、beacon=zero owner=deployer、value=41
Transparent compatible V2 implementationだけV2へ変更 owner / valueを維持、revision=2
Transparent collision V2 同じraw slotを逆の型で読む owner=0x…0029、valueが旧ownerの整数表現
UUPS implementation=V1→V2、admin / beacon=zero owner upgrade成功、outsiderとdirect implementationは拒否
unsafe initializer 明示的にempty dataを許可したproxy outsiderがproxy ownerを取得。unsafe implementation自身も直接初期化可能
Beacon A / B implementation / admin=zero、beacon=共通address 1回のbeacon upgradeで両方version 2、value 7 / 9を維持

このaddressとprivate keyはAnvilが作る使い捨て検証環境専用です。mainnet address、個人key、実在account、wallet prompt、外部RPC、資産の証拠ではありません。

initializerはproxy construction dataで同時に実行する

implementationのconstructorはimplementation自身のstorageにしか作用しません。proxy側のownerや初期値は、initializerをdelegatecallしてproxy storageへ書きます。

安全側の再現codeは次の2点を組み合わせます。

  1. proxy構築時のdatainitialize(owner, value)を入れ、配置と初期化を同じtransactionにする
  2. implementation constructorで_disableInitializers()を呼び、implementation自身を直接初期化できないようにする

OpenZeppelin ERC1967Proxy 5.6はempty dataを既定でERC1967ProxyUninitializedとして拒否します。事故を再現するため、検証用codeだけが次のunsafe overrideを明示しています。

contract UnsafeUninitializedProxy is ERC1967Proxy {
    constructor(address implementation) ERC1967Proxy(implementation, "") {}

    function _unsafeAllowUninitialized() internal pure override returns (bool) {
        return true;
    }
}

このproxyへoutsiderが先にinitializeするとownerを取れました。constructor dataを空にすることやoverrideをproduction patternとして勧める例ではありません。またimplementation直callでownerになれても、それだけでproxy ownerになったとは限りません。2つのstorage contextを分けます。

storage layoutはsource差分ではなくslotの意味を比べる

delegatecallでは新implementationも既存proxy storageを読みます。変数名が同じでも、declaration order、inheritance、packing、型が変われば同じwordの意味が変わります。

ローカル再現のV1とcompatible V2は末尾へrevisionを追加しました。collision V2は最初の2宣言を逆にしています。

raw position V1 / compatible V2 collision V2 同じwordの結果
slot 0 uint256 value = 41 address owner 0x0000…0029として読む
slot 1 address owner = 0xf39F…2266 uint256 value 1390849295786071768276380950238675083608645509734として読む
slot 2 compatible V2のuint256 revision = 2 宣言なし collision側からは使わない

upgrade transaction自体は成功しても、意味は壊れています。compilerのstorage layout output、前version、次version、継承順、namespaced storage、migration initializerを比較し、local forkまたは決定的なローカル再現で値を読むまでをupgrade reviewに含めます。

EIP-7702 lifecycleの記事でも、codeの参照先を変えてもstorageやexternal permissionは自動で消えないことを確認しました。仕組みは別ですが、「codeの変更」と「addressに残るstate」を分ける観点はproxy upgradeでも同じです。

同じdelegatecallでもtransaction中だけ存在するstateは別領域です。EIP-1153 transient storageの記事で、CALLはcallee、DELEGATECALLはproxy / host側のtransient storeを所有することと、normal return後のcleanupを再現しています。

upgrade前後の権限や会計を単発scenarioだけでなく操作列として検査する場合は、Foundry invariant testの記事でhandler、actor、ghost variable、縮約counterexampleの作り方を確認できます。

eventとblock-specific slotでimplementation履歴を作る

ERC-1967はslot変更時のUpgradedAdminChangedBeaconUpgraded eventを定義します。current slotだけでなく、deployment blockからeventをaddress別・log index順に集め、そのblockのslotを読み直します。

import { parseAbiItem } from 'viem'

const upgraded = await publicClient.getLogs({
  address: proxy,
  event: parseAbiItem('event Upgraded(address indexed implementation)'),
  fromBlock: deploymentBlock,
  toBlock: fixedHead,
})

for (const log of upgraded) {
  const word = await publicClient.getStorageAt({
    address: proxy,
    slot: slots.implementation,
    blockNumber: log.blockNumber,
  })
  console.log({ blockNumber: log.blockNumber, logIndex: log.logIndex, event: log.args, word })
}

ローカル再現では次の履歴になりました。

emitter event blocks / count 読み方
Transparent proxy Upgraded 7、8、9 / 3 initial V1 → compatible V2 → collision V2
Transparent proxy AdminChanged 7 / 1 zero → generated ProxyAdmin
UUPS proxy Upgraded 10、11 / 2 constructor V1 → owner-authorized V2
beacon Upgraded 15、18 / 2 beacon自体のimplementation履歴
Beacon Proxy A / B BeaconUpgraded 16、17 / 各1 各proxyが参照するbeaconの発見event

同じUpgraded(address) signatureでも、proxyがemitするimplementation変更とbeacon contractがemitする変更があります。必ずemitter addressを記録します。authority調査ではProxyAdmin / beaconのownership transfer、AccessControl、timelock、governance eventも別に追います。

reorg、RPCの履歴保持範囲、deployment blockの取り違え、custom eventの欠落は残る境界です。重要な調査では複数のarchive-capable sourceとblock hashを照合します。

blob transactionではexecution RPCのversioned hashが残っていても、Beacon sidecarは別のretention境界を持ちます。Fusaka・PeerDASの記事では、固定blockのexecution evidenceとretention-limitedなblob dataを分けて再現しています。

verified sourceは調査の入口であって安全証明ではない

source verificationが示すのは、対象bytecodeとsource / compiler設定の対応です。次は別々に確認します。

  • proxy addressのruntime bytecodeとproxy source
  • current implementation addressのruntime bytecode、source、ABI
  • ProxyAdmin、UUPS authority、beaconとそのownerのsource・current state
  • compilerのstorage layoutとupgrade前後の互換性
  • deployment / upgrade / authority changeのevent履歴
  • pause、role、oracle、external call、business logicなどapplication固有risk

Etherscanのproxy verification APIはproxyとexpected implementationの関連付けを検証するendpointです。Sourcifyもsource metadata照合の別経路になります。どちらもupgrade authorityの妥当性、storage互換性、audit完了、悪意あるlogicがないことを保証しません。

source照合そのものを追試する場合は、verified sourceをcreation/runtime・compiler・constructor・metadataから再ビルドする手順で、proxyとimplementationを別targetにしたraw bytecode比較を確認できます。

Explorerが「Read as Proxy」で結合ABIを表示しても、current block、implementation source、proxy state、admin / beacon authorityを自分の調査メモへ分けます。未検証ならABIを推測してWriteしません。verifiedでもシミュレーションや権限確認を省略しません。

Safe proxyを調べている場合は、ERC-1967 slotがある前提にせず、対象Safe versionの公式proxy sourceへ戻ります。Safe transactionの署名・nonce・実行失敗を分ける手順では、implementationとVERSION()を固定してからowners、threshold、modules、guards、Safe nonceをread-onlyで追います。

read-only調査チェックリスト

  • chain、proxy address、fixed block number / hashを記録した
  • implementation、admin、beaconのexact slotを同じblockで読んだ
  • 32-byte word、下位20-byte address、zero、code有無を保存した
  • proxy runtime bytecode / sourceとcall behaviorを照合した
  • Transparent、UUPS、Beacon、custom proxyをslot一つだけで断定していない
  • beaconならbeacon.implementation()を同じblockで読んだ
  • UUPSならproxiableUUID、proxy context、_authorizeUpgradeを確認した
  • admin / owner / role / multisig / timelock / governanceをauthority chainとして追った
  • UpgradedAdminChangedBeaconUpgradedをemitterとlog index付きで集めた
  • event blockのslotを再読し、current valueだけから過去を推測していない
  • implementation direct callとproxy delegatecallのstorage contextを分けた
  • proxy constructor data、initializer version、implementation lockを確認した
  • compiler storage layoutをupgrade前後で比較し、local stateを再現した
  • verified sourceを公式性、監査、安全、変更不能と同一視していない
  • 調査のread-only操作とwallet接続、署名、upgrade、資産操作を分離した

Proxy調査の完了条件は、Explorerがimplementation名を表示したことではありません。proxy addressのraw slotを起点に、実装code、upgrade authority、event履歴、storage layoutを同じblockへ対応させることです。

この記事にスポンサー、affiliate、wallet接続、署名要求、upgrade transaction、資産操作のCTAはありません。将来Explorer、RPC、monitoring、verification、audit、developer toolingの広告を置く場合も広告であることを明示し、raw slot、source、authority、events、layoutの調査結果や安全評価から分離します。

確認した一次情報