可升级合约介绍- 钻石合约(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提出了三种实作方式,可以做到可升级合约,而最终的实作选用了Unstructured Storage的这个方式,这种方式对于开发较友善,开发时不需特别处理state variables(不过升级时就需要特别注意了)。而这篇主要是介绍Diamond standard,OpenZeppelin的可升级合约就不多做介绍。
钻石合约
名词介绍
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的方式处理。
https://eips.ethereum.org/EIPS/eip-2535#diagrams
在diamond的设计中,每个facet合约都是独立的,因此可以重复使用(跟library的概念一样)
https://eips.ethereum.org/EIPS/eip-2535#diagrams
小结
diamond合约使用不同的设计来达成合约的可升级性,藉由这种Hub方式可随时扩充/移除功能,让合约不再受限于24KB的限制,此外充分的模组化,让每次升级的范围可以很小。最后,因为跟library一样只处理逻辑,并无状态储存,所以可以重复被不同的diamond合约所使用。
虽然又不少好处,也是有些缺点。首先,术语名词太多,facet, diamondCut, loupe等等(其实还有好几个,不过没有介绍到那些部分,所以没有写出来)。开发上不直觉,把变数跟逻辑拆开,若要再加上合约之间的继承关系,容易搞混,不易维护。最后,gas的花费,在函式的读取、呼叫,变数的存取、传递都会有不少的额外支出。
为了模组化及弹性,diamond合约在设计上有点太复杂(over engineering),会造成可读性越差(这点也是Vyper诞生的原因之一),而可读性越差就越容易产生bug 、也越不容易抓到bug,而在defi专案中,一个小小的bug通常代表着大笔金额的损失
声明:本站所提供的资讯信息不代表任何投资暗示, 本站所发布文章仅代表个人观点,仅供参考。