はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、PUSH0 を使い、ERC1167のミニマルプロキシをさらに軽量化する仕組みを提案しているERC7511についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

PUSH0 という新しい命令がShanghaiアップグレードで追加されたことで、ERC1167(Minimal Proxy Contract)をより効率的に実装できるようになりました。 具体的には、デプロイ時のガス消費が約 200 ガス減り、実行時のガスも約 5 ガス減っています。 従来の仕組みと同じ動作を維持しながら、より軽くて読みやすいプロキシコードを作れるようになった点が大きなメリットです。

ERC1167については以下の記事を参考にしてください。

Minimal Proxy Contractは、既存のロジックを持つコントラクトを参照して必要最小限の機能だけを持つ「軽量なコピー」を作るための仕組みです。 この仕組みに PUSH0 を活用することで、無駄な命令を減らして処理をシンプルにできます。

動機

コントラクトのバイトコードを 1 バイト削減する

従来のERC1167では、アドレスやスタックの並び替えに使う SWAP 命令が含まれていました。 この命令は処理を行うためには必要ではあるものの、PUSH0 を利用するとそもそも SWAP を使わなくても成立する構成を作ることができます。

PUSH0 はスタックに「0」を積むだけの命令で、これをうまく利用するとスタック操作の組み立てがシンプルになるため、結果として SWAP が不要になります。 SWAP を1つ取り除くだけで、生成されるコントラクトのバイトコードが 1 バイト短くなります。

Minimal Proxy Contractは大量にデプロイされるケースが多いため、この 1 バイト削減には実用的な価値があります。

実行時ガスを削減する

従来のプロキシ実装では、元のコントラクトを呼び出すために DUP(スタックの値を複製する命令)が2回必要でした。 DUP は 3 ガスかかりますが、PUSH0 を使う構成に変えるとこの複製処理をより軽量にできます。

PUSH0 のコストは 2 ガスのため、2回使用すると合計 4 ガスです。 一方、DUP を2回使うと 6 ガスかかります。 これにより、1回の実行で 2 ガス軽くなり、その他の命令構成の見直しと合わせて約 5 ガス削減できます。

ガス消費は長期運用や多数の呼び出しが発生する環境では積み重なるため、この削減は意味があります。

プロキシコードの読みやすさを改善する

PUSH0 は「ゼロをスタックに積む」という非常に単純な命令です。 この特徴によって、スタック操作の流れをより自然に組み立てられるようになります。 Minimal Proxy Contractの構造を PUSH0 を前提に作り直すと、不必要なスタック操作が減り、動作の意図をコードから理解しやすくなります。

従来のERC1167コードは、最小化を優先した結果、必ずしも読みやすくありませんでした。 PUSH0 を使った新しい構成では、同じ動きを保ちながら、より直感的で理解しやすいコードに整理できます。

仕様

標準プロキシコントラクト(Standard Proxy Contract)

最適化されたMinimal Proxy Contractのランタイムコードは次の 44 バイトです。

text
365f5f375f5f365f73bebebebebebebebebebebebebebebebebebebebe5af43d5f5f3e5f3d91602a57fd5bf3

このうち、インデックス 9〜28 の 20 バイトが実装元コントラクト(implementation contract)のアドレスに置き換えられます。 これがプロキシの目的である「別のコントラクトを参照して動作する仕組み」を実現します。

ランタイムコードのディスアセンブル

以下の表では、バイトコードを命令単位に分解して命令の意味とスタックの状態を示しています。

pc op 説明 スタック
00 36 CALLDATASIZE(呼び出しデータのサイズを取得) cds
01 5f PUSH0(0 を積む) 0 cds
02 5f PUSH0 0 0 cds
03 37 CALLDATACOPY(calldata をコピー)
04 5f PUSH0 0
05 5f PUSH0 0 0
06 36 CALLDATASIZE cds 0 0
07 5f PUSH0 0 cds 0 0
08 73… PUSH20 <実装元アドレス> impl 0 cds 0 0
1d 5a GAS gas impl 0 cds 0 0
1e f4 DELEGATECALL(実装元へ委譲呼び出し) suc
1f 3d RETURNDATASIZE rds suc
20 5f PUSH0 0 rds suc
21 5f PUSH0 0 0 rds suc
22 3e RETURNDATACOPY suc
23 5f PUSH0 0 suc
24 3d RETURNDATASIZE rds 0 suc
25 91 SWAP2 suc 0 rds
26 602a PUSH1 0x2a(JUMPDEST の位置を積む) 0x2a suc 0 rds
27 57 JUMPI(成功/失敗で分岐) 0 rds
29 fd REVERT(失敗時の終了)
2a 5b JUMPDEST(成功時の復帰位置) 0 rds
2b f3 RETURN(結果を返す)

コード全体は、呼び出しデータのコピー → 実装元への DELEGATECALL → 戻り値のコピー → RETURN/REVERT の流れで構成されます。 PUSH0 の採用により、従来の DUP などを使わずにスタック操作を簡潔に表現しています。

最小クリエーションコード(Minimal Creation Code)

デプロイ時に使用されるコードは以下の 53 バイトです。

text
602c8060095f395ff3365f5f375f5f365f73bebebebebebebebebebebebebebebebebebebebe5af43d5f5f3e5f3d91602a57fd5bf3

先頭の 9 バイトが initcode で、残りがランタイムコードとしてコントラクトに保存されます。

text
602c8060095f395ff3

initcode の役割は、ランタイムコードをメモリに配置し、そのまま RETURN するシンプルなものです。 最小限の処理だけでプロキシを展開できるよう設計されています。

デプロイ

以下のSolidityコントラクトは、PUSH0 を利用したMinimal Proxy ContractをEthereum上にデプロイします。

js
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

// Note: this contract requires `PUSH0`, which is available in solidity > 0.8.20 and EVM version > Shanghai
contract Clone0Factory {
    error FailedCreateClone();

    receive() external payable {}

    /**
     * @dev Deploys and returns the address of a clone0 (Minimal Proxy Contract with `PUSH0`) that mimics the behaviour of `implementation`.
     *
     * This function uses the create opcode, which should never revert.
     */
    function clone0(address impl) public payable returns (address addr) {
        // first 18 bytes of the creation code 
        bytes memory data1 = hex"602c8060095f395ff3365f5f375f5f365f73";
        // last 15 bytes of the creation code
        bytes memory data2 = hex"5af43d5f5f3e5f3d91602a57fd5bf3";
        // complete the creation code of Clone0
        bytes memory _code = abi.encodePacked(data1, impl, data2);

        // deploy with create op
        assembly {
            // create(v, p, n)
            addr := create(callvalue(), add(_code, 0x20), mload(_code))
        }

        if (addr == address(0)) {
            revert FailedCreateClone();
        }
    }
}

このコントラクトは、creation codeの前半(18 バイト)、後半(15 バイト)、そして実装元アドレスを結合して、完全なcreation codeを生成します。 そのコードを create 命令でデプロイし、新しいMinimal Proxy Contractのアドレスを返します。

PUSH0 を前提にしているため、Solidity 0.8.20以降かつEVMがShanghai以降である必要があります。

補足

最適化されたMinimal Proxy Contractは、プロキシに必要な最小限の処理だけを組み込みつつ、PUSH0 を活用することでコード量とガス消費を削減しています。 ここでは、プロキシを構成する基本要素と、それをどのように効率的に組み立てているのかを手順ごとに説明します。

プロキシの主な流れは以下の4つです。

  1. CALLDATACOPY で calldata をコピーする
  2. DELEGATECALL で実装元コントラクトへ処理を委譲する
  3. RETURNDATACOPY で返されたデータをコピーする
  4. 成功か失敗かに応じて RETURN か REVERT を実行する

以下では、それぞれの手順をバイトコードとスタック操作を交えて解説します。

手順 1:Calldata のコピー

最初に、呼び出し時に渡された calldata をメモリにコピーします。 CALLDATACOPY には3つの引数が必要で、順に「コピー先のメモリ位置」、「コピー元の開始位置」、「コピーサイズ(calldata size)」です。 ここでは [0, 0, cds] を積む必要があります。

pc op 説明 スタック
00 36 CALLDATASIZE(calldata のサイズ取得) cds
01 5f PUSH0 0 cds
02 5f PUSH0 0 0 cds
03 37 CALLDATACOPY

PUSH0 により0を積み、引数を揃える流れが従来より簡潔になっています。

手順 2:Delegatecall の実行

calldata を実装元コントラクトに渡すため、DELEGATECALL を準備します。 DELEGATECALL には6つの引数(ガス量、対象アドレス、入力位置、入力サイズ、出力位置、出力サイズ)が必要ですが、このプロキシは入力・出力の位置を0とし、利用するガスは GAS 命令で取得、アドレスは PUSH20 で積みます。

pc op 説明 スタック
04 5f PUSH0 0
05 5f PUSH0 0 0
06 36 CALLDATASIZE cds 0 0
07 5f PUSH0 0 cds 0 0
08 73… PUSH20(実装元アドレス) impl 0 cds 0 0
1d 5a GAS(残りガス取得) gas impl 0 cds 0 0
1e f4 DELEGATECALL suc

DELEGATECALL の結果は suc(成功:1 / 失敗:0)としてスタックに残ります。 この値を後の分岐に使います。

手順 3:返り値のコピー

DELEGATECALL の返り値(returndata)は RETURNDATACOPY でメモリにコピーします。 RETURNDATACOPY の引数は [0, 0, rds] で、rds は RETURNDATASIZE 命令で取得します。

pc op 説明 スタック
1f 3d RETURNDATASIZE(返り値サイズ) rds suc
20 5f PUSH0 0 rds suc
21 5f PUSH0 0 0 rds suc
22 3e RETURNDATACOPY suc

ここでも PUSH0 を使うことで、従来の DUP 指令を不要にし、スタック操作を最小限にしています。

手順 4:Return または Revert の分岐

DELEGATECALL の成功または失敗に応じて結果を返すか、処理を巻き戻す必要があります。 しかし、EVMにはif/elseのような構文がないため、JUMPI と JUMPDEST を用いて条件分岐を構築します。

分岐のために suc をスタックの適切な位置に調整し、さらに RETURN と REVERT が必要とする引数 [0, rds] を共通で準備します。 rds は DELEGATECALL 後にしか取得できないため、SWAP2 が必須になります。

pc op 説明 スタック
23 5f PUSH0 0 suc
24 3d RETURNDATASIZE rds 0 suc
25 91 SWAP2(スタック調整) suc 0 rds
26 602a PUSH1 0x2a(成功時ジャンプ先) 0x2a suc 0 rds
27 57 JUMPI(成功なら 0x2a へ) 0 rds
29 fd REVERT
2a 5b JUMPDEST 0 rds
2b f3 RETURN

この構造により、成功時は RETURN、失敗時は REVERT が実行されます。

最終的なランタイムコード

手順を積み重ねて完成したランタイムコードが以下です。

text
365f5f375f5f365f73bebebebebebebebebebebebebebebebebebebebe5af43d5f5f3e5f3d91602a57fd5bf3

全体で 44 バイト となり、従来より 1 バイト短縮されています。

この最適化により、次のメリットがあります。

  • デプロイ時に約 200 gas 削減
  • 実行時に約 5 gas 削減
  • PUSH0 によりスタック操作が簡潔になり、コードの読みやすさが向上

機能そのものは以前のMinimal Proxy Contract(ERC1167)と同じでありながら、サイズ・ガスコストの両面で効率化されています。

互換性

PUSH0 を使った新しい**Minimal Proxy Contract は、PUSH0 オペコードが前提になっています。 PUSH0 はShanghaiアップグレードで追加された命令なので、Shanghai以降のEVM環境でしかデプロイできません。

つまり、以下のような制約があります。

  • Shanghaiより前のチェーン状態では、このプロキシはデプロイできない
  • EVMがShanghaiに対応していないL2 / サイドチェーンなどでも動作しない

一方で、動作そのものは従来のMinimal Proxy Contract(ERC1167)と同じです。 PUSH0 を使うことでコードサイズとガスが削減されただけで、プロキシとしての振る舞い(DELEGATECALL で実装元コントラクトに処理を委譲する動き)は変わりません。

そのため、既存の設計・アーキテクチャはそのままに、環境さえ対応していれば、古いMinimal Proxy Contractを新しいものに置き換えることができます。

セキュリティ

PUSH0 対応版のプロキシは、構造・振る舞いともにERC1167と同一です。 したがって、セキュリティ上の注意点もERC1167と共通になります。

ポイントごとに整理します。

観点 内容
アップグレード不可 一度どの実装コントラクトに委譲するか決めると変更できない
初期化の問題 コンストラクタが使えず、初期化関数を別途呼ぶ必要がある
ロジックコントラクトの安全性 実装側に脆弱性があると、すべてのプロキシに影響する
透明性の問題 表面的には空に見え、実際のロジックが見えづらい

アップグレード不可(Non-Upgradability)

Minimal Proxy Contractは、特定の実装コントラクトに対して DELEGATECALL するだけの非常にシンプルな作りになっています。 どのアドレスに対して DELEGATECALL するかは、デプロイ時のコード(PUSH20 で埋め込まれている実装アドレス)で固定され、後から変更する仕組みはありません。

つまり、一度デプロイしたプロキシは、後から「別の実装コントラクトに差し替える」ことができません。 一般的なアップグレード可能プロキシ(Admin 付きの Transparent Proxy や UUPS など)とは異なり、ERC1167系のプロキシはアップグレード前提の設計ではない、という点を意識して使う必要があります。

初期化の問題

Minimal Proxy Contractでは、*ンストラクタが使えません。 プロキシ自体はほぼ何も状態を持たず、実際の状態は実装コントラクト側のストレージレイアウトに従って書き込まれます。

そのため、デプロイ後には「初期化用の関数」を実装コントラクト側に用意しておき、プロキシ経由で呼び出す必要があります。 この初期化処理を怠ると、例えばオーナーアドレスが未設定のまま残るなど、安全でない状態のまま本番で動いてしまう可能性があります。

要するに、Minimal Proxy Contractを使う場合は、 「デプロイ → 初期化関数を呼ぶ」というフローをきちんと設計に含める必要があります。

ロジックコントラクトの安全性

Minimal Proxy Contractは、自身にはロジックをほとんど持たず、実装コントラクト(ロジックコントラクト)にすべての処理を委譲します。

この構造の結果として、もし実装コントラクトに脆弱性があれば、その実装を参照しているすべてのプロキシに影響が及びます。 プロキシ自体を何個デプロイしても、中身のロジックが同じであれば、同じバグを共有している状態になります。

したがって、Minimal Proxy Contractを大量にデプロイするようなケースでは、実装コントラクトの監査やテストを十分に行うことが特に重要になります。

透明性の問題

Minimal Proxy Contractはバイトコードが極端に短く、Etherscanのようなブロックエクスプローラ上で見ると、「何もしていないコントラクト」のように見えることがあります。 実際には DELEGATECALL で別のアドレスに処理を飛ばしていますが、それを直感的に追うのはユーザーにとって簡単ではありません。

その結果、ユーザーから見ると、以下が分かりにくくなり透明性が下がるという問題があります。

  • どのコントラクトに処理が委譲されているのか
  • 実際のロジックがどこにあるのか

運用上は、ドキュメントやUIで実装コントラクトのアドレスを明示したり、「このアドレスはMinimal Proxyであり、実際のロジックは別アドレスにある」と説明するなど、補助的な工夫が必要になります。

引用

0xAA (@AmazingAng), vectorized (@Vectorized), 0age (@0age), "ERC-7511: Minimal Proxy Contract with PUSH0 [DRAFT]," Ethereum Improvement Proposals, no. 7511, September 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7511.

最後に

今回は「PUSH0 を使い、ERC1167のミニマルプロキシをさらに軽量化する仕組みを提案しているERC7511」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!