CertiK:ダイヤモンド代理契約の最良のセキュリティプラクティス
著者:CertiK
代理契約は、スマートコントラクト開発者にとって重要なツールです。現在、契約システムにはさまざまな代理モデルとそれに対応する使用ルールがあります。以前、私たちはアップグレード可能な代理契約の安全なベストプラクティスを概説しました。
この記事では、開発者コミュニティで非常に人気のある別の代理モデル、すなわちダイヤモンド代理モデルを紹介します。
ダイヤモンド代理契約、または「ダイヤモンド」とも呼ばれるものは、Ethereumスマートコントラクトの設計パターンであり、Ethereum改善提案(EIP)2535によって導入されました。
ダイヤモンドモデルは、契約の機能を小さな契約(比喩的に「ファセット」と呼ばれる)に分割することによって、契約が無限の機能を持つことを可能にします。ダイヤモンドは代理として機能し、関数呼び出しを適切なファセットにルーティングします。
ダイヤモンドモデルの設計は、Ethereumネットワークの最大契約サイズ制限の問題を解決できます。大規模な契約を小さなファセットに分解することにより、ダイヤモンドモデルは開発者がサイズ制限に影響されずに、より複雑で機能豊富なスマートコントラクトを構築することを可能にします。
従来のアップグレード可能な契約と比較して、ダイヤモンド代理は大きな柔軟性を提供します。これにより、契約の一部をアップグレードし、特定の関数を追加、置き換え、または削除することができ、他の部分には影響を与えません。
この記事では、EIP-2535の概要を提供し、広く使用されている透明代理モデルやUUPS代理モデルとの比較、およびそれが開発者コミュニティに与える安全性の考慮について説明します。
EIP-2535の文脈において、「ダイヤモンド」は異なる論理契約によって機能が実現される代理契約であり、これらの論理契約は「ファセット」と呼ばれます。
本物のダイヤモンドには異なる側面があり、これをファセット(facet)と呼ぶと想像してみてください。それに応じて、Ethereumのダイヤモンド契約にも異なるファセットがあります。各ダイヤモンドが機能を借用する契約は、異なる側面またはファセット(facet)です。
ダイヤモンド標準は、「ダイヤモンドカット」の機能を拡張するアナロジーを使用して、ファセットと機能を追加、置き換え、または削除するためのものです。
さらに、ダイヤモンド標準は「ダイヤモンドルーペ(Diamond Loupe)」と呼ばれる機能を提供し、ファセットに関する情報とダイヤモンドに存在する機能を返します。 従来の代理モデルと比較して、「ダイヤモンド」は代理契約に相当し、異なる「ファセット」は実装契約に対応します。ダイヤモンド代理の異なるファセットは、内部関数、ライブラリ、および状態変数を共有できます。ダイヤモンドの重要な構成要素は以下の通りです:
代理としての中央契約であり、関数呼び出しを適切なファセットにルーティングします。ファセットアドレスへの関数セレクタのマッピングを含みます。
特定の機能を実現する単一の契約です。各ファセットには、ダイヤモンドから呼び出されることができる関数のセットが含まれています。
EIP-2535で定義された一連の標準関数であり、ダイヤモンド内で使用されるファセットと関数セレクタに関する情報を提供します。ダイヤモンドルーペは、開発者とユーザーがダイヤモンドの構造を検査し理解することを可能にします。
ダイヤモンド内のファセットとその対応する機能セレクタを追加、置き換え、または削除するための関数です。権限のあるアドレス(例えば、ダイヤモンドの所有者やマルチシグ契約)のみがダイヤモンドカットを行うことができます。
従来の代理と同様に、ダイヤモンド代理で関数呼び出しが行われると、代理のフォールバック関数がトリガーされます。ダイヤモンド代理との主な違いは、フォールバック関数内にselectorToFacetマッピングがあり、どの論理契約アドレスが呼び出された関数の実装を持っているかを保存し決定することです。その後、従来の代理と同様にdelegatecallを使用してその関数を実行します。
すべての代理はfallback()関数を使用して、関数呼び出しを外部アドレスに委任します。以下は、ダイヤモンド代理の実装と従来の代理の実装です。
彼らのアセンブリコードブロックは非常に似ているため、唯一の違いはダイヤモンド代理の委任呼び出しにおけるファセットアドレスと従来の代理の委任呼び出しにおけるimplアドレスです。
主な違いは、ダイヤモンド代理では、ファセットのアドレスが呼び出し元のmsg.sig(関数セレクタ)からファセットのアドレスへのハッシュマップによって決定されるのに対し、従来の代理では、implアドレスは呼び出し元の入力に依存しないことです。
ダイヤモンド代理フォールバック関数
従来の代理フォールバック関数 SelectorToFacetマッピングは、どの契約が各関数セレクタの実装を含んでいるかを決定します。プロジェクトのスタッフは、しばしばこの関数セレクタを実装契約に追加、置き換え、または削除する必要があります。EIP-2535では、これを達成するためにdiamondCut()関数が必要です。以下はサンプルインターフェースです。
各FacetCut構造は、ダイヤモンド代理契約内で更新するためのファセットアドレスと4バイトの機能セレクタ配列を含みます。FaceCutActionは、人々が機能セレクタを追加、置き換え、削除できるようにします。diamondCut()関数の実装は、ストレージスロットの衝突を防ぎ、失敗時に復元するための十分なアクセス制御を含むべきです。 ダイヤモンド代理がどのような機能を持ち、どのファセットを使用しているかを照会するために、「ダイヤモンドルーペ」を使用します。「ダイヤモンドルーペ」は、EIP-2535で定義された以下のインターフェースを実装した特別なファセットです:
facets()関数は、すべてのファセットのアドレスとそれらの4バイトの関数セレクタを返すべきです。facetFunctionSelectors()関数は、特定のファセットがサポートするすべての関数セレクタを返すべきです。facetAddresses()関数は、ダイヤモンドが使用するすべてのファセットアドレスを返すべきです。
facetAddress()関数は、与えられたセレクタをサポートするファセットを返すべきであり、見つからない場合はaddress(0)を返すべきです。注意すべきは、同じ機能セレクタを持つファセットアドレスが1つ以上存在してはいけないということです。
ダイヤモンド代理が異なる関数呼び出しを異なる実装契約に委任するため、ストレージスロットを正しく管理して衝突を防ぐことが重要です。EIP-2535では、いくつかのストレージスロット管理方法が言及されています。
このファセットは、構造内で状態変数を宣言できます。このファセットは、異なるストレージ位置を持つ任意の数の構造を使用できます。各構造は契約ストレージ内で特定の位置を持ちます。ファセットは独自の状態変数を宣言できますが、他のファセットが宣言した状態変数のストレージ位置と衝突してはいけません。EIP-2535では、サンプルライブラリとダイヤモンドストレージ契約が提供されています。以下の図を参照してください: Appストレージは、ダイヤモンドストレージのより専門的なバージョンです。このモデルは、ファセットの状態変数をより便利に、より簡単に共有するために使用されます。Appストレージ構造は、アプリケーションに必要な任意の数とタイプの状態変数を含むように定義されます。ファセットは常にAppStorage構造を最初かつ唯一の状態変数として宣言し、ストレージスロットの第0位に配置します。異なるファセットは、その構造から変数にアクセスできます。 さらに、ダイヤモンドストレージとAppStorageの混合を含む他のストレージスロット管理戦略もあります。たとえば、いくつかの構造が異なるファセット間で共有され、いくつかは特定のファセットに固有です。すべてのケースにおいて、偶発的なストレージスロットの衝突を防ぐことが非常に重要です。
透明代理とUUPS代理との比較
現在、Web3開発者コミュニティで使用されている2つの主要な代理モデルは、透明代理モデルとUUPS代理モデルです。このセクションでは、ダイヤモンド代理モデルと透明代理およびUUPS代理モデルを簡単に比較します。 1.EPI-2535:https://eips.ethereum.org/EIPS/eip-2535#Facets,%20State%20Variables%20and%20Diamond%20Storage
2.EPI-1967:https://eips.ethereum.org/EIPS/eip-1967
3.Diamond proxy reference implementation:https://github.com/mudgen/Diamond
4.OpenZeppelin implementation:https://github.com/OpenZeppelin/openzeppelin-contracts/tree/v4.7.0/contracts/proxy
代理とアップグレード可能なソリューションは複雑なシステムであり、OpenZeppelinはUUPS、透明、Beaconアップグレード可能な代理のためのコードライブラリと包括的なドキュメントを提供しています。しかし、ダイヤモンド代理モデルについては、OpenZeppelinはその利点を認めているものの、EIP-2535ダイヤモンドの実装を彼らのライブラリに含めないことを決定しました。
したがって、既存のサードパーティライブラリを使用するか、このソリューションを独自に実装する開発者は、実装時に特に注意が必要です。ここでは、開発者コミュニティの参考のために、安全なベストプラクティスのチェックリストを作成しました。 契約ロジックをより小さく、管理しやすいモジュールに分解することにより、開発者はコードのテストと監査をより容易に行うことができます。
さらに、このアプローチにより、開発者は複雑な単一のコードベースを管理するのではなく、契約の特定の側面の構築と維持に集中することができます。最終的な結果は、契約の他の部分に影響を与えることなく、簡単に更新および変更できる、より柔軟でモジュール化されたコードベースです。 資料出典:Aavegotchi Github
ダイヤモンド代理契約がデプロイされると、DiamondCutFacet契約のアドレスをダイヤモンド代理契約に追加し、diamondCut()関数を実装する必要があります。diamondCut()関数は、ファセットと関数を追加、削除、または置き換えるために使用され、DiamondCutFacetとdiamondCut()がなければ、ダイヤモンド代理は正常に機能しません。 資料出典:Mugen's Diamond-3-Hardhat スマートコントラクトに新しい状態変数をストレージ構造に追加する場合、構造の末尾に追加する必要があります。構造の先頭や中間に新しい状態変数を追加すると、新しい状態変数が既存の状態変数データを上書きし、新しい状態変数の後の任意の状態変数が誤ったストレージ位置を参照する可能性があります。
AppStorageモデルでは、ダイヤモンド代理のために1つだけの構造を宣言し、その構造はすべてのファセットで共有される必要があります。複数の構造が必要な場合は、DiamondStorageモデルを使用するべきです。 構造を別の構造の中に直接置かないでください。内部構造にさらに状態変数を追加するつもりがないことが確実でない限り、そうするべきではありません。構造の後に宣言された変数のストレージスロットを上書きしない限り、内部構造に新しい状態変数を追加することはできません。
解決策は、新しい状態変数をストレージマッピング構造に追加することであり、「構造」を「構造」の中に直接置くことではありません。マッピング内の変数のストレージスロットの計算方法は異なり、ストレージ内で連続していません。
配列のサイズは構造のサイズに影響されます。新しい状態変数が構造に追加されると、その構造のサイズとレイアウトが変更されます。
その構造が配列の要素として使用される場合、問題が発生する可能性があります。構造のサイズとレイアウトが変更されると、配列のサイズとレイアウトも変更され、インデックスや他の依存する操作に問題が生じる可能性があります。
他の代理モデルと同様に、各変数には一意のストレージスロットが必要です。そうでない場合、同じ位置にある2つの異なる構造が互いに上書きされます。
initialize()関数は通常、特権ロールのアドレスなどの重要な変数を設定するために使用されます。契約デプロイ時に初期化されない場合、悪意のある行為者が契約を呼び出して制御する可能性があります。
初期化/設定関数には適切なアクセス制御を追加するか、その関数が契約デプロイ時に呼び出され、再度呼び出されないことを確認することをお勧めします。
契約内の任意のファセットがselfdestruct()関数を呼び出すことができる場合、それは契約全体を破壊し、資金やデータの損失を引き起こす可能性があります。これはダイヤモンド代理モデルでは非常に危険であり、複数のファセットが代理契約のストレージとデータにアクセスできるためです。 現在、私たちはますます多くのプロジェクトが彼らのスマートコントラクトにダイヤモンド代理モデルを採用しているのを見ています。従来の代理と比較して、柔軟性やその他の利点があります。
しかし、追加の柔軟性は、攻撃者により広範な攻撃面を提供する可能性もあります。この文章が開発者コミュニティにダイヤモンド代理モデルのメカニズムとその安全性の考慮を理解するのに役立つことを願っています。
同時に、プロジェクトチームは、ダイヤモンド代理契約の実装に関連する脆弱性のリスクを減らすために、厳格なテストと第三者監査を行うべきです。