3MIKAN
仮想通貨直コン

Minimal Proxyとは:EIP-1167 cloneのbytecode・initializer・factoryを検証する

EIP-1167 minimal proxyの45-byte runtime、埋め込みimplementation、delegatecall、initializer、CREATE2 factory、immutable args、調査手順をlocalで検証します。

3MIKANのブランドキャラクターが1つの共通設計図を参照する多数の小型contract装置と初期設定の状態を点検する記事画像

Minimal Proxyは、同じ機能のcontractを何度もfull deployせず、短いproxyを複数作って1つのimplementation codeを共有する方法です。ERC-1167(EIP-1167)は、implementation addressを埋め込んだ標準runtime bytecodeを定めています。

短いcodeだから自動的に安全になるわけではありません。cloneのstorageは空から始まり、implementationのconstructorはclone側で動きません。initializeを別transactionへ残すと、第三者が先にownerを設定できる設計があります。

この記事はaccount、vault、poolなどをclone factoryで量産する開発者と、ExplorerやRPCからcloneを調べる人向けです。chain → clone runtime → implementation address / code hash → delegatecall context → storage layout → initializer state → factory / salt → extension → authorityの順に確認できる状態を目指します。

検証にはSolidity 0.8.36、Foundry / Anvil 1.8.0、OpenZeppelin Contracts 5.6.1、viem 2.56.0を使いました。EVM versionはCancun、chain IDは31346です。隔離したlocal chainだけで実行し、public RPC、mainnet fork、browser wallet、実資産は使っていません。

Full deploymentとminimal cloneの違い

full deploymentは、各instanceへ機能を含むruntime codeを置き、constructorでそのinstanceのstorageを初期化します。minimal cloneは45-byteの小さなruntimeだけを置き、呼び出しを固定implementationへDELEGATECALLします。

固定したlocal結果では、比較用full contractのruntimeは366 bytes、cloneのruntimeは45 bytes、共有implementationは1,079 bytesでした。code sizeは小さくなりますが、cloneごとのstorage、初期化transaction、implementationの検証は別に必要です。

項目 Full deployment 標準minimal clone
instanceのruntime 機能を含むcode 45-byte redirect code
実行するcode instance自身 固定implementation
stateの保存先 instance clone
constructor instance作成時に実行 implementation constructorはcloneで再実行されない
初期値 constructorで設定できる initializer等を別途設計する
upgrade contract設計次第 標準runtime自体にadmin / upgrade機能はない

deployment gasの差だけで選ぶと、initializer、implementation dependency、運用上の調査コストを落とします。最初に「codeを共有すること」と「stateを共有すること」を分けてください。clone同士が共有するのはimplementation codeで、通常のstorageはcloneごとに別です。

EIP-1167 runtimeは45 bytes

EIP-1167が示す標準runtimeは次の45 bytesです。<implementation>には20-byte addressが入ります。

363d3d373d3d3d363d73
<20-byte implementation>
5af43d82803e903d91602b57fd5bf3

0始まりのbyte indexでは、implementation addressは10から29までです。opcodeとしてはoffset 9PUSH20が続く20 bytesをstackへ積み、offset 31DELEGATECALLがその宛先を使います。revert時はreturndataをcloneのcallerへ戻し、成功時も同様にreturn dataを返します。

localでdeployしたcloneのruntimeは次の値でした。

0x363d3d373d3d3d363d73
  e7f1725e7734ce288f8367e1bb143e90bb3f0512
  5af43d82803e903d91602b57fd5bf3

抽出した0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512は、実際にdeployしたimplementation addressと一致しました。

TypeScriptでは、長さとprefix / suffixを確認してからaddress部分を切り出せます。

const prefix = '363d3d373d3d3d363d73'
const suffix = '5af43d82803e903d91602b57fd5bf3'

function extractImplementation(runtime: `0x${string}`) {
  if ((runtime.length - 2) / 2 !== 45) throw new Error('not 45-byte runtime')
  if (!runtime.slice(2).startsWith(prefix)) throw new Error('prefix mismatch')
  if (!runtime.slice(2).endsWith(suffix)) throw new Error('suffix mismatch')

  return `0x${runtime.slice(2 + 10 * 2, 2 + 30 * 2)}`
}
短いminimal proxy runtimeの中にimplementation address領域が埋め込まれ、共有implementationへ結び付く関係を示す概念図
図は埋め込み領域の関係だけを示します。正確な45 bytes、index 10–29、opcodeは本文と実bytecodeを正本にします。

delegatecallはimplementation codeをcloneのcontextで動かす

Solidityの説明どおり、delegatecallではtargetのcodeをcaller contractのcontextで実行します。ここでcaller contractはcloneです。

実行要素 delegatecall時に使われるもの
code implementation addressのcode
storage cloneのstorage
address(this) clone address
balance clone balance
msg.sender cloneを呼んだ元callerのまま
msg.value cloneへ渡したvalueのまま

local例では、ownerがcloneのexecute(5)123 weiを付けて呼びました。cloneのvalueは77から82へ変わり、address(this)はclone address、lastCallerはowner、clone balanceは123 weiになりました。一方、implementation側のvalueとbalanceは0のままです。

callerがcloneへ渡した処理が共有implementationのcodeを借り、結果をclone固有のstorageへ保存する流れの概念図
codeはimplementationから借りますが、state、address、balance、sender、valueはcloneの実行contextです。

この性質により、implementationとcloneのstorage layoutが一致しないと、別のslotをownerやvalueとして読み書きします。変数名が同じかではなく、slot番号、型、packing、継承順を照合します。

implementationのconstructorはclone storageを初期化しない

constructorは、そのcontractを作るcreation codeの実行中に1回だけ動きます。implementationをdeployしたときのconstructorはimplementation storageを変更しますが、後から作るclone storageにはコピーされません。

local例では、implementationのconstructorが記録したdeployer addressを確認できました。同じgetterを未初期化cloneで読むとzero addressで、ownerもversionもzeroでした。

contract CloneLogic {
    address public owner;
    uint64 public initializedVersion;
    address public constructorSender;

    constructor() {
        constructorSender = msg.sender; // implementation側だけ
    }

    function initialize(address initialOwner) external {
        require(initializedVersion == 0, "already initialized");
        require(initialOwner != address(0), "zero owner");
        initializedVersion = 1;
        owner = initialOwner; // delegatecallならclone storageへ書く
    }
}

Solidityのimmutableも注意が必要です。immutable値はruntime codeへ埋め込まれるため、cloneごとにstorageへ置かれる値ではありません。implementation constructorで決まったimmutableは、そのimplementationを使うcloneから共通に見えます。

initializerはdeployと同じtransactionで完了させる

安全側のfactoryは、clone作成とinitializerをatomicに実行し、どちらかが失敗すればtransaction全体をrevertします。

function deployCloneAndInitialize(
    address implementation,
    address owner,
    uint256 value
) external returns (address instance) {
    require(implementation.code.length > 0, "implementation has no code");
    instance = Clones.clone(implementation);
    CloneLogic(instance).initialize(owner, value);
}

clone()だけを先に実行し、後のtransactionでinitialize()する形には未初期化の時間があります。permissionlessなinitializerなら、mempoolで見た第三者や、単に先に呼んだ第三者がownerになれます。

固定例では、uninitialized cloneへattackerがinitialize(attacker, 1)を先に実行し、ownerになりました。その後の予定ownerによるinitialize(owner, 77)already initializedでrevertしました。

state / failure 観測 対応
atomic initialize deployと設定が同じtransactionで完了 factoryで一括実行し、失敗時は全体revert
uninitialized owner / versionがzero 利用開始・送金・権限付与を止める
first-caller claim 想定外addressがowner incidentとしてfactory transactionとcallerを追う
initializer replay 2回目も通る version guardを実装する
unauthorized reinitialize owner以外がversionを進められる authorityとversionを両方検証する
implementationにcodeなし initializeが成功したように見えるがstateが変わらない clone作成前後にcode length / hashを固定する

initializeだけでなく、reinitializeV2のような移行関数も対象です。local例ではversion 1 → 2をownerだけに許可し、owner以外、同versionの再実行、初期化の再実行をそれぞれrevertさせました。

codeのないimplementationでは成功に見える場合がある

OpenZeppelin Contracts 5.6.1のClonesは、clonecloneDeterministicがimplementationにcodeがあるか確認しないと警告しています。codeのないaddressへのDELEGATECALLは、呼び出し自体がsuccessになっても実行するcodeがなく、stateを変更しません。

local例では、code lengthが00x…bEEFをimplementationとして45-byte cloneを作りました。initializerのlow-level callはsuccess = true、return dataは0xでしたが、owner slotはzeroのままでした。owner()を読むlow-level callもsuccessで空dataを返し、ABIで復号できる32-byte値は得られません。

factoryでは少なくとも次を別々に確認します。

  1. deploy前のimplementation code.length > 0
  2. 想定したimplementation runtime code hashとの一致
  3. clone runtimeから抽出したimplementation addressとの一致
  4. initializer callのsuccessだけでなく、owner、version、domain等のpost-state
  5. eventだけでなくtransaction receiptとpost-state

「transactionがsuccess」「factory eventが出た」「initializer callがrevertしなかった」のどれも、初期化完了を単独では証明しません。

deterministic cloneはimplementation・salt・factoryを固定する

cloneDeterministicはCREATE2でcloneを作ります。標準cloneのinit codeにはimplementation addressが入り、CREATE2 addressはfactory、salt、init-code hashから決まります。

clone address = keccak256(
  0xff ++ factory ++ salt ++ keccak256(clone_init_code)
)[12:]

local例では、factory 0x5FbD…aa3、implementation 0xe7f1…0512、固定saltから予測した0x5f19…0e40とdeploy後addressが一致しました。同じ組み合わせをもう一度deployするとcollisionでrevertしました。

この一致はlibraryの予測関数だけで確認していません。45-byte runtimeへ10-byteのcreation prefixを付けたclone init codeは55 bytes、init-code hashは0xcb1c…e7c8でした。前掲のEIP-1014式へfactory、32-byte salt、このhashを入れた手計算結果も0x5f19…0e40です。

標準cloneでは通常、initializer calldataはclone init codeに含まれません。そのため、同じimplementation、factory、saltならinitializerのownerやvalueを変えても予定addressは変わらない設計があります。だからこそ、deploy直後に同じtransactionでinitializeする必要があります。

salt、init code、factoryを含むCREATE2の計算とcollisionは、CREATE2 addressを予測する手順で詳しく分けています。

immutable args付きcloneは標準45-byte runtimeと分ける

OpenZeppelin Contracts 5.6.1には、cloneWithImmutableArgscloneDeterministicWithImmutableArgsがあります。argsはclone codeの末尾へ付加され、fetchCloneArgsで取得できます。

local例では、標準cloneは45 bytes、64-byteのABI encoded argsを付けたcloneは109 bytesでした。後者の先頭には標準redirect部分がありますが、contract code全体はEIP-1167が示すexact 45-byte runtimeではありません

判定 標準clone immutable-args拡張
code length 45 bytes 45 bytes + args length
args保存 なし code末尾
取得方法 該当なし library固有の取得処理
Explorer判定 exact bytecode match extensionとして別判定
CREATE2入力 標準clone init code implementation・argsを含む拡張init code

custom minimal proxyはprefix、suffix、revert処理、calldata加工、immutable argsの読み方が異なる場合があります。45 bytesでないcodeを、addressらしい20 bytesが見つかったという理由だけで標準cloneと断定しません。

standard clone自体はupgradeableではない

EIP-1167の標準runtimeは、20-byte implementation addressを固定で埋め込みます。admin slot、upgrade function、implementationを書き換えるstorage slotはありません。そのclone単体については、通常のERC-1967 proxyのようにimplementationを変更できません。

local cloneでERC-1967 implementation slotとadmin slotをraw storageから読むと、どちらもzeroでした。調査時は次の違いを先に固定します。

pattern implementationの場所 upgrade経路 主なauthority確認
EIP-1167 standard clone runtime byte index 10–29 標準cloneにはなし factory、initializer、埋め込みimplementation
UUPS proxy ERC-1967 implementation slot implementation側のupgrade関数 upgrade authorizationを実装するrole / owner
Transparent proxy ERC-1967 implementation / admin slot adminまたはProxyAdmin経由 admin / ProxyAdmin ownerとadmin fallback制限

UUPSとTransparentをcode lengthだけで区別せず、ERC-1967 slot、verified implementation、upgrade function、admin / authorityを合わせて確認します。

ただし、次の依存は残ります。

  • 埋め込まれたimplementation codeが正しいか
  • cloneとimplementationのstorage layoutが一致するか
  • implementation codeが外部contract、oracle、registryへ依存するか
  • clone storageに別proxy用slotを持ち、implementation側のcodeがそれを読む設計か
  • chain / forkによってcode lifecycleの前提が違わないか

SELFDESTRUCTについて「implementationがいつでも消えてcloneが壊れる」と現在のEthereumへ一律に書くのは不正確です。Cancun以降、既存contractのSELFDESTRUCTは通常codeを削除せず、作成と同じtransactionで実行した場合だけ従来の削除動作を保ちます。fork境界はSELFDESTRUCTとEIP-6780の検証で分けています。

一方、別chain、古いfork、作成transaction内の特殊なlifecycle、最初からcodeのないaddressは別です。調査時はchainとblockを固定し、その時点のeth_getCodeとcode hashを記録します。

ExplorerとRPCでcloneを調べる順番

Explorerの「Proxy」表示だけを結論にせず、raw codeとtransactionを順に確認します。

順番 確認するもの 判断できること
1 chain ID、block、clone address 別network / 別時点の混同を防ぐ
2 clone eth_getCode、length、code hash exact 45-byteか、extension / customか
3 prefix、bytes 10–29、suffix 標準runtimeとembedded implementation
4 implementation code / code hash codeの有無と想定artifactとの一致
5 clone creation transaction、factory deployer、CREATE / CREATE2、atomic call
6 initializer calldata、receipt、trace 誰が何を設定したか
7 owner、version、domain、重要slot post-stateが完了したか
8 storage layoutとverified source delegatecall先とclone slotの整合
9 salt derivation、prediction deterministic addressの再計算
10 authority / external dependency 後から挙動へ影響できる主体

verified sourceがある場合も、表示sourceとon-chain runtimeの一致条件を確認します。compiler、optimizer、metadata、linked libraryを固定してbytecodeを再構築する方法は、verified sourceとruntime bytecodeを照合する記事を参照してください。

ERC-1967、Transparent、UUPS、Beaconとminimal cloneを同じ「proxy」という言葉だけでまとめると調査を誤ります。implementation slot、admin、upgrade authorityの読み分けは、ERC-1967 proxyの調査手順で整理しています。

cloneやproxyが読むstorageをmoduleごとに分ける設計では、namespaceを採用しただけでfield互換性が保証されるわけではありません。ERC-7201 Namespaced Storageのroot・並べ替え・型変更を検証する手順で、通常layoutとnamespace layoutのcollision境界を比較できます。

Factory reviewで残すチェックリスト

deploy前、transaction内、deploy後を分けます。

Deploy前

  • chain ID、factory address、factory code hashを固定したか
  • implementation address、code length、runtime code hashを固定したか
  • cloneとimplementationのstorage layoutが一致するか
  • initializerにversion guardとauthorityの検査があるか
  • saltの導出規則とcaller namespaceを説明できるか
  • standard、immutable args、custom proxyのどれか明示したか

同じtransaction内

  • clone deployとinitializeをatomicにしたか
  • initializer failureで全体がrevertするか
  • initial owner / role / domain / assetがcaller任せになっていないか
  • initializer returnだけでなく、期待stateを検査できるか
  • CREATE2 collision時のfailureを処理しているか

Deploy後

  • clone codeが期待するlength、prefix、implementation、suffixか
  • implementation code hashがdeploy前と一致するか
  • owner、initializer version、role、重要parameterが期待値か
  • event、receipt、trace、post-stateが同じinstanceを指すか
  • factoryとimplementationへ影響できるauthorityを記録したか

まとめ

Minimal Proxyの確認順は、chain → clone runtime → implementation address / code hash → delegatecall context → storage layout → initializer state → factory / salt → extension → authorityです。

標準EIP-1167 runtimeは45 bytesで、implementation addressはbyte index 10–29へ埋め込まれます。実行codeはimplementationから借りますが、storage、address(this)、balance、msg.sendermsg.valueはclone側です。

constructorはclone storageを初期化しません。factoryはimplementation codeを検査し、clone作成とinitializeを同じtransactionで完了させ、ownerやversionのpost-stateまで確認します。45 bytesより長いimmutable-args / custom extensionは標準runtimeと分けて判定してください。

確認した一次情報