介绍
在写复杂逻辑的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合约没有什么质的区别。一般我们不会再手动构造这个合约,直接使用标准合约即可。核心功能:
- 根据路由表执行的fallback函数
- 初始化Diamond
- 配置diamondCutFacet,给Diamond增加diamondCut方法
- 配置loupe,给Diamond增加loupe相关方法
- 配置ERC165,给Diamond增加ERC165(supportInterfaces)相关支持
- 配置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共享的存储空间,可以有多种实现方式,看你项目的具体需求,我想到的几个方式:
- facet直接用合约调用的方式与共享facet交互,这个调用成本应该是会高一些,但是代码逻辑会更干净。
- 继承。共同继承一个基类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部署/升级流程
- 部署DiamondFacetCut(可选)
- 这些基础合约我们可以使用网络上已经部署好的实例,不需要我们单独部署
- 如果有特殊需求可以配置使用自己的版本
- 部署DiamondLoupe(可选)
- 同上
- 部署OwnershipFacet(可选)
- 同上
- 部署Diamond
- 这里会携带第一步的合约地址来创建我们的Diamond
- 部署我们自己的业务Facet
- 将各个Facet包含的方法通过diamondCut方法注册到Diamond中。
升级:
- 编译新Diamond
- 获取已部署Diamond的所有facets,通过loupe facet的facets方法(这里就能知道为什么要有loupe facet)
- 比对新旧版本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"
],
})
);