3MIKAN
仮想通貨直コン

SELFDESTRUCTはcontractを消す?EIP-6780後のcode・storage・再deployを検証

EIP-6780後のSELFDESTRUCTをcode、storage、balance、nonce、同一transaction例外、CREATE2再deployへ分け、ShanghaiとCancunで検証します。

3MIKANのブランドキャラクターが資金の移動後も残るcontractのcodeとstorageを点検し、解体命令との違いを確認する記事画像

SELFDESTRUCTを「contractを完全に削除する命令」と覚えているなら、その説明には実行chainのforkとtransaction境界が足りません。Ethereum mainnetでDencunが有効になった後、以前から存在するcontractがこのopcodeを実行しても、原則として全balanceをbeneficiaryへ送り、現在のcall frameを終了するだけです。code、storage、account自体は削除されません。

例外は、contractを作成したのと同じtransaction内でSELFDESTRUCTを実行した場合です。このときは旧挙動が保たれ、transactionの終わりにcodeやstorageを含むaccountが削除されます。

この記事は、deploy factoryやupgrade機構を実装する開発者、古いexploitやmetamorphic contractを調べる人、EVM version差をreviewする人向けです。chainとfork → creation transaction → selfdestruct transaction → code・storage・balance・nonce → CREATE2 preimage → 次transactionの順に確認し、「消えた」「残った」「再deployできる」を分けます。

検証にはSolidity 0.8.36、Foundry / Anvil 1.8.0、viem 2.56.0を使いました。同じShanghai互換bytecodeを、ShanghaiとCancunに固定した2つのlocal chainで実行しています。public RPC、mainnet fork、wallet、実資産、公開transactionは使っていません。

最初にchain、fork、transactionを固定する

EIP-6780はhard forkによるconsensus ruleの変更です。Ethereum mainnetではDencunの一部として、timestamp 1710338135、2024年3月13日13:55 UTCに有効になりました。

この日時を、すべてのEVM chainへそのまま当てはめてはいけません。L2、sidechain、private chain、local nodeでは、同じopcodeでもforkの有効化時期や設定が異なります。

最初に残す項目 何を確認するか 省くと起きる混同
chain chain ID、network、client Ethereum mainnetのfork時刻を別chainへ使う
block number、hash、timestamp pre-Cancunとpost-Cancunを混ぜる
runtime fork Shanghai / Cancun以降 compiler設定だけで実行挙動を決める
creation contractを作ったtransaction same-transaction例外を判定できない
destruction SELFDESTRUCTを実行したtransaction 同じblockを同じtransactionと誤認する
post-state 次blockのcode、slot、balance、nonce traceにopcodeがあるだけで削除と断定する

Solidityの資料は、Cancun以降の挙動がnetwork-wideの変更であり、compile時の--evm-versionはlive chain上のSELFDESTRUCT semanticsを決めないと明記しています。compiler targetは生成bytecodeの互換性に関係しますが、既にdeploy済みのcontractへどのfork ruleを適用するかは実行側のEVMが決めます。

同じcontractのcode、storage、balance、nonceを、旧ルールとEIP-6780後のルールで別々のレーンに分けて比較する概念図
balanceの移動とaccount削除は別のstate変化です。4項目を分けて読みます。

code・storage・balance・nonceは別々に読む

local検証では、CREATE2で作ったcontractを次の初期stateへ固定しました。

field selfdestruct前
runtime code 195 bytes
storage slot 0 77
balance 1 ETH
nonce 1

別transactionで同じdestroy(beneficiary)を実行し、transaction確定後にeth_getCodeeth_getStorageAteth_getBalanceeth_getTransactionCountを読み直した結果です。

実行rule code slot 0 balance nonce beneficiary
Shanghai 0 bytes 0 0 ETH 0 +1 ETH
Cancun 195 bytes 77 0 ETH 1 +1 ETH

両方でSELFDESTRUCT opcodeは1回実行され、beneficiaryへ1 ETHが移りました。しかし、Cancunではcode hash、storage、nonceが実行前と同じです。balanceが0になったことは、contractが消えた証拠ではありません。

逆にShanghaiのlocal ruleでは、transaction終端後にcode、slot、account nonceが空になりました。それでも過去block、transaction、traceまで消えるわけではありません。stateからaccountが削除されることと、blockchain historyから情報が消去されることも分けます。

callの途中でaddress.code.lengthを読んだ値だけにも注意が必要です。旧ruleでも削除はtransaction終端で確定するため、同じtransaction内の観測をpost-stateの代わりにできません。receiptを待ち、確定blockを指定して4項目を読み直します。

同一transactionでcreateして壊した場合だけ例外になる

EIP-6780は、contractが作られたのと同じtransactionでSELFDESTRUCTした場合に旧挙動を残します。外部accountから直接作るcontract-creation transactionだけでなく、CREATECREATE2が始まった時点もcreationです。

検証では、constructorの中でSELFDESTRUCTする小さなcontractをCREATE2で作りました。ShanghaiとCancunのどちらでも、transaction確定後は次のstateです。

field 確定後
runtime code 0 bytes
storage slot 0 0
balance 0 ETH
nonce 0

ただし、同じtransaction内で同じfactory、salt、init codeを使って2回目のCREATE2を実行すると、返り値はzero addressでした。EIP-1014が説明するように、SELFDESTRUCTは同じtransactionの途中でcodeやnonceを即時消去しないため、creation collisionを避けられません。

次のtransactionではaccountが空になっているため、1回目のCREATE2は同じ予定addressで再び成功しました。整理すると、同じtransactionの2回目は失敗し、次transactionの1回目は成功するという境界です。

「同じblockで別transaction」と「一つのtransaction内の別call」も同じではありません。調査記録にはblock numberだけでなくtransaction hashとcall traceを残します。

CREATE2はsaltだけで同じaddressを作るわけではない

CREATE2の予定addressは、次のpreimageから計算されます。

address = keccak256(
  0xff ++ factory_address ++ salt ++ keccak256(init_code)
)[12:]

固定するのは、次の3項目です。

  1. CREATE2を実行するfactory address
  2. 32-byte salt
  3. constructor引数を含むinit codeのhash

local検証のpersistent targetでは、factoryが0x5FbD…0aa3、saltが0xa4a8…3336、init-code hashが0x4374…433dで、予定addressは0x9e48…9ab4でした。constructor引数を77から78へ変えるとinit code hashが変わるため、同じsaltでも別addressになります。

factory、salt、constructor、linked library、metadata、proxy initializerまで含む一般的な予測手順は、CREATE2 addressをSolidityとviemで独立計算する記事で扱っています。

Shanghai ruleでは、既存targetを別transactionでselfdestructした後、同じ3項目を使う次transactionのCREATE2が成功し、0x9e48…9ab4へcodeとslot 77が戻りました。Cancun ruleではcodeとnonceが残るためCREATE2がcollisionし、zero addressを返しました。

factory、salt、init codeから同じ予定addressへ進み、既存contractはcollisionし、作成直後に消える例だけが次transactionで再利用される流れを示す概念図
予定addressの計算、現在のaccount state、transaction境界の3つが揃って初めて再deploy可否を判断できます。

CREATE2のaddress計算そのものはEIP-6780で変わっていません。未deploy addressを先に計算して、後で一度だけdeployするcounterfactual用途も、SELFDESTRUCTによる再deployとは別です。ERC-6492のcounterfactual account検証では、factory、salt、init codeから予定signerを結び付ける例を確認できます。

metamorphic contractとproxy upgradeを混同しない

古典的なmetamorphic contractは、一定のinit codeから外部stateに応じたruntime codeを返し、既存contractをselfdestructしてから同じCREATE2 addressへ別codeを置く設計です。Cancun以降、以前のtransactionから存在するcontractは削除されないため、この再deploy cycleは成立しません。

すべてのCREATE2 factoryやupgradeが壊れた、という意味ではありません。

pattern 同じaddressを保つ方法 code / storageの所在 EIP-6780との関係
persistent CREATE2 同じfactory・salt・init code deploy先contract 空addressへ一度deployする用途は継続
classic metamorphic delete後に同じaddressへ再deploy 同じaddressのruntimeを入れ替える 既存contractの削除が止まりcycleが失敗
constructor-selfdestruct creation transaction内で削除 確定後はempty 次transactionで再利用できるがlive upgradeではない
ERC-1967 proxy proxyがimplementation addressを読む stateはproxy、実行codeはimplementation selfdestruct再deployではなくslot更新で切り替える

proxyを調べるときは、ERC-1967のimplementation・admin・beacon slotを読む方法のように、proxy addressのstateとimplementation addressのruntime codeを分けます。verified sourceを含む再構築はbytecode・compiler設定・proxy先を同じblockで照合する方法へ進めます。

もう一つの注意はdelegatecallです。Solidityの資料は、対象contract自身のsourceにSELFDESTRUCTがなくても、delegatecall先のcodeからopcodeを実行できると警告しています。delegatecallではcaller側のstorage、balance、address contextで実行されるため、implementationだけを検索して「proxyにはopcodeがない」と終えません。

Cancun ruleなら既存proxyのcodeとstorageを削除しませんが、balance送付とcall frame終了は起きます。deprecated warningが出ること、codeが残ること、影響がないことは同義ではありません。

historical挙動はlocal hardforkと実blockを分けて再現する

今回のShanghai結果は、旧semanticsを比較するdeterministic local testです。Ethereum mainnetの過去transactionをreplayした結果ではありません。

実際のincidentをhistorical blockで確認するなら、少なくとも次を一組で保存します。

  • chain ID、block number、block hash、timestamp
  • creation transactionとselfdestruct transaction
  • clientと適用hardfork
  • trace内のCREATE / CREATE2 / SELFDESTRUCTとcall depth
  • selfdestruct前後blockのcode、relevant storage slot、balance、nonce
  • beneficiaryとbalance差分
  • CREATE2ならfactory、salt、完全なinit code hash

opcode traceにSELFDESTRUCTが1回あるだけでは、どのpost-stateになったか分かりません。今回もShanghaiとCancunのtraceはどちらもopcodeを1回含み、差が現れたのはtransaction確定後のstateでした。

公開したShanghai / Cancun比較JSONには、同じaddressのbefore / after、CREATE2結果、opcode count、固定versionを分けてあります。値はlocal chainの再現結果であり、特定mainnet contractの安全性を示すものではありません。

incident調査とcode reviewのチェックリスト

  • chain ID、block number / hash、runtime hardforkを固定した
  • creation transactionとselfdestruct transactionが同じか確認した
  • receipt後のeth_getCodeを確定blockで読んだ
  • relevant storage slotをselfdestruct前後で比較した
  • contract balanceとbeneficiaryの増分を別々に確認した
  • account nonceを読み、CREATE2 collision条件を確認した
  • factory address、salt、constructor引数込みinit-code hashを保存した
  • traceでCREATE2の返り値とSELFDESTRUCTのcall contextを確認した
  • delegatecall経路を含め、実際にopcodeへ到達できる権限を確認した
  • metamorphic、proxy、counterfactual deploymentを別patternとして扱った
  • compiler warning、compile target、runtime forkを同じversion情報へ潰していない

現行ruleでの結論は「SELFDESTRUCTが何もしない」ではありません。既存contractではcodeとstorageを残しながら、balanceを送り、実行を終了します。何が残ったかをfieldごとに読み、transactionとforkを固定して初めて、安全な停止設計やincidentの説明につながります。

この記事には特定contract、token、upgrade serviceの利用や投資を促す導線はありません。将来audit、verification、deployment toolingを紹介する場合も、広告表記と技術検証を分離します。

確認した一次情報