可升級合約介紹 - 鑽石合約(EIP-2535 Diamond standard)
前言
可升級合約簡單來說是透過 proxy contract(代理合約)來達成,藉由代理合約去呼叫欲執行的合約,若要升級,則把代理合約中的指向的地址換為新的合約地址即可。而執行的方式則是透過 delegateCall,但 delegateCall 不會更動目標合約的狀態。所以要怎麼處理變數,就是一門學問了。
舉例來說,contract B 有個變數 uint256 x,初始值為 0, 而 function setX(uint256),可以改變 x 的值。proxy contract A 使用 delegatecall 呼叫 contract B 的 setX(10),交易結束後,contract B中的 x 依然還是 0。
OpenZeppelin 提出了三種實作方式,可以做到可升級合約,細節可參考 Proxy Patterns,而最終的實作選用了 Unstructured Storage
的這個方式,這種方式對於開發較友善,開發時不需特別處理 state variables(不過升級時就需要特別注意了)。而這篇主要是介紹 Diamond standard,OpenZeppelin 的可升級合約就不多做介紹。
USDC V2 : Upgrading a multi-billion dollar ERC-20 token 詳細地介紹代理合約跟變數儲存之間的關係,不了解升級合約的原理,建議先看看。
鑽石合約
名詞介紹
diamond
:合約本體,是一個代理合約,無商業邏輯facet
:延伸的合約(實際商業邏輯實作的合約)loupe
:也是一個 facet,負責查詢的功能。可查詢此diamond
所提供的facet
與facet
所提供的函式diamondCut
:一組函式,用來管理(增加/取代/減少)此diamond
合約所支援的功能
Loupe
直接來看 loupe
的介面,從宣告就能很清楚暸解 diamond
合約的實作方式,loupe
宣告了一個結構 Facet
,Facet
結構包含一個地址及 function selector 陣列,所以我們只需要記錄一個 Facet
陣列就可以得知這個 diamond 合約有多少個延伸合約及所支援的功能(loupe
只定義結構,而實際變數是存在diamon合約中的)。也就是 diamond
合約中只記錄延伸合約的地址及其支援的 function selectors,及少數 diamond
合約的管理邏輯,並無商業邏輯,因此可以外掛非常非常多的合約上去(就像一個Hub),也就可以突破一個合約只有24K的限制。
// A loupe is a small magnifying glass used to look at diamonds.
interface IDiamondLoupe {
struct Facet {
address facetAddress;
bytes4[] functionSelectors;
}
function facets() external view returns (Facet[] memory facets_);
function facetFunctionSelectors(address _facet) external view returns (bytes4[] memory facetFunctionSelectors_);
function facetAddresses() external view returns (address[] memory facetAddresses_);
function facetAddress(bytes4 _functionSelector) external view returns (address facetAddress_);
}
DiamondCut
至於 facet
在 diamond
合約上的註冊或是修改,就由 diamondCut
負責,從以下程式碼可以清楚瞭解其功能(EIP中有規範,每次改變都需要發送DiamondCut
事件)
interface IDiamondCut {
enum FacetCutAction {Add, Replace, Remove}
// Add=0, Replace=1, Remove=2
struct FacetCut {
address facetAddress;
FacetCutAction action;
bytes4[] functionSelectors;
}
function diamondCut(
FacetCut[] calldata _diamondCut,
address _init,
bytes calldata _calldata
) external;
event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata);
}
Diamond合約
接下來就是最核心的部分 — diamond
本體合約。以下是官方的範例,方法上跟 OpenZeppelin 一樣使用 fallback 函式跟 delegateCall 。
呼叫合約所不支援的函式,就會去執行 fallback 函式,fallback 函式中再透過 delegateCall 呼叫 facet 合約相對應的函式
fallback() external payable {
address facet = selectorTofacet[msg.sig];
require(facet != address(0));
// Execute external function from facet using delegatecall and return any value.
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 {revert(0, returndatasize())}
default {return (0, returndatasize())}
}
}
主要的差異在於變數的處理,OpenZepplin 是針對單一合約設計的代理合約(也就是每個合約都有自己的代理合約),所以無法處理單一代理合約儲存多個合約的變數(state variables)的狀況(後有圖例)。先由官方的範例程式來了解是怎麼處理變數的
在官方的範例中,都是以更改合約 owner 為例子
首先看到 DimaondStorage
這個結構,結構中的前面三個變數都是在維持 diamond
合約的運作(同上面loupe
的範例),最後一個變數 contractOwner
就是我們商業邏輯中所需的變數。
接著看到 function diamondStorage()
,取變數的方式就跟OpenZeppelin 儲存特定變數方式一樣(EIP-1967),是把變數存到一個遠方不會跟其他變數碰撞到的位置,在這裡就是從 DIMOND_STORAGE_POSITION
這個 storage slot 讀取。
在實作上就可以有 LibDiamond1
,宣告DIMOND_STORAGE_POSITION1=keccak256("diamond.standard.diamond.storage1")
,負責處理另一組的變數。藉由這種方式讓每個 facet
合約有屬於自己合約的變數, facet
合約間就不會互相影響。而最下方的 setContractOwner
是實際使用的範例。
library LibDiamond { bytes32 constant DIAMOND_STORAGE_POSITION =
keccak256("diamond.standard.diamond.storage"); struct FacetAddressAndSelectorPosition {
address facetAddress;
uint16 selectorPosition;
} struct DiamondStorage {
mapping(bytes4 => FacetAddressAndSelectorPosition)
facetAddressAndSelectorPosition;
bytes4[] selectors;
mapping(bytes4 => bool) supportedInterfaces;
// owner of the contract
address contractOwner;
} function diamondStorage() internal pure returns
(DiamondStorage storage ds)
{
bytes32 position = DIAMOND_STORAGE_POSITION;
assembly {
ds.slot := position
}
} function setContractOwner(address _newOwner) internal {
DiamondStorage storage ds = diamondStorage();
address previousOwner = ds.contractOwner;
ds.contractOwner = _newOwner;
emit OwnershipTransferred(previousOwner, _newOwner);
}
每個 library 處理了一組或多組變數的存取, facet
合約透過 library 對變數做操作。也就是把變數存在diamond
主體合約,延伸的 facet
合約只處理邏輯,是透過 library 去操作變數。
下面圖中清楚地解釋了 facet
合約,function selectors 與變數之間的關係,從最左上這邊有個 facets 的 map,紀錄了哪個 selector 在哪個合約中,例如func1, func2是 FacetA
的函式。左下角宣告了變數,每組變數的存取如同上述 library 的方式處理。
在 diamond
的設計中,每個 facet
合約都是獨立的,因此可以重複使用(跟library 的概念一樣)
小結
diamond
合約使用不同的設計來達成合約的可升級性,藉由這種Hub方式可隨時擴充/移除功能,讓合約不再受限於24KB的限制,此外充分的模組化,讓每次升級的範圍可以很小。最後,因為跟library一樣只處理邏輯,並無狀態儲存,所以可以重複被不同的diamond
合約所使用。
雖然又不少好處,也是有些缺點。首先,術語名詞太多,facet, diamondCut, loupe等等(其實還有好幾個,不過沒有介紹到那些部分,所以沒有寫出來)。開發上不直覺,把變數跟邏輯拆開,若要再加上合約之間的繼承關係,容易搞混,不易維護。最後,gas的花費,在函式的讀取、呼叫,變數的存取、傳遞都會有不少的額外支出。Trail of Bits 專欄中有點出更多的缺陷 Good idea, bad design: How the Diamond standard falls short,不過作者也有反擊 Addressing Josselin Feist’s Concern’s of EIP-2535 Diamond Standard,有興趣的讀者可以自行看看、比較。
為了模組化及彈性,diamond合約在設計上有點太複雜(over engineering),會造成可讀性越差(這點也是Vyper誕生的原因之一),而可讀性越差就越容易產生bug、也越不容易抓到bug,而在defi專案中,一個小小的bug通常代表著大筆金額的損失 😱😱😱。
雖然如此,筆者還是覺得很酷,有些設計的思維仍然可以使用在自己的專案