はじめに
初めまして。
CryptoGamesというブロックチェーンゲーム企業でエンジニアをしている cardene(かるでね) です!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。
代表的なゲームはクリプトスペルズというブロックチェーンゲームです。
今回は、プロキシコントラクトとDictionaryコントラクトを使用したモジュール化とアップグレードの仕組みを提案し、関数レベルでのコントラクトアップグレード可能性、ファクトリ/クローン対応と同時アップグレードを可能にしているERC7546についてまとめていきます!
以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。
他にも様々なEIPについてまとめています。
概要
Ethereumの仮想マシン(EVM)上でクローン可能かつアップグレード可能なコントラクトを作成することは難く課題でした。
ERC2535のDiamondsパターンなどの既存プロキシ標準は部分的な解決策を提案していますが、完全な課題解決には至っていません。
この規格では、以下の2つの主要な機能の導入によって課題を解決する提案をしています。
アップグレーダブルなコントラクトでは、「プロキシコントラクト」(以降「Proxy」)と「インプリメンテーションコントラクト」(以降「implementation」)の2つに分かれています。
implementationはコントラクトのロジック(実装)を管理し、Proxyはimplementationに実装されている機能の呼び出しとデータの保持を担当します。
これにより、「データ」をProxyが管理し、「実装」をimplementationが管理できるため、実装をアップグレードしたい時は新しいimplementationをデプロイし、Proxyから呼び出すimplementationを新しいコントラクトにすることができ、これによりコントラクトのアップグレードが可能になります。
より詳しくは以下の記事を参考にしてください。
関数レベルでのアップグレード
ERC2535で提案されている仕組みを活用し、各関数の呼び出しを実装コントラクトにリダイレクトすることができます。
これにより、関数単位でアップグレードを行うことが可能になり、コントラクトの一部だけを効率的に改善できます。
また、関数ごとに実装コントラクトを分けることで、EVMのコントラクトサイズ上限(24.576kB
)を回避しやすくなります。
ERC2535については以下の記事を参考にしてください。
ファクトリ/クローン対応 & 同時アップグレード可能性
ERC1967のビーコンモデルを参考にして、Proxyコントラクトのクローン作成とアップグレードプロセスを効率化します。
これにより、データを保有するそれぞれのコントラクトが呼び出す機能の一貫性を維持することができます。
通常、Proxyは基本的なアップグレーダブル機能に限定されるか、ERC1167標準に従っていますが、この規格で提案されている機能では、両方の機能をコンパクトなProxyに統合しています。
ERC1967については以下の記事を参考にしてください。
ERC1167については以下の記事を参考にしてください。
動機
スマートコントラクト開発では、コントラクトサイズの上限やスタックの深さなどのEthereum Virtual Machine (EVM)の固有の制限によく直面しています。
また、スマートコントラクトのロジックやコンパイラの脆弱性に対処する必要が常にあります。
さらに、アップグレードの管理において信頼された第三者への依存を最小限にしようとする一方で、複雑なガバナンス構造を導入することは、DevOpsの作業量を大幅に増加させて作業負荷を増大させます。
この規格で提案されているアプローチは、コントラクトの構造を単純化し、よりアクセスしやすく楽しいものにすることを目指しています。
これにより、DevOpsの関心ごとをビジネスロジックから明確に分離して、コードの明確化を図り、監査を容易にし、特定のインフラやドメインに合わせた言語モデル(LM)技術を用いた分析を可能にします。
ユースケース
コントラクトの設計パターンはこれまでに多く提案されてきましたが不十分であり、このアップグレード可能なクローン標準(UCS)はさまざまんシナリオに対応するために設計されています。
用語
-
コントラクトレベルのアップグレード可能性
- 1つのプロキシコントラクトが1つの実装コントラクトに対応し、プロキシの全てのロジックを管理します。
-
関数レベルのアップグレード可能性
- 1つのプロキシコントラクトが複数の実装コントラクトに対応し、各実装コントラクトが特定の関数を担当します。
-
ファクトリ
- 共通の実装を持つプロキシをクローンできるコントラクトです。
- アップグレード可能なコンテキストにおいてクローンされたプロキシを同時にアップグレードすることができます。
ユースケース
-
基本的なニーズ
- アップグレードやファクトリが不要な場合、通常のスマートコントラクトのデプロイで十分です。
-
ファクトリが必要だがアップグレード不要な場合
- ERC1167が適しています。
-
コントラクトレベルのアップグレード可能性が必要だがファクトリが不要な場合
- ERC1822が使用できます。
ERC1822については以下の記事を参考にしてください。
-
コントラクトレベルのアップグレード可能性とファクトリが必要な場合
- ERC1967のビーコンを使用します。
-
ファンクションレベルのアップグレード可能性が必要だがファクトリが不要な場合
- ERC2535が利用可能です。
-
ファンクションレベルのアップグレード可能性とファクトリが必要な場合
- このアップグレード可能なクローン標準(UCS)が最適な選択です。
仕様
EVMのコントラクトアカウントでは、以下の4つのフィールドを持っています。
nonce
balance
code
storage
このERCのアーキテクチャでは、3つの異なるタイプのコントラクトにモジュールかし、それぞれを組み合わせて1つのアカウントとして表現します。
Proxyコントラクト
プロキシコントラクトは、コントラクトアカウントのステート(nonce
、balance
、storage
)を管理します。
このコントラクトは、辞書コントラクトに登録されたファンクションコントラクトに対してdelegatecallを行うことで、状態とロジックを分離しつつ効果的に統合します。
Dictionaryコントラクト
Dictionaryコントラクトは、関数のセレクタに基づいて適切な関数コントラクトに関数コールをルーティングするディスパッチャーとして機能します。
これにより、関数のアップグレードや動的アドレス指定を容易にします。
プロキシコントラクトからこのコントラクトを外部化することで、facotry/clone対応が可能になり、同時アップグレードもサポートされます。
用語がわかりづらいという方向けにわかりやすく説明します。
関数セレクタに基づくルーティング*
コントラクトの中で関数を呼び出すとき、Dictionaryはその関数のセレクタ(calldata
の先頭4バイト分で関数のIDのようなもの)を確認し、どの関数コントラクト(ロジックが書かれているコントラクト)にその呼び出しを送るかを決定します。
https://solidity-by-example.org/function-selector/
関数のアップグレードが容易
関数を新しいバージョンにアップグレードしたい場合、Dictionaryコントラクト内でその関数の新しいアドレスを指定するだけです。
これにより、既存のプロキシコントラクトを変更することなく、関数のロジックを簡単に更新できます。
動的アドレス指定
関数のアドレスが動的に変わる場合でも、Dictionaryコントラクトが適切なアドレスを指すようにしてお具ことでプロキシコントラクトからの呼び出しは正しく機能します。
プロキシコントラクトから外部化するメリット
-
ファクトリ/クローン対応
- Dictionaryコントラクトをプロキシコントラクトから分離することで、複数のプロキシコントラクトが同じDictionaryコントラクトを共有できるようになります。
- これにより、新しいプロキシコントラクトを簡単にクローン(コピー)できます。
-
同時アップグレードのサポート
- 1つのDictionaryコントラクトを更新するだけで、関連する全てのプロキシコントラクトが同時にアップグレードされ、管理が非常に効率的になります。
ファンクション(Implementation)コントラクト
Implementationコントラクトは、関数呼び出しに対する実行可能なロジックを実装します。
プロキシコントラクトによってdelegatecall
された場合、コントラクトのコードに定義されたロジックを実行します。
このアーキテクチャは、EVMコントラクトアカウントのコア属性と整合性をとりつつ、アカウントステート、関数ディスパッチ、ロジック実装の明確化によってスマートコントラクトのモジュール性、アップグレード性、スケーラビリティを大幅に向上させます。
Proxyコントラクト
プロキシコントラクトは、実行したい関数の関数セレクタをDictionaryコントラクトに渡して関数コントラクトのアドレスを取得し、そのアドレスに対してdelegatecall
を実行します。
ストレージとイベント
このコントラクトは、Dictionaryコントラクトのアドレスを特定のストレージスロットに保存します。
保存するスロットは、**ERC1967*に従って以下の計算式から0x267691be3525af8a813d30db0c9e2bad08f63baecf6dceb85e2cf3676cff56f4
などの特定の値が割り当てられます。
bytes32(uint256(keccak256('erc7546.proxy.dictionary')) - 1)
また、Dictionaryコントラクトのアドレスが変更された場合、以下のイベントを発行します。
event DictionaryUpgraded(address dictionary);
Dictionaryコントラクト
Dictionaryコントラクトは、関数セレクタに対応する関数コントラクトのアドレスを管理するマッピングを保持します。
プロキシコントラクトからのリクエストを処理するためにこのマッピングを使用します。
ストレージとイベント
Dictionaryコントラクトは、関数セレクタと関数コントラクトのアドレスのマッピングを保持し、変更があった場合イベントを発行します。
event ImplementationUpgraded(bytes4 functionSelector, address implementation);
関数
getImplementation
関数セレクタに対応する関数コントラクトのアドレスを返す関数です。
function getImplementation(bytes4 functionSelector) external view returns(address implementation);
setImplementation
新しい関数セレクタと関数コントラクトのアドレスを設定する関数です。
function setImplementation(bytes4 functionSelector, address implementation) external;
supportsInterface
ERC165で定義されているsupportsInterface(bytes4 interfaceID)
関数を実装し、マッピング内のコントラクトがサポートするインターフェースを返すことが推奨されます。
ERC165については以下の記事を参考にしてください。
supportsInterfaces
登録されているインターフェースIDのリストを返す関数です。
function supportsInterfaces() public view returns (bytes4[] memory);
Function (Implementation)コントラクト
関数コントラクトは、プロキシコントラクトがdelegatecall
するロジック実装コントラクトであり、そのアドレスはDictionaryコントラクトに関数セレクタと共に登録されます。
データについては、このコントラクトのstorage
を使用せず、delegatecall
を通じてプロキシコントラクトのstorage
を使用します。
プロキシコントラクトは複数の関数コントラクトとstorage
を共有します。
例えば、デフォルトのコンパイラオプションでスロット0
から順に割り当てると、ストレージの競合が発生する可能性があります。
ストレージスロットについては以下の記事を参考にしてください。
storage
の競合を防ぐために、このコントラクトはストレージレイアウトを適切に管理する必要があります。
storage
の管理については、ERCレベルでもSolidityなどの言語レベルでも長年議論されてきましたが未だに決まった標準はありません。
そのため、このERCでは具体的なstorage
管理技術については言及されておらず、適切と考えられるstorage
管理方法を選択することが推奨されてます。
例えば、ERC7201のような有用なストレージレイアウトパターンに従ってストレージを配置することが考えられます。
ERC7201については以下の記事を参考にしてください。
このコントラクトは、Dictionaryコントラクトに登録された関数セレクタと同じ関数セレクタを持たなければなりません。
もし異なる場合、プロキシのdelegatecall
が失敗します。
そのため、各関数コントラクトがDictionaryに追加される時に登録された関数セレクタを正しく実装していることを確認するため、ERC165のsupportsInterface(bytes4 interfaceID)
を実装することが推奨されます。
補足
ERC2535との比較
このERCとµERC2535の両方は、関数レベルのアップグレード可能性を提案していますがアプローチが異なります。
ERC2535では、実装コントラクト(ERC2535では「ファセット」と呼ばれる)のマッピングをプロキシ自体の内部に保持します。
一方、、このERCではマッピングを外部のDictionaryコントラクトに保存します。
マッピングをプロキシから分離することで、コントラクトのクローン作成や同時アップグレードが簡単になります。
これは、ERC2535のフレームワークでの簡単に実装できないです。
引用: https://eips.ethereum.org/EIPS/eip-7546
ダイヤモンド標準との比較図
Dictionaryコントラクトとプロキシコントラクトを分離したのは、ファクトリ/クローン対応と同時アップグレードを実現するためです。
この目的のために、関数実装コントラクトのアドレス管理機能をプロキシコントラクト内に含めるのではなく、Dictionaryコントラクトとして外部化しました。
これは、ビーコンプロキシアプローチに似た概念です。
関数がプロキシコントラクト内にある場合、各プロキシごとに実装をアップグレードする必要がありますが、外部化することで共通の実装をクローンし、同時にアップグレードすることが可能になります。
引用: https://eips.ethereum.org/EIPS/eip-7546
ビーコンプロキシについては以下の図がわかりやすいです。
factoryコントラクトでプロキシーコントラクトを複数作成し、各プロキシーコントラクトからはbeaconコントラクトを経由して実装コントラクトを呼び出すような構成です。
参考: https://gist.github.com/yurenju/ef4c901a48c523ac74bf942b50ab5108?permalink_comment_id=4018413#gistcomment-4018413
関数セレクタと実装アドレスのマッピング
Dictionaryコントラクトの関数セレクタと対応する関数実装コントラクトのアドレスのマッピングをプロキシコントラクトが呼び出し、返された実装アドレスに対してdelegatecall
を行うことで関数レベルのアップグレード可能性が実現されます。
このアプローチを採用することで、プロキシはDictionaryコントラクト内に登録さている関数実装コントラクトを全て実装しているように振る舞えます。
この仕様は、ダイヤモンド標準で示されたパターンと非常に似ています。
実装
実装に関しては以下に情報がまとめられています。
セキュリティ
実装管理の委任
この規格で提案されているパターンでは、実行したい関数の呼び出し時にDictionaryコントラクトを経由する必要があり、Dictionaryコントラクトの管理者を信用する必要があります。
信頼できない管理者が提供するDictionaryコントラクトにプロキシを接続することは危険です。
そのため、別の管理者によって管理される別のDictionaryコントラクトに切り替える機能の提供が推奨されます。
Dictionaryコントラクトのアドレスをコードエリア(例:Solidityのimmutable
やconstant
)に保存することも可能ですが、Dictionaryコントラクトの管理者がプロキシコントラクトの管理者と異なる場合、実装を操作する権限が永久に失われる可能性を考慮して設計する必要があります。
ストレージの競合
このデザインパターンでは、複数の関数実装コントラクトが単一のプロキシコントラクトのストレージを共有します。
そのため、適切なストレージ管理方法を使用してストレージの競合を防ぐことが重要です。
ストレージの競合が起きると、想定しない値の保存がされてしまい脆弱性に繋がってしまいます。
関数セレクタの不一致
Dictionaryコントラクトは、プロキシコントラクトによって呼び出された関数セレクタに基づいて、対応する関数実装コントラクトのアドレスを返します。
Dictionaryコントラクトに登録されている関数セレクタと、関数実装コントラクトに実装されている関数セレクタが一致しない場合実行が失敗します。
予期しない動作を防ぐために、Dictionaryコントラクトにアドレスを設定する時に、関数実装コントラクトが登録される関数セレクタ(インターフェース)を含んでいるか確認することが推奨されます。
CALLとSTATICCALLの処理
プロキシコントラクトは主にCALL
とSTATICCALL
オペコードに応答するように設計されています。
もしこのプロキシコントラクトに対してDELEGATECALL
が行われた場合、プロキシコントラクトはstorage
に保存されているDictionaryコントラクトのアドレスを使用して、getImplementation(bytes4 functionSelector)
関数を介して処理をリクエストします。
呼び出し元のコントラクトのストレージレイアウトがプロキシコントラクトの期待するレイアウトと一致しない場合、DELEGATECALL
を使用すると想定した結果が得られない可能性があります。
ただ、プロキシコントラクト自体に直接的な脅威やセキュリティリスクをもたらすわけではありません。
開発者はこのプロキシコントラクトをDELEGATECALL
で呼び出すと、予期しない動作を引き起こす可能性を認識しておく必要があります。
引用
Shogo Ochiai (@shogochiai) shogo.ochiai@pm.me, Kai Hiroi (@KaiHiroi) kai.hiroi@pm.me, "ERC-7546: Upgradeable Clone for Scalable Contracts [DRAFT]," Ethereum Improvement Proposals, no. 7546, October 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7546.
参考
最後に
今回は「プロキシコントラクトとDictionaryコントラクトを使用したモジュール化とアップグレードの仕組みを提案し、関数レベルでのコントラクトアップグレード可能性、ファクトリ/クローン対応と同時アップグレードを可能にしているERC7546」についてまとめてきました!
いかがだったでしょうか?
質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!
他の媒体でも情報発信しているのでぜひ他も見ていってください!