可升级合约介绍- 钻石合约(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通常代表着大笔金额的损失

本文链接地址:https://www.wwsww.cn/jishu/8954.html
郑重声明:本文版权归原作者所有,转载文章仅为传播更多信息之目的,如作者信息标记有误,请第一时间联系我们修改或删除,多谢。