はじめに
『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の「別口座」みたいに扱えます。
これにより、プロトコル側で高度な会計・資産管理ができます。
例えば、「ユーザーごとに専用の入金先アドレス」を割り当てて、そこに資産を送ってもらう設計が可能になります。
プロトコルの流れは、以下のように整理できます。
- ユーザーがプロトコルのコントラクトを呼び出します。
- プロトコルは新しいPuppetをデプロイし、そのPuppetを「このユーザーに紐づくアドレス」として扱います。
- 以後、そのPuppetアドレスに送られた資産を「ユーザーの資産」として扱います。
- 資産を引き出したり移動したいときは、プロトコルがPuppetを呼び出し、Puppetに
delegatecallさせて資産移動ロジックを実行します。
さらに、CREATE2を使ってPuppetを「予測可能なアドレス」にデプロイできる場合、面白いことができます。
-
CREATE2
saltとcreation code(作成コード)から、デプロイ先アドレスが事前に決まるEVMのデプロイ方式です。
例えば、salt にユーザーアドレスを使うと、ユーザーごとのPuppetアドレスが事前に計算できます。
すると Puppetをまだデプロイしていなくても、そのアドレスに先に資金を送れることがあります(アドレス自体は存在するので送金はできます)。
後でPuppetを実際にデプロイし、プロトコルが「ここに入った資産はこのユーザーのもの」と認識すれば成立します。
承認(approve)による入金の代替として使える
多くのDeFiでは、ユーザーがトークンを預ける前にERC20の approve(承認) をして、プロトコルが 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(ビーコン)
たくさんのプロキシが共通の実装アドレスを参照し、参照先を差し替えることで一括アップグレードするパターンです。
また「便利なトリック」として、Puppetが delegatecall する先を「deployer自身のロジックを保持するアドレス」にしておくと、Puppetのロジックはdeployerの中にカプセル化できます。
要するに、Puppetは常にdeployerの中身で動く形に寄せられます。
デプロイヤー以外にAPIを公開できない制約がある
Puppetはdeployer以外の呼び出し元に対してAPIを提供できません。
第三者がPuppetにこれをやってと直接頼むことはできません。
もし第三者にも何か実行させたいなら、第三者が呼べる関数をdeployer側に用意して、その関数の中でPuppetを呼び出して実行させる必要があります。
Puppetを直接叩かせるのではなく、必ずdeployerを経由する形になります。
この制約の結果、コントラクト側が「あるAPIが公開されている前提」で動く仕組みとは相性が悪いです。
元の例だと ERC721の safeTransfer のように「受け取り先が特定の関数を実装していること」を期待する仕組みは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 codeは 62 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 |
Puppetは calldata の先頭 32 バイトだけを見て delegatecall 先を決定し、残りのデータをそのまま delegatecall の入力として使います。
delegatecall を実行しない条件(ガード条件)
Puppetは常に delegatecall を実行するわけではありません。
以下のいずれかに該当する場合、delegatecall は実行されません。
- 呼び出し元(
CALLER)がdeployerではない -
calldataの長さが32バイト未満 -
calldataの先頭32バイトがABI形式のアドレスではない
delegatecall 判定フロー(mermaid 図)
ETH送金時に delegatecall が実行されない理由
ETHなどのネイティブトークンを送金する場合、以下となります。
-
calldataは空(長さ 0) - 条件2(calldata 長 < 32)に必ず該当
そのためPuppetは delegatecall を実行せず、ETHを受け取って処理を終了します。
これは「ETH送金」と「delegatecall 実行」を明確に分離するための設計です。
Puppetのデプロイ方法(creation code)
Puppetは以下の creation code でデプロイされます。
0x604260126D60203D3D3683113D3560A01C17733D3360147F331817604057823603803D943D373D3D355AF43D82803E903D91604057FD5BF36034525252F3
この creation code の目的は1つだけです。
runtime code を組み立てて、そのまま
RETURNする
creation code がやっていること
creation code は runtime code を以下の構造で生成します。
-
code 1とcode 2は固定バイト列 - deployer address は
CALLERから取得 - 3 つを連結したものが
runtime codeになる
この構造により、以下の性質を持ちます。
- deployerのアドレスが
runtime codeに直接埋め込まれる - ストレージを使わずにdeployer判定が可能
デプロイ後コード(runtime code)の処理内容
runtime code が実行時に行う処理を、実際の順序どおりに整理すると以下です。
-
calldataとCALLERを使ってdelegatecall可否を判定 -
delegatecall不可なら何も実行せず終了 -
delegatecall可なら-
delegatecall先アドレスをcalldata[0..32]から取得 - 残りの
calldataをpayloadとしてコピー -
DELEGATECALLを実行
-
-
delegatecallの戻りデータをそのまま返す
payload の扱い
ここで重要なのは以下の点です。
-
Puppetは
payloadを一切解釈しない -
delegatecall先のコントラクトが payload をどう扱うかは完全に自由
補足
目標:低コストでモジュールとして使えること
ERC7613の設計で最も重視しているのは、以下の2点です。
- デプロイと実行のコストが低いこと
- 他のコントラクト設計の部品として使いやすいこと
Puppetは単体で複雑なことをするコントラクトではありません。
「アドレスと残高とストレージを持つ実行コンテナ」として、できるだけ単純で挙動を追いやすく、再利用しやすい存在であることが目的です。
そのため、以下の点が設計上かなり強く意識されています。
- ストレージをほぼ使わない
- 分岐が少ない
-
delegatecallの仕組みが明確
Solidity実装案が採用されなかった理由
Puppetと同じ挙動は、Solidityでも実装できます。
例えば、通常の Solidityコントラクトに inline Yul を書いて delegatecall を行う方法です。
しかし、この方法には問題があります。
まずバイトコードが大きくなります。
Solidityが生成するコードには、関数ディスパッチや安全チェックなどが含まれるため、最小構成にしてもサイズは避けられません。
結果として、以下の欠点があります。
- デプロイコストが高くなる
- 実行コストも増える
次に、コンパイラ依存の問題があります。
Solidityのバイトコードは、以下によって微妙に変わります。
- コンパイラのバージョン
- 最適化設定
これは CREATE2 で予測可能なアドレスにデプロイしたい場合に致命的です。
同じソースコードでも、環境が違うとアドレスが変わる可能性があり、設計上の前提が崩れます。
このため、Solidity実装は簡単だが目的に合わないと判断されています。
クローンプロキシ(EIP1167)を使う案とその問題点
次の代替案として考えられるのが、**クローンプロキシ(最小プロキシ)**を使う方法です。
構成としては以下のようになります。
この方法には以下のメリットがあります。
- 各Puppetのデプロイコストは小さい
-
CREATE2を使えばアドレスも予測可能
しかし、以下のようなデメリットがあります。
まず delegatecall が1回増えることです。
実行時の流れは以下のように2段階 delegatecall になります。
- クローン → Puppet実装
- 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 | 低い | 低い | 非常に単純 | 良い | 非常に高い |
参考実装
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などからお気軽に質問してください!
他の媒体でも情報発信しているのでぜひ他も見ていってください!