EIP-2535 Diamond standard 解析与上手教程

1,400 阅读8分钟

介绍

在写复杂逻辑的solidity智能合约时,经常会碰到两个问题,升级和合约大小限制。

升级目前有几种proxy模式,通过delegatecall把数据存储和业务逻辑区分开。基本上算是能满足需求。

合约大小目前限制在24KB,这个就比较难解决,一般是使用库函数和业务拆解成多个合约,但是不是所有的业务都适合,并且拆解难度也很大,最后很可能造成代码结构过于复杂。

在这个场景下,就提出了EIP-2535。

EIP-2535的官方介绍很啰嗦,还引入了很多晦涩的术语,但实际从需求和实现的角度理解就会比较容易。

一个Proxy一个Implementation合约,那Implementation合约一样会受合约大小的限制,业务逻辑容纳有限,那我们就尝试一个Proxy有多个Implementation合约,做法就是改造fallback,之前的proxy模式都是fallback到一个固定的实现合约,Diamond把fallback函数改造成根据函数签名,把delegatecall路由到不同的Implementation合约,这样Implementation合约的数量基本就没有限制了。一个合约可以有多个实现,就像一个钻石可以有很多个切面,这就是Diamond名字的来源。

所以原理上就是通过函数签名把调用路由到不同的Implementation合约。

那我们就需要记录这个路由表,并且能有方法暴露这个路由表进行查看和管理。这个在Diamond的标准实现里是实现了一个DiamondCutFacet,这个合约会保存这个路由表,并由LibDiamond库实现了维护这张路由表的方法(增删改查)。

还有一个看起来很晦涩的概念,Loupe,原意是钻石加工中戴眼睛上那个很蒸汽朋克风的放大镜,说的很玄乎,其实就是调用路由表的读取标准接口,能让你根据签名查实现合约地址,根据合约地址查包含的函数。 主要作用就是切面管理,当你有新的切面或者切面更新时,知道哪些方法需要添加,哪些方法需要替换或者删除。

先看个例子

新建一个hardhat项目

这里强烈推荐使用hardhat,其他的框架目前看已经有些跟社区脱节。

安装hardhat

这里就啰嗦安装方法,直接看官方文档。 github.com/NomicFounda…

插件

hardhat-deploy 帮助你配置管理各种部署场景 hardhat-diamond-abi 将多个facet abi合并的工具,可以后期按需使用

初始化项目

mkdir diamond-demo
cd diamond-demo
npx hardhat

安装hardhat-deploy :

npm install -D hardhat-deploy

配置hardhat-deploy,在hardhat的配置文件hardhat.config.js中增加以下一行

require("hardhat-deploy");

合约代码: FacetA:

// SPDX-License-Identifier: UNLICENSED  
pragma solidity ^0.8.9;  
  
contract FacetA {  
  
    bytes32 constant FACET_A_STORAGE_POSITION = keccak256("diamond.standard.facet.a.storage");  
  
    struct FacetAStruct {  
        uint a;  
        uint b;  
    }  
  
    function diamondStorage() internal pure returns (FacetAStruct storage ds) {  
        bytes32 position = FACET_A_STORAGE_POSITION;  
        assembly {  
            ds.slot := position  
        }  
    }  
  
    function setA(uint _a) external {  
        diamondStorage().a = _a;  
    }  
  
    function setB(uint _b) external {  
        diamondStorage().b = _b;  
    }  
  
    function getA() external view returns (uint) {  
        return diamondStorage().a;  
    }  
  
    function getB() external view returns (uint) {  
        return diamondStorage().b;  
    }  
  
    function getAxB() external view returns (uint) {  
        return diamondStorage().a * diamondStorage().b;  
    }  
}

FacetB:

// SPDX-License-Identifier: UNLICENSED  
pragma solidity ^0.8.9;  
  
contract FacetB {  
    bytes32 constant FACET_B_STORAGE_POSITION = keccak256("diamond.standard.facet.b.storage");  
  
    struct FacetBStruct {  
        address c;  
        address d;  
    }  
  
    function diamondStorage() internal pure returns (FacetBStruct storage ds) {  
        bytes32 position = FACET_B_STORAGE_POSITION;  
        assembly {  
            ds.slot := position  
        }  
    }  
  
    function setC(address _c) external {  
        diamondStorage().c = _c;  
    }  
  
    function setD(address _d) external {  
        diamondStorage().d = _d;  
    }  
  
    function getC() external view returns (address) {  
        return diamondStorage().c;  
    }  
  
    function getD() external view returns (address) {  
        return diamondStorage().d;  
    }  
}

部署脚本: 在项目根目录下建立deploy文件夹,新建deploy_diamon_demo.js 这里小心deploy不要写错字,我经常手潮敲成depoly,然后还没有任何报错提示,很折腾人。

const { hre, ethers } = require("hardhat");  
  
module.exports = async function (hre) {  
    const { deployments } = hre;  
    const { diamond } = deployments;  
  
    const [ deployer ] = await ethers.getSigners();  
  
    await diamond.deploy("DiamondDemo", {  
        from: deployer.address,  
        log: true,  
        facets: [  
            "FacetA",  
            "FacetB",  
            ]  
    });  
}  
  
module.exports.tags = ["DiamondDemo"];

部署: 在项目根目录执行

npx hardhat deploy

这个例子简单展示了Diamond的使用,以及两个有独立存储空间的Facet实现。通过这样的方式,就可以把单个合约进行无限扩充。

概念详解

Diamon

Diamond其实就是proxy合约,负责数据存储和通过fallback函数转发函数调用到实现合约。这一点和别的proxy合约没有什么质的区别。一般我们不会再手动构造这个合约,直接使用标准合约即可。核心功能:

  1. 根据路由表执行的fallback函数
  2. 初始化Diamond
    1. 配置diamondCutFacet,给Diamond增加diamondCut方法
    2. 配置loupe,给Diamond增加loupe相关方法
    3. 配置ERC165,给Diamond增加ERC165(supportInterfaces)相关支持
    4. 配置ERC173,给Diamond增加ERC173(ownership)相关支持
// SPDX-License-Identifier: MIT  
pragma solidity ^0.8.0;  
  
/******************************************************************************\  
* Author: Nick Mudge <nick@perfectabstractions.com> (https://twitter.com/mudgen)  
* EIP-2535 Diamonds: https://eips.ethereum.org/EIPS/eip-2535  
*  
* Implementation of a diamond.  
/******************************************************************************/  
  
import {LibDiamond} from "./libraries/LibDiamond.sol";  
import {IDiamondCut} from "./interfaces/IDiamondCut.sol";  
  
contract Diamond { 
	// 这个是和Diamond标准略微不同的地方,hardhat-deploy加入了对facet的初始化支持,这样在一些特殊场景可以支持部署的事务化
    struct Initialization {  
        address initContract;  
        bytes initData;  
    }  
  
    /// @notice This construct a diamond contract    /// @param _contractOwner the owner of the contract. With default DiamondCutFacet, this is the sole address allowed to make further cuts.    /// @param _diamondCut the list of facet to add    /// @param _initializations the list of initialization pair to execute. This allow to setup a contract with multiple level of independent initialization.    constructor(  
        address _contractOwner,  
        IDiamondCut.FacetCut[] memory _diamondCut,  
        Initialization[] memory _initializations  
    ) payable {  
        if (_contractOwner != address(0)) {  
            LibDiamond.setContractOwner(_contractOwner);  
        }  
  
        LibDiamond.diamondCut(_diamondCut, address(0), "");  
  
        for (uint256 i = 0; i < _initializations.length; i++) {  
            LibDiamond.initializeDiamondCut(_initializations[i].initContract, _initializations[i].initData);  
        }  
    }  
  
    // Find facet for function that is called and execute the  
    // function if a facet is found and return any value.    
    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.selectorToFacetAndPosition[msg.sig].facetAddress;  
        require(facet != address(0), "Diamond: Function does not exist");  
        // 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())  
            }  
        }  
    }  
  
    receive() external payable {}  
}

FacetCut

对Diamond进行增删改的接口。

// SPDX-License-Identifier: MIT  
pragma solidity ^0.8.0;  
  
/******************************************************************************\  
* Author: Nick Mudge <nick@perfectabstractions.com> (https://twitter.com/mudgen)  
* EIP-2535 Diamonds: https://eips.ethereum.org/EIPS/eip-2535  
/******************************************************************************/  
  
import { IDiamondCut } from "../interfaces/IDiamondCut.sol";  
import { LibDiamond } from "../libraries/LibDiamond.sol";  
  
contract DiamondCutFacet is IDiamondCut {  
    /// @notice Add/replace/remove any number of functions and optionally execute    ///         a function with delegatecall    /// @param _diamondCut Contains the facet addresses and function selectors    /// @param _init The address of the contract or facet to execute _calldata    /// @param _calldata A function call, including function selector and arguments    ///                  _calldata is executed with delegatecall on _init    function diamondCut(  
        FacetCut[] calldata _diamondCut,  
        address _init,  
        bytes calldata _calldata  
    ) external override {  
        LibDiamond.enforceIsContractOwner();  
        LibDiamond.diamondCut(_diamondCut, _init, _calldata);  
    }  
}

Loupe

对Facet进行查看的接口

// SPDX-License-Identifier: MIT  
pragma solidity ^0.8.0;  
/******************************************************************************\  
* Author: Nick Mudge <nick@perfectabstractions.com> (https://twitter.com/mudgen)  
* EIP-2535 Diamonds: https://eips.ethereum.org/EIPS/eip-2535  
/******************************************************************************/  
  
import { LibDiamond } from  "../libraries/LibDiamond.sol";  
import { IDiamondLoupe } from "../interfaces/IDiamondLoupe.sol";  
import { IERC165 } from "../interfaces/IERC165.sol";  
  
contract DiamondLoupeFacet is IDiamondLoupe, IERC165 {  
    // Diamond Loupe Functions  
    ////////////////////////////////////////////////////////////////////    /// These functions are expected to be called frequently by tools.    //    // struct Facet {    //     address facetAddress;    //     bytes4[] functionSelectors;    // }  
    /// @notice Gets all facets and their selectors.    /// @return facets_ Facet    function facets() external override view returns (Facet[] memory facets_) {  
        LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();  
        uint256 numFacets = ds.facetAddresses.length;  
        facets_ = new Facet[](numFacets);  
        for (uint256 i; i < numFacets; i++) {  
            address facetAddress_ = ds.facetAddresses[i];  
            facets_[i].facetAddress = facetAddress_;  
            facets_[i].functionSelectors = ds.facetFunctionSelectors[facetAddress_].functionSelectors;  
        }  
    }  
  
    /// @notice Gets all the function selectors provided by a facet.    /// @param _facet The facet address.    /// @return facetFunctionSelectors_    function facetFunctionSelectors(address _facet) external override view returns (bytes4[] memory facetFunctionSelectors_) {  
        LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();  
        facetFunctionSelectors_ = ds.facetFunctionSelectors[_facet].functionSelectors;  
    }  
  
    /// @notice Get all the facet addresses used by a diamond.    /// @return facetAddresses_    function facetAddresses() external override view returns (address[] memory facetAddresses_) {  
        LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();  
        facetAddresses_ = ds.facetAddresses;  
    }  
  
    /// @notice Gets the facet that supports the given selector.    /// @dev If facet is not found return address(0).    /// @param _functionSelector The function selector.    /// @return facetAddress_ The facet address.    function facetAddress(bytes4 _functionSelector) external override view returns (address facetAddress_) {  
        LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();  
        facetAddress_ = ds.selectorToFacetAndPosition[_functionSelector].facetAddress;  
    }  
  
    // This implements ERC-165.  
    function supportsInterface(bytes4 _interfaceId) external override view returns (bool) {  
        LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();  
        return ds.supportedInterfaces[_interfaceId];  
    }  
}

storage

EVM默认是从0 slot开始给每一个变量顺序分配slot,如果想每个facet不相互干扰,可以像例子里一样,给每个facet配置独立的storage位置(struct里面字段你可以理解为是sub slot)。 如果你有想多个facet共享的存储空间,可以有多种实现方式,看你项目的具体需求,我想到的几个方式:

  1. facet直接用合约调用的方式与共享facet交互,这个调用成本应该是会高一些,但是代码逻辑会更干净。
  2. 继承。共同继承一个基类facet,基类facet中是各个facet需要共享的方法,用来对处理对共享数据的操作与访问。代码上会干净些,但是部署成本和子类facet大小就会上升

单元测试

如果facet间逻辑都是独立的,理论上单测可以跟diamond无关,把facet当作普通合约去写测试就可以了。 如果涉及多个facet联动等场景,你可能必须要把Diamond整个搭起来,这个推荐是直接把hardhat-deploy的diamond部署复用到测试脚本,不然手动部署Diamond还是相当繁琐的,类似下面的代码:

const diamondDeployResult = await deployments.run('Diamond'); // Diamond是你deploy任务的tag
const diamondAddress = diamondDeployResult.DiamondName.address; // DiamondName是你部署脚本中Diamond的name

部署/升级

一个Diamond部署/升级流程

  1. 部署DiamondFacetCut(可选)
    1. 这些基础合约我们可以使用网络上已经部署好的实例,不需要我们单独部署
    2. 如果有特殊需求可以配置使用自己的版本
  2. 部署DiamondLoupe(可选)
    1. 同上
  3. 部署OwnershipFacet(可选)
    1. 同上
  4. 部署Diamond
    1. 这里会携带第一步的合约地址来创建我们的Diamond
  5. 部署我们自己的业务Facet
  6. 将各个Facet包含的方法通过diamondCut方法注册到Diamond中。

升级:

  1. 编译新Diamond
  2. 获取已部署Diamond的所有facets,通过loupe facet的facets方法(这里就能知道为什么要有loupe facet)
  3. 比对新旧版本Diamond的facets,旧版本不存在的记为add,新旧版本facet地址不同的记为replace,旧版本中存在新版本不存在的记为remove,最终拼接成一个交易数据,可以在一个交易中完成Dimaond的升级动作。

与Gnosis Safe配合

一般生产合约我们都会把owner身份转移到Gnosis Safe或者类似的链上多签钱包进行管理,这就导致我们一般的部署流程无法完成,这里解决方法是用hardhat-deploy的cathUnknownSigner方法,捕获异常签名地址,然后把部署需要执行的交易打印到终端上,接下来就可以把交易信息复制到链上多签钱包去执行了。

await catchUnknownSigner(  
    diamond.deploy("Diamond", {  
        from: deployer,  
        owner: owner,  
        log: true,  
        facets: [  
            "FacetA",  
            "FacetB"
        ],  
    })  
);