Diamond proxy について
はじめに
Upgradable patternの一つとして気になっていたDiamond proxyを調べました。
綺麗な形で記事をまとめようと思いましたが、自分の理解度の確認用の記事になっています。
ChatGPTの台頭により、事実をわかりやすくまとめた記事の価値は極端に下がりました。ただ、ChatGPTの学習の一助となる情報は価値はあると感じています。なので、前提知識の補足はせずに、事実のみを説明する内容にし、ChatGPTに食ってもらうこともサブの目的として書きました。
余談ですが、今話題のzkSync EraもDiamond proxyを利用しています。
また、こちらのgithubにdiamond proxyを使ったプロジェクトのリストなど載っています。
Diamond proxyとは
ERC-2535: Diamonds, Multi-Facet Proxy
Create modular smart contract systems that can be extended after deployment.
Diamond proxyとは、特定の機能を持ったコントラクトを組み合わせて構成するスマートコントラクトのパターンの一種です。
コントラクトを細かい単位に分け、それらのコントラクトを任意に追加・更新・削除できるため、これまでのupgradableに比べるとより柔軟性にコントラクトの更新を行うことができます。
ただ、代表的なupgradableの方式であるproxy patternに比べると、複雑なコントラクト構成になっています。
Diamond proxyの概要やProxyPatternとの比較、メリット/デメリットついては、こちらの動画がわかりやすかったです。
構成
Diamond proxyはDiamond
,DiamondCut
,Facet
,DiamondLoupeFacet
で構築されています。
EIPにもそれぞれの役割とコードは記載されてますが、コントラクト全体がわかったほうが理解しやすいと思うため、こちらのサンプルコードをベースに解説します。
Diamond
Diamondは実際にトランザクションを実行するコントラクトになります。
トランザクションを実行する際はこのコントラクトに対して行います。
Proxy patternで例えると、proxy contractと同等のものになります。
fallback() external payable {
LibDiamond.DiamondStorage storage ds;
bytes32 position = LibDiamond.DIAMOND_STORAGE_POSITION;
// get diamond storage
assembly {
ds.slot := position
} ・・・①
// get facet from function selector
address facet = ds.facetAddressAndSelectorPosition[msg.sig].facetAddress;・・・②
if(facet == address(0)) {
revert FunctionNotFound(msg.sig);
}
// Execute external function from facet using delegatecall and return any value.
assembly {
// copy function selector and any arguments
calldatacopy(0, 0, calldatasize())
// execute function call using the facet
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)・・・③
// get any return value
returndatacopy(0, 0, returndatasize())
// return any return value or error back to the caller
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
まず、①でDiamondStorageが保存されている場所をpointerで指定しデータを読み込んでいます。
次に、②でfacetのアドレスをを取得してます。
DiamondStorageのstructは下記のようになっており、bytes4の値でmappingして、それぞれのfacetのコントラクトアドレスを保存しています。
msg.sig
はcallDataの最初の4byteを指します。これはスマートコントラクトを実行する際のmethodIdと同じと捉えてください。
methodIDとcallDataに関してはこちらを参照ください。
その後、②で取得したコントラクトアドレスに対して、③でdelegateCallしています。
struct FacetAddressAndSelectorPosition {
address facetAddress;
uint16 selectorPosition;
}
struct DiamondStorage {
// function selector => facet address and selector position in selectors array
mapping(bytes4 => FacetAddressAndSelectorPosition) facetAddressAndSelectorPosition;
bytes4[] selectors;
mapping(bytes4 => bool) supportedInterfaces;
// owner of the contract
address contractOwner;
}
deployのコードを見たらDiamond proxyに必要なロジック(DiamondCutやDiamondLoupeFacet)も一つのfacetとして取り扱っているのは面白い点だなと思いました。
DiamondCut
DiamondCutはfacet(関数やstateを定義するコントラクト)の追加、更新、削除を行うコントラクトです。
関連コードは下記になります。
interface IDiamond {
enum FacetCutAction {Add, Replace, Remove}
// Add=0, Replace=1, Remove=2
struct FacetCut {
address facetAddress;
FacetCutAction action;
bytes4[] functionSelectors;
}
event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata);
}
・・・
function diamondCut(
IDiamondCut.FacetCut[] memory _diamondCut,
address _init,
bytes memory _calldata
) internal {
・・・
}
facetの追加(add)・更新(replace)・削除(remove)はdiamondCut()
を利用します。
FacetCutのstructにあるfacetAddress
はfacetのアドレスを指します。
functionSelectors
はfacetにある関数のmethodIdを指し、複数(facetにある関数分)登録することができます。
Proxy patternでは一つのImplementatioに全ての関数関数が記載されているため、Implementationのコントラクトアドレスさえ登録していれば十分でした。
しかし、Diamondの場合は、関数を管理するコントラクトは複数登録されているものの、関数を実行する受け口は一つ(diamond contract)のみであるので、関数のmethodIdを起点に、その関数が定義されているfacetのコントラクトアドレスを登録しています。
そうすることで、DiamondにmethodIdを渡すだけで、その関数が定義されているfacetのコントラクトアドレスを取得し、そのアドレスに対してdelegateCall()される仕組みになっています。
DiamondLoupeFacet
dimaondにどのようなfacetが登録されているかを確認できるコントラクト。
これを定義することで、diamond proxy 専用のexplolerで表示することができようになります。
このコントラクトのコードはDiamondのコアのロジックに直接関与しないため説明は省きます。
Facet
Facetは関数やstateを定義するコントラクトになります。
library TestLib {
bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("diamond.standard.test.storage"); ・・・①
struct TestState {
address myAddress;
uint256 myNum;
}・・・②
function diamondStorage() internal pure returns (TestState storage ds) {
bytes32 position = DIAMOND_STORAGE_POSITION;
assembly {
ds.slot := position
}
}
function setMyAddress(address _myAddress) internal {
TestState storage testState = diamondStorage();
testState.myAddress = _myAddress;
}
function getMyAddress() internal view returns (address) {
TestState storage testState = diamondStorage();
return testState.myAddress;
}
}
contract Test1Facet {
event TestEvent(address something);
function test1Func1() external {
TestLib.setMyAddress(address(this));
}
function test1Func2() external view returns (address){
return TestLib.getMyAddress();
}
function test1Func3() external {}
・・・
}
スマートコントラクトはコントラクトで定義されてる順番でstateを管理します。ただDiamond proxyの場合は、facetの追加削除を行うことが可能なのでstateを順番に行うことができず、stateの衝突を起こす可能性があります。
①でstateを管理する場所を決めて、その場所に②のstructを保存してstateを管理しています。
したがって、各facetで定義するpointer(①)は被らない必要があります。
関数の衝突についてはこちらを参考にしてください
- Upgradeability - Proxy Patternについて / 状態変数(State Variable)衝突
- Beware of the proxy: learn how to exploit function clashing
setMyAddress()
とgetMyAddress()
を見てみると、毎回diamondStorage()
でstateを取得しているのは、今までのsolidityの書き方と比べると直感的ではないですが、facetを柔軟性をあげているが故の仕様であると感じます。
おわりに
Diamond proxyは柔軟な設計にしているが故に、poroxy patternと比べると構成が複雑になっており、upgradeの際の運営オペレーションには最新の注意が必要だと感じました。
ただ、txの送受信を一つのコントラクトにまとめることができるので、upgradeの
冒頭でも記載しましたが、zkSyncのdocsを読むとより理解が深まると思います。
p.s.
Nomadはupgrade関連のコードを適切に運用できなかったためハッキングが起こりました。
詳細を解説している記事を見かけなかったのでリクエストがあれば記事書こうかと思います!
参考
- Smart Contract Security and Auditing 101
- mudgen/diamond-1-hardhat
- The Diamond Standard (EIP-2535) Explained - Part 1
- ERC-2535: Diamonds, Multi-Facet Proxy
- fantom : Diamond Proxy Pattern
- solidstate-solidity/contracts/proxy/diamond/
- Beware of the proxy: learn how to exploit function clashing
- Upgradeability - Proxy Patternについて
Discussion