0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[ERC7613] calldataで実行先を指定するPuppet Proxyの仕組みを理解しよう!

Posted at

はじめに

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

今回は、デプロイヤーから呼ばれた場合にのみ、呼び出し時の calldata に含めたアドレスへ delegatecall することで、固定の実装を持たずに実行ロジックを切り替えられるPuppet型プロキシの仕組みを提案しているERC7613についてまとめていきます!

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

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

概要

**Puppet(パペット)**は、「呼び出されると空のアカウント(Externally Owned Account: EOA のように見える存在)」のように振る舞うコントラクトです。
普段は何もしないうえに、外部に公開された API(= 関数インターフェース)がありません。

ただし例外が1つだけあります。
Puppetをデプロイしたアドレス(deployer)から呼ばれたときだけ、Puppetは「呼び出しデータ(calldata)に渡されたアドレス」に対して delegatecall(デリゲートコール)を行います。

  • calldata(コールデータ)
    コントラクト呼び出し時に渡される入力データです。
    どの関数を呼ぶか、引数は何か、などが入っています。
  • delegatecall(デリゲートコール)
    別のコントラクトのコードを実行するが、ステート(ストレージ)や残高などは呼び出し元(ここではPuppet**)のものとして扱う**」呼び出し方法です。

この仕組みにより、deployerは「Puppet自身のコンテキスト(Puppetのストレージ・アドレス・保有資産など)」の中で、好きなロジックを実行できるようになります。
Puppet自体は固定で単純でも、実際に動くロジックは calldata で指定した先に置ける、という構造です。

動機

デプロイヤーの別アカウントとして使える

Puppetは deployerと別のアドレスを持つため、資産残高の箱を分けられるのが大きな特徴です。
deployerの「別口座」みたいに扱えます。

これにより、プロトコル側で高度な会計・資産管理ができます。
例えば、「ユーザーごとに専用の入金先アドレス」を割り当てて、そこに資産を送ってもらう設計が可能になります。

プロトコルの流れは、以下のように整理できます。

  1. ユーザーがプロトコルのコントラクトを呼び出します。
  2. プロトコルは新しいPuppetをデプロイし、そのPuppetを「このユーザーに紐づくアドレス」として扱います。
  3. 以後、そのPuppetアドレスに送られた資産を「ユーザーの資産」として扱います。
  4. 資産を引き出したり移動したいときは、プロトコルがPuppetを呼び出し、Puppetdelegatecall させて資産移動ロジックを実行します。

さらに、CREATE2を使ってPuppetを「予測可能なアドレス」にデプロイできる場合、面白いことができます。

  • CREATE2
    saltcreation code(作成コード)から、デプロイ先アドレスが事前に決まるEVMのデプロイ方式です。

例えば、salt にユーザーアドレスを使うと、ユーザーごとのPuppetアドレスが事前に計算できます。
すると Puppetをまだデプロイしていなくても、そのアドレスに先に資金を送れることがあります(アドレス自体は存在するので送金はできます)。
後でPuppetを実際にデプロイし、プロトコルが「ここに入った資産はこのユーザーのもの」と認識すれば成立します。

承認(approve)による入金の代替として使える

多くのDeFiでは、ユーザーがトークンを預ける前にERC20approve(承認) をして、プロトコルが transferFrom で引き出す方式が一般的です。
しかし approve はUX的にもセキュリティ的にも気を遣う点があります。

Puppetを使うと、入金の入口を「approve + pull」から「単純な transfer」へ寄せられます。
つまり、どんなコントラクトでも、どんなウォレットでも、ユーザーに割り当てられたPuppetアドレスへ普通に送金するだけで資金を載せられます。
プロトコルへの承認も、プロトコル呼び出しも必須ではありません。

  • 入金は複数回のトランザクションに分けてもよいし、複数の送金元から集めてもよいです。
  • 別プロトコルから資金を移すときも、相手が「任意アドレスへ送金できる」なら追加の統合が不要です。
  • ERC20送金しかできない」ような機能の限られたウォレットでも入金できます。
  • 高機能ウォレットのユーザーも、危険な可能性のある「中身がよく分からない calldata」に署名する必要が減ります。
    単に transfer ならこれまでと同様の操作で済みます。
  • Puppetに資金が入ったあと、プロトコル側が入金されたことを知るために、誰かがプロトコルを呼ぶ必要があります。
    ここはプロトコル設計次第で、ユーザー本人がやる場合もあれば、第三者が肩代わりできる場合もあります。

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

  • ガスレス取引(gasless transactions)
    ユーザーがガス代を直接払わず、第三者(リレイヤーなど)が手数料を肩代わりする形の体験を指します。

また、プロトコルによっては「入金後に何をするか」をユーザーが毎回指定しなくてもよかったり、事前に設定しておけたりします。
こういうプロトコルは特にPuppetと相性が良く、approve ベースの入金をしているプロトコルの多くがPuppetのメリットを受けられる、というのが元の主張です。

Puppet自体をアップグレードしなくても全体を更新できる

Puppetの本体ロジックは基本的に固定で、アップグレード不要です。
挙動を変えたければdeployer側が次のどちらかを変えます。

  • delegatecall 先のアドレス
  • delegatecall 用に渡す calldata

つまり「Puppetはただの器」で、実際に動かすロジックはdeployerが指示して差し替えられます。

さらに、1つのコントラクト(deployer)が大量のPuppetを配っている場合でも、deployerをアップグレードすれば、配下のPuppet全体の振る舞いをまとめて変えられます。
ここで元の文章が言っているのは、「Beacon(ビーコン)」のような仕組みをPuppet側に持たせなくてもよい、という点です。

  • Beacon(ビーコン)
    たくさんのプロキシが共通の実装アドレスを参照し、参照先を差し替えることで一括アップグレードするパターンです。

また「便利なトリック」として、Puppetdelegatecall する先を「deployer自身のロジックを保持するアドレス」にしておくと、Puppetのロジックはdeployerの中にカプセル化できます。
要するに、Puppetは常にdeployerの中身で動く形に寄せられます。

デプロイヤー以外にAPIを公開できない制約がある

Puppetはdeployer以外の呼び出し元に対してAPIを提供できません。
第三者がPuppetにこれをやってと直接頼むことはできません。

もし第三者にも何か実行させたいなら、第三者が呼べる関数をdeployer側に用意して、その関数の中でPuppetを呼び出して実行させる必要があります。
Puppetを直接叩かせるのではなく、必ずdeployerを経由する形になります。

この制約の結果、コントラクト側が「あるAPIが公開されている前提」で動く仕組みとは相性が悪いです。
元の例だと ERC721safeTransfer のように「受け取り先が特定の関数を実装していること」を期待する仕組みはPuppetではそのまま成立しません。

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

  • safeTransfer(ERC-721)
    NFTをコントラクトに安全に送るため、受け取り側が特定の関数(受領確認)を実装しているかチェックする転送です。

コントラクト内にNFTを送付する機能がない場合に、そのコントラクト内にNFTが一生ロックされてしまうため、事前にNFTを送付する機能をチェックしてから送付する仕組みです。

生成コード(creation code)のバイト列として定義する理由

この標準はPuppetを「コントラクトのソース」ではなく、creation code(デプロイ時に使うコード)のバイト列として定義します。

  • creation code(作成コード)
    コントラクトをデプロイするときに実行され、最終的なruntime bytecode(実行時コード)をチェーン上に残すためのコードです。
  • blob of bytes
    バイト列のかたまり、という意味です。

こう定義しておくと、特定言語・特定コンパイラに依存しにくく、さまざまなフレームワークや実装言語で同一のPuppetを作りやすくなります。
ツールやライブラリ類は標準の範囲外ですが、実運用のためのヘルパーは作りやすいはず、という主張です。

また、実装が同じバイト列を使う限り、どの実装でも同一のPuppetが作られます。CREATE2 を使う場合はアドレスも決定的なので、互換性が高くなります。

CREATE3 の代替やCREATE3 ファクトリーの置き換えにもなり得る

Puppetは固定ロジックを持たないのに「予測可能なアドレスにデプロイできる」という性質があるため、場合によっては CREATE3 の代替として使えます。

  • CREATE3
    デプロイされる最終コントラクトのアドレスを、デプロイするコード(実装)に依存させず salt のみに依存させたい」といった目的で使われることが多いパターン・仕組みの呼び名です(EVM命令としての CREATE3 があるわけではなく、一般にファクトリー実装などで実現されます)。

さらに、CREATE2 で予測可能なPuppetをデプロイし、そのPuppetが通常の CREATE を使って任意コードをデプロイすることで、CREATE3 ファクトリーを完全に置き換えられる場合があります。

デプロイコストが小さく決定論的アドレスに置きやすい

Puppetはデプロイコストが安いことも強調されています。

  • デプロイ後のバイトコード(runtime bytecode)は 66 bytes
  • デプロイ用の creation code62 bytes
  • デプロイコストは 45K gas
  • clone proxy(いわゆる最小プロキシ)より 4K gas 高い程度

ここでの clone proxy(クローンプロキシ)は、最小限のコードで実装コントラクトへ処理を転送する軽量プロキシのことです(一般にEIP1167の最小プロキシが知られています)。

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

また Puppet は clone proxy と同様に、Solidity のメモリ上の scratch space(作業用領域)だけでデプロイできる、と言っています。ここは「デプロイコードが非常に短く、組み立てやすい」ことを指しています。

最後にもう1点重要なのが、「バイトコードがコンパイル生成物ではないので、コンパイラのバージョンに依存せず、信頼できる CREATE2 の決定的アドレスに配置できる」という点です。つまり、コンパイラ差分で微妙に bytecode が変わってアドレスがズレる、といった問題を避けやすい、という意図です。

仕様

デリゲート実行の入力形式(calldata の構造)

Puppetは、呼び出し時の calldata を使って
どのコントラクトのコードを delegatecall するか」を判断します。

そのため、calldata は以下の構造の必要があります。

位置 内容
先頭 32 バイト delegatecall 先のコントラクトアドレス(ABI 形式)
32 バイト以降 delegatecall 先で実行したい関数呼び出しデータ

ここで言うABI 形式のアドレスとは、実際の address(20 バイト)の左側を 0 で埋めて 32 バイトを指します。

calldata =
| 0..31 bytes                     | 32..end bytes          |
| delegatecall 先アドレス(ABI形式) | 実行したい関数の calldata |

Puppetcalldata の先頭 32 バイトだけを見て delegatecall 先を決定し、残りのデータをそのまま delegatecall の入力として使います。

delegatecall を実行しない条件(ガード条件)

Puppetは常に delegatecall を実行するわけではありません。
以下のいずれかに該当する場合、delegatecall は実行されません。

  1. 呼び出し元(CALLER)がdeployerではない
  2. calldata の長さが 32 バイト未満
  3. calldata の先頭 32 バイトがABI形式のアドレスではない

delegatecall 判定フロー(mermaid 図)

ETH送金時に delegatecall が実行されない理由

ETHなどのネイティブトークンを送金する場合、以下となります。

  • calldata は空(長さ 0)
  • 条件2(calldata 長 < 32)に必ず該当

そのためPuppetdelegatecall を実行せず、ETHを受け取って処理を終了します。

これは「ETH送金」と「delegatecall 実行」を明確に分離するための設計です。

Puppetのデプロイ方法(creation code)

Puppetは以下の creation code でデプロイされます。

0x604260126D60203D3D3683113D3560A01C17733D3360147F331817604057823603803D943D373D3D355AF43D82803E903D91604057FD5BF36034525252F3

この creation code の目的は1つだけです。

runtime code を組み立てて、そのまま RETURN する

creation code がやっていること

creation coderuntime code を以下の構造で生成します。

  • code 1code 2 は固定バイト列
  • deployer address は CALLER から取得
  • 3 つを連結したものが runtime code になる

この構造により、以下の性質を持ちます。

  • deployerのアドレスが runtime code に直接埋め込まれる
  • ストレージを使わずにdeployer判定が可能

デプロイ後コード(runtime code)の処理内容

runtime code が実行時に行う処理を、実際の順序どおりに整理すると以下です。

  1. calldataCALLER を使って delegatecall 可否を判定
  2. delegatecall 不可なら何も実行せず終了
  3. delegatecall 可なら
    • delegatecall 先アドレスを calldata[0..32] から取得
    • 残りの calldatapayload としてコピー
    • DELEGATECALL を実行
  4. delegatecall の戻りデータをそのまま返す

payload の扱い

ここで重要なのは以下の点です。

  • Puppetpayload を一切解釈しない
  • delegatecall 先のコントラクトが payload をどう扱うかは完全に自由

補足

目標:低コストでモジュールとして使えること

ERC7613の設計で最も重視しているのは、以下の2点です。

  • デプロイと実行のコストが低いこと
  • 他のコントラクト設計の部品として使いやすいこと

Puppetは単体で複雑なことをするコントラクトではありません。
アドレスと残高とストレージを持つ実行コンテナ」として、できるだけ単純で挙動を追いやすく、再利用しやすい存在であることが目的です。

そのため、以下の点が設計上かなり強く意識されています。

  • ストレージをほぼ使わない
  • 分岐が少ない
  • delegatecall の仕組みが明確

Solidity実装案が採用されなかった理由

Puppetと同じ挙動は、Solidityでも実装できます。
例えば、通常の Solidityコントラクトに inline Yul を書いて delegatecall を行う方法です。

しかし、この方法には問題があります。

まずバイトコードが大きくなります。
Solidityが生成するコードには、関数ディスパッチや安全チェックなどが含まれるため、最小構成にしてもサイズは避けられません。
結果として、以下の欠点があります。

  • デプロイコストが高くなる
  • 実行コストも増える

次に、コンパイラ依存の問題があります。
Solidityのバイトコードは、以下によって微妙に変わります。

  • コンパイラのバージョン
  • 最適化設定

これは CREATE2 で予測可能なアドレスにデプロイしたい場合に致命的です。
同じソースコードでも、環境が違うとアドレスが変わる可能性があり、設計上の前提が崩れます。

このため、Solidity実装は簡単だが目的に合わないと判断されています。

クローンプロキシ(EIP1167)を使う案とその問題点

次の代替案として考えられるのが、**クローンプロキシ(最小プロキシ)**を使う方法です。

構成としては以下のようになります。

この方法には以下のメリットがあります。

  • Puppetのデプロイコストは小さい
  • CREATE2 を使えばアドレスも予測可能

しかし、以下のようなデメリットがあります。

まず delegatecall が1回増えることです。
実行時の流れは以下のように2段階 delegatecall になります。

  1. クローン → Puppet実装
  2. Puppet実装 → 実行ロジック

これはそのままガスコスト増加につながります。

また、構成が複雑になります。

  • Puppet実装コントラクトが必要
  • 先にそれをデプロイする初期化ステップが必要
  • 管理すべきコントラクトが増える

さらに重要なのが CREATE2 アドレス予測への影響です。

クローンプロキシの creation code には、Puppet実装コントラクトのアドレスが含まれます。
つまり、実装コントラクトのアドレスが変わると、クローンのアドレスも変わる構造になります。

これにより、以下の制約が生まれます。

  • 完全に独立した CREATE2 アドレス予測ができない
  • 初期化順序に依存する

Beacon プロキシ案と却下理由

もう1つの代替案がBeaconプロキシパターンです。

Beaconプロキシは、実装アドレスをBeaconコントラクトに保持して各プロキシがBeaconに問い合わせて delegatecall 先を決めるという仕組みです。

しかし、この方式はPuppetの目的とは合いません。

理由は以下です。

まず、Solidityで安全にBeaconを参照する処理自体が重いです。
アドレス取得やチェックのためのコードが増え、バイトコードサイズも実行コストも増えます。

次に、Puppetのような極小コントラクトでコストを抑えたい場合、
Beacon プロキシ単体では足りず、結局クローンプロキシと組み合わせる必要が出てきます。

その結果、コントラクト構成がさらに複雑になり、デプロイ・実行コストがさらに上がるという状態になります。

また、Beaconはステートを更新しないと delegatecall 先を変えられません。
これは、以下の点で設計思想が根本的に異なります。

  • calldata で柔軟に delegatecall 先を指定できるPuppet
  • ステート更新が必要なBeacon

Puppet方式が選ばれた理由の整理

各方式を目的別に整理すると、以下のようになります。

方式 デプロイコスト 実行コスト 構成の単純さ CREATE2 との相性 delegatecall 先の柔軟性
Solidity 実装 高い 高い 普通 悪い 高い
クローンプロキシ やや高い 複雑 制限あり 低い
Beacon プロキシ 高い 高い 非常に複雑 悪い 低い
Puppet 低い 低い 非常に単純 良い 非常に高い

参考実装

Puppet.sol
library Puppet {
    bytes internal constant CREATION_CODE =
        hex"604260126D60203D3D3683113D3560A01C17733D3360147F33181760405782"
        hex"3603803D943D373D3D355AF43D82803E903D91604057FD5BF36034525252F3";
    bytes32 internal constant CREATION_CODE_HASH = keccak256(CREATION_CODE);

    /// @notice Deploy a new puppet.
    /// @return instance The address of the puppet.
    function deploy() internal returns (address instance) {
        bytes memory creationCode = CREATION_CODE;
        assembly {
            instance := create(0, add(creationCode, 32), mload(creationCode))
        }
        require(instance != address(0), "Failed to deploy the puppet");
    }

    /// @notice Deploy a new puppet under a deterministic address.
    /// @param salt The salt to use for the deterministic deployment.
    /// @return instance The address of the puppet.
    function deployDeterministic(bytes32 salt) internal returns (address instance) {
        bytes memory creationCode = CREATION_CODE;
        assembly {
            instance := create2(0, add(creationCode, 32), mload(creationCode), salt)
        }
        require(instance != address(0), "Failed to deploy the puppet");
    }

    /// @notice Calculate the deterministic address for a puppet deployment made by this contract.
    /// @param salt The salt to use for the deterministic deployment.
    /// @return predicted The address of the puppet.
    function predictDeterministicAddress(bytes32 salt) internal view returns (address predicted) {
        return predictDeterministicAddress(salt, address(this));
    }

    /// @notice Calculate the deterministic address for a puppet deployment.
    /// @param salt The salt to use for the deterministic deployment.
    /// @param deployer The address of the deployer of the puppet.
    /// @return predicted The address of the puppet.
    function predictDeterministicAddress(bytes32 salt, address deployer)
        internal
        pure
        returns (address predicted)
    {
        bytes32 hash = keccak256(abi.encodePacked(hex"ff", deployer, salt, CREATION_CODE_HASH));
        return address(uint160(uint256(hash)));
    }

    function delegationCalldata(address delegateTo, bytes memory data)
        internal
        pure
        returns (bytes memory payload)
    {
        return abi.encodePacked(bytes32(uint256(uint160(delegateTo))), data);
    }
}

セキュリティ

クローンプロキシに似せたバイトコード設計

ERC7613のバイトコードは、可能な限りクローンプロキシ(最小プロキシ)に似た構造になるよう設計されています。

これは機能面の理由ではなく、監査(audit)をしやすくするための配慮です。

クローンプロキシはすでに広く使われており、以下の背景があります。

  • バイトコードの構造がよく知られている
  • delegatecall を使う前提の設計に監査者が慣れている

Puppetがまったく未知の構造をしていると、監査時に「意図しない挙動が隠れていないか」を1から精査する必要が出てきます。

そこでPuppet では、以下を既存のクローンプロキシに近い形に寄せることで、「見たことのある構造」、「想定どおりの delegatecall 専用コントラクト」として理解しやすくしています。

  • 処理の流れ
  • delegatecall 周りの構造
  • エラー時の挙動

結果として、以下の実務的なセキュリティ向上につながっています。

  • コードレビューの負担が下がる
  • 想定外の挙動を疑われにくい

delegation先アドレスをABIエンコードさせる理由

もう1つ重要なのが、delegatecall 先アドレスをABI形式で calldata に含めるという設計です。

これは、第三者による意図しない delegatecall 誘導を防ぐための仕組みです。

想定される攻撃シナリオ

もしPuppetが、calldata の先頭を厳密にチェックせず、単に最初の 20 バイトをアドレスとして使うといった仕様だった場合、第三者が用意した calldata をdeployerが意図せずPuppetに渡してしまいPuppetが攻撃者指定のアドレスに delegatecall してしまいます。

つまり、「deployer自身がPuppetを呼んだ」という事実だけを利用して、delegatecall 先をすり替える攻撃が成立する余地が出てきます。

ABI形式チェックによる防御

ERC7613では、この問題を避けるために以下の条件をすべて満たさない限り delegatecall を実行しません。

  • calldata が 32` バイト以上ある
  • 先頭 32 バイトがABI形式のアドレス
    • 上位 12 バイトがすべて 0
    • 下位 20 バイトがアドレス

このチェックにより、適当に作った calldataや通常の関数呼び出しが、そのまま delegatecall 先指定として解釈されることはありません。

関数セレクタ 0x00000000 の特殊性

delegatecall が成立する唯一の例外的なケースとして、deployerが関数セレクタ 0x00000000 を持つ関数をPuppet に対して呼び出した場合関数セレクタは、関数シグネチャ(func(type1,type2,...))のKeccak-256ハッシュの先頭 4 バイトです。

0x00000000 というセレクタは、現実的な関数名・引数から生成されることがほぼなく、Solidityで通常定義される関数ではまず使われないという性質を持ちます。

そのため、deployerが通常のSolidity関数呼び出しをしている限り第三者が calldata を細工していてもPuppetが誤って delegatecall を実行する状況は実質的に発生しません。

このように、delegatecall が成立するのはdeployerが明確に意図した場合だけに限定されています。

引用

Igor Żuk (@CodeSandwich), "ERC-7613: Puppet Proxy Contract [DRAFT]," Ethereum Improvement Proposals, no. 7613, February 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7613.

最後に

今回は「デプロイヤーから呼ばれた場合にのみ、呼び出し時の calldata に含めたアドレスへ delegatecall することで、固定の実装を持たずに実行ロジックを切り替えられるPuppet型プロキシの仕組みを提案しているERC7613」についてまとめてきました!
いかがだったでしょうか?

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

Twitter @cardene777

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?