概述

正如我们在上篇博客结尾时所述,本文主要依靠openzeppelin库介绍代理合约的编写。

本文主要介绍的代理类型如下:

  • EIP-1967
  • EIP-2535

由于本文依赖于Openzeppelin/openzeppelin-contracts进行介绍EIP标准,所以请读者使用以下命令在项目内安装对应的库:

forge install Openzeppelin/openzeppelin-contracts

你可以在github仓库内找到本文所使用的全部代码。

因为此文主要使用openzeppelin编写智能合约,建议阅读以下文章:

openzeppelin的文档中,一般称逻辑合约的英文为implementation contract而不是logic contract

EIP-1967

本合约是上篇介绍的EIP-1822 UUPS的进一步标准化版本,读者可以在这里找到ERC文档。该标准被etherscan等区块链浏览器支持,可以提供完整的代理合约展示和交互功能。你可以前往USDC合约查看情况。如下图:

etherscanproxy.png

基本标准

此标准文档与EIP-1822文档大有不同。由于EIP-1822较为古老,在其文档中仍存在大量的解释性内容由于解释合约运行的原理。但在EIP-1967中,由于其创建时间较晚,合约代理的基本原理已被智能合约开发者所熟知,所以在EIP-1967的文档中没有介绍代理合约的基本原理,主要是对存储槽、事件进行了标准化和解释。在本节内容中,我们将介绍EIP-1822的基本标准和指定这些标准的原因。

首先被定义的就是逻辑合约(Logic Contract)的地址,在EIP-1822中我们一般采用keccak256("PROXIABLE")值,即0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7,该值其实可以有开发者自行决定。但在EIP-1967中,为了方便区块链浏览器的访问,该地址被标准化为keccak256('eip1967.proxy.implementation') - 1,即0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

你可以在lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol第21行中找到此地址槽的定义:

bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

在此处我们选择keccak256('eip1967.proxy.implementation') - 1的原因是为了避免潜在的攻击。如果想阅读以下的讨论,请先行阅读Understanding Ethereum Smart Contract Storage中的mapping部分。

为了方便读者理解以下内容,我将进行一次对某智能合约的假想攻击。假设当前存在一份使用keccak256("PROXIABLE")的值作为逻辑合约存储槽的代理合约。且作为攻击者的我们通过阅读逻辑合约源代码发现在逻辑合约内存在mapping数据结构。通过阅读Understanding Ethereum Smart Contract Storage,我们已知在合约内mapping数据结构存储在keccak256(key, slot)的地址内,且keyslot拼接方式已知。显然,我们可以阅读代理合约的代码或存储的状态变量得到slot的值,一般而言我们也可以通过交互合约操作key的值。如果满足上述条件,我们可以构造一种特殊的keyslot的拼接使其值等于PROXIABLE实现将逻辑合约存储槽写入特定的value。在代理合约内一般仅存在fallback等函数,一旦逻辑合约地址被改变,则资金无法转移。这是极其严重的事故。但在EIP-1967中。使用了keccak256('eip1967.proxy.implementation') - 1导致无法在简单地使用mappingkeccak256(key, slot)存储槽进行占用。除非你可以将keccak256('eip1967.proxy.implementation') - 1转换为keccak256(x)的形式。但基于哈希函数的不可逆性,我们无法计算出x的值,导致无法构造攻击用的(slot, key)

当然,该标准与EIP-1822仍存在一点不同,就是逻辑合约(Logic Contract)的地址可以为空,但前提是存在信标代理(Beacon contract)的地址存储槽不为空。我们将在下一段介绍信标代理。

同时,标准也规定了每次升级合约应给出Upgradedevent。见lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol第33行:

event Upgraded(address indexed implementation);

此处indexed的属性用于检索event日志,由于在此处我们仅涉及合约编写,所以我们不在此阐述其作用。

EIP-1967也加入了一个我们过去没有给出的合约类型——信标代理(Beacon Contract)。ERC标准规定信标代理的地址存储在bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)中,其值为0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50

我们可以在lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol中的第142行查到以下代码:

bytes32 internal constant _BEACON_SLOT = 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50;

信标代理的作用是同一逻辑合约可以实现多个代理合约共同代理。在这里给出一种情况,如果你开发了一个NFT发布合约希望为以尝鲜为目的的客户服务。但为了体现项目的区块链属性,你决定让用户可以获得智能合约地址等信息。如果使用一般的架构,你需要为每一个用户部署一份相同NFT合约。这将消耗大量的gas费,而且这些部署的NFT合约逻辑完全类似。作为开发者的我们可以考虑使用信标代理的架构,即开发一个通用逻辑的NFT合约,使用信标代理架构为其实现多个代理合约。这样部署NFT合约的费用降低为了部署一个逻辑简单的代理合约的gas费。当然如果你的项目中存在高净值用户需要复杂的NFT逻辑,你可以为其进行单独部署合约,然后改变代理合约内的地址存储槽内的信息实现合约升级。未来,此项目可能作为我们的实战内容出现在我的博客中。

上述方案可以称为BeaconProxy,此方法的基本原理是修改逻辑合约地址的获得。在以往的模式中,我们将逻辑合约地址存储在代理合约内部,但在BeaconProxy方案中,我们将逻辑合约地址单独放置在一个智能合约(下称此合约为存储合约)内,要求代理合约在每次调用逻辑合约时先去读取存储合约内的逻辑合约地址。下面给出test/EIP1967/EIP1967.t.soltestInit()栈调用:

Traces:
  [20126] ContractTest::testInit() 
    ├─ [13293] BeaconProxy::name() 
    │   ├─ [2308] UpgradeableBeacon::implementation() [staticcall]
    │   │   └─ ← NFTImplementation: [0xce71065d4017f316ec606fe4422e11eb2c47c246]
    │   ├─ [3191] NFTImplementation::name() [delegatecall]
    │   │   └─ ← "TEST"
    │   └─ ← "TEST"
    └─ ← ()

为了方便读者与常规调用进行对比,我们在此给出test/EIP1822/EIP1822.t.soltestInit():

Traces:
  [12752] ContractTest::testInit() 
    ├─ [7131] Proxy::totalSupply() 
    │   ├─ [2318] NumberStorage::totalSupply() [delegatecall]
    │   │   └─ ← 1000
    │   └─ ← 1000
    └─ ← ()

由给出的栈调用,我们可以明显看到BeaconProxy在调用真正的合约NFTImplementation前调用了UpgradeableBeacon获取了合约地址,而在常规方法中,则是直接调用了指定的逻辑合约NumberStorage,而没有在调用逻辑合约前进行获取地址的操作。当我们需要升级智能合约时,我们首先升级逻辑合约,再升级存储合约,这样依赖于存储合约的所有代理合约都可以同步升级。

当然,正如升级合约会触发事件,升级信标代理也会触发以下事件(可见lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol第147行):

event BeaconUpgraded(address indexed beacon);

同时,也规定了信标代理内必须存在以下函数(在lib/openzeppelin-contracts/contracts/proxy/beacon/IBeacon.sol实现了该接口):

function implementation() returns (address)

接口中不对函数进行定义,仅会指明该函数的存在,而由继承该接口的合约实现。当我们需要调用其他合约时,可以选择仅导入对方合约的接口,避免合约体积增大而导致gas费上升,具体的实战案例可以参考WTF solidity。在此合约中具体实现可以参考lib/openzeppelin-contracts/contracts/proxy/beacon/UpgradeableBeacon.sol第35行。

EIP-1967也规定了合约拥有者的地址存储位置(Admin address),该存储操位于bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1),即0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103,可在lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol的第106行找到定义。同时也规定了改变此存储槽中的内容必须触发下述事件:

event AdminChanged(address previousAdmin, address newAdmin);

lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol第111行进行了定义。

最终我们对上文内容进行总结。下表为EIP-1967规定的存储槽列表:

存储槽位置存储槽名称作用
bytes32(uint256(keccak256(’eip1967.proxy.implementation’)) - 1)Logic contract address存储逻辑合约地址
bytes32(uint256(keccak256(’eip1967.proxy.beacon’)) - 1)Beacon contract address存储信标代理合约地址
bytes32(uint256(keccak256(’eip1967.proxy.admin’)) - 1)Admin address存储代理合约拥有者的地址

下表为EIP-1967规定的事件列表:

事件名称事件代码触发条件
Upgradedevent Upgraded(address indexed implementation);逻辑合约地址升级
BeaconUpgradedevent BeaconUpgraded(address indexed beacon);信标代理合约升级
AdminChangedevent AdminChanged(address previousAdmin, address newAdmin);合约拥有者改变

Openzeppelin架构

代理合约的基础架构如下:

ProxySystem

此图过于简单,我们在此列出UML图:

Contract UML

在UML图中,以#开头的函数代表此函数仅能在合约内调用internal; +开头的函数或变量代表public; -开头的函数或变量代表private,即不能在合约外调用; 斜体函数名为抽象函数,即在当前合约内仅注明了函数名,我们需要在继承合约内实现。注意此图中省略了部分函数,如果想获得详细信息,请查阅文档

我们首先介绍Proxy合约,该合约的主体部分是fallback()函数,使用了下述代码:

function _fallback() internal virtual {
    _beforeFallback();
    _delegate(_implementation());
}

其中beforeFallback()代表在合约delegatecall进行前执行的函数,如果没有特殊需求,可以不使用此函数。而delegatecall()函数的代码如下:

function _delegate(address implementation) internal virtual {
    assembly {
        calldatacopy(0, 0, calldatasize())
        let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())

        switch result
        case 0 {
            revert(0, returndatasize())
        }
        default {
            return(0, returndatasize())
        }
    }
}

上述代码我们已经多次使用过,但如果你仔细对比,你会发现部分地址从0x40被替换为了0,两者基本等效,我们可以从Layout in Memory查阅到以下内容:

0x00 - 0x3f (64 bytes): scratch space for hashing methods
0x40 - 0x5f (32 bytes): currently allocated memory size (aka. free memory pointer)

0属于暂存空间的一部分,而我们直接常用的0x40则指向当前为空的内存地址。在一般情况下,0x40指向的空间就是以0开始的内存块,所以我们可以简单地认为00x40是等价的,暂时没有找到可以严格证明两者不同的文献资料。因为openzeppelin选择了0,所以我们在后文会使用此内存地址。

同时我们发现了另一个与我们之前编写的fallback函数不同的是,在openzeppelin中使用了参数implementation,该参数来自_implementation()函数。而后者为抽象函数,我们需要进行在继承合约内进行实现。在UML图中,我们可以看出ERC1967ProxyBeaconProxy都实现了此函数,但两者的实现方法不同,正是这种不同使BeaconProxy可以实现通过一次调用实现所有代理合约的升级。

ERC1967Proxy中的实现implementation如下:

function _implementation() internal view virtual override returns (address impl) {
    return ERC1967Upgrade._getImplementation();
}

//_getImplementation()来自`ERC1967Upgrade.sol`
function _getImplementation() internal view returns (address) {
    return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value;
}

为了方便读者阅读,我们将位于ERC1967Upgrade中的_implementation()函数也一并列出。StorageSlot来自utils/StorageSlot.sol,该合约中的函数主要作用是用常规的函数调用代替了assembly,你可以查阅源代码。简单分析以下就可以得到以下结论: ERC1967Proxy采用了直接在地址槽内读取逻辑合约地址的方法获取逻辑合约地址,这与ERC-1822是一致的。

BeaconProxy中的实现implementation如下:

function _implementation() internal view virtual override returns (address) {
    return IBeacon(_getBeacon()).implementation();
}

//下列函数来自`ERC1967Upgrade.sol`
function _getBeacon() internal view returns (address) {
    return StorageSlot.getAddressSlot(_BEACON_SLOT).value;
}

这里出现了一个奇特合约名称IBeacon,这是一个接口合约,接口合约仅对函数名称进行定义而不对函数体进行定义,接口合约可以用于合约的远程调用,具体可参考WTF solidity。该合约主体部分如下:

interface IBeacon {
    function implementation() external view returns (address);
}

openzeppelin下有对此接口的进行实现的合约UpgradeableBeacon.sol,其中implementation()函数如下:

function implementation() public view virtual override returns (address) {
    return _implementation;
}

该函数过于简单,作用就是返回函数内存储的_implementation的值。virtual关键词声明此函数在可以在子合约可以被重写,override关键词说明此函数重写了其母合约的virtual函数。两者出现在一起没有问题,说明此合约重写了母合约中的函数,而且允许我们通过继承此合约重写此函数。

通过上述一系列操作,最终的效果就是当代理合约进行delegatecall操作时,合约会调用_implementation()函数获取逻辑合约地址。_implementation()函数在ERC1967Upgrade进行了实现,该实现要求逻辑合约。

Beacon FlowChart

当然上述流程仅给出了获取逻辑合约地址的流程,其他流程与我们之前熟悉的EIP-1822相同。

接下来,我们分析ERC1967ProxyBeaconProxy的构造器,了解构造器对于我们进行合约部署是必要的。

首先给出ERC1967Proxy的构造器,如下:

constructor(address _logic, bytes memory _data) payable {
    _upgradeToAndCall(_logic, _data, false);
}

//下列函数来自`ERC1967Upgrade.sol`
function _upgradeToAndCall(
    address newImplementation,
    bytes memory data,
    bool forceCall
) internal {
    _upgradeTo(newImplementation);
    if (data.length > 0 || forceCall) {
        Address.functionDelegateCall(newImplementation, data);
    }
}

为方便分析,我们也将母合约中的函数一并给出。与我们之前熟悉的ERC1822的构造器基本类似,要求我们输入逻辑合约地址和使用abi编码调用的函数名。在部署时,基本与之前给出ERC1822类似,在下文介绍部署时,我们不会再讨论ERC1967Proxy的部署。

给出BeaconProxy的构造函数:

constructor(address beacon, bytes memory data) payable {
    _upgradeBeaconToAndCall(beacon, data, false);
}

//下列函数来自`ERC1967Upgrade.sol`
function _upgradeBeaconToAndCall(
    address newBeacon,
    bytes memory data,
    bool forceCall
) internal {
    _setBeacon(newBeacon);
    emit BeaconUpgraded(newBeacon);
    if (data.length > 0 || forceCall) {
        Address.functionDelegateCall(IBeacon(newBeacon).implementation(), data);
    }
}

在此合约内,我们可以看到与ERC1967Proxy结构基本类似,但是要求beacon的地址。beacon合约必须是实现IBeacon接口的合约,在日常使用中,我们可以直接使用UpgradeableBeacon合约或者个人编写的继承合约。此类合约的部署比较复杂,我们将在下文详细叙述并给出实战说明如何进行beacon合约的部署。

我们也给出ERC1967Upgrade中的其他函数作用:

  • _get类函数,注意这是一类函数,包括_getImplementation()_getAdmin()_getBeacon()函数,用于获取各个存储槽内的数据类型,建议继承后设置public函数;
  • _set类函数,这也是一类函数,主要包括_setImplementation等,用于直接改变存储槽内的数据,不建议使用;
  • _upgradeToAndCallUUPS函数,用于EIP1822函数的升级,要求原EIP1822合约内存在proxiableUUID()函数,我们之前编写的EIP1822合约是符合定义的

注意上述函数都为internal属性,只能在合约或子合约内调用,如果你想部署的代理合约可以调用这些函数,你需要通过继承ERC1967ProxyBeaconProxy合约编写public函数实现在部署后的调用。

合约编写

经过上述讨论,我们已经大致知道了openzeppelin的整体架构,接下来我们进行智能合约的代码编写。由于EIP-1967的正常模式,即直接将逻辑合约地址存储到对应的存储槽中的模式与EIP-1822类似,我们在此不进行详细讨论。我们主要介绍BeaconProxy模式。

首先,因为BeaconProxyERC1967Upgrade合约内有大量的函数属于internal类型,我们没有办法进行外部调用,所以我们需要继承BeaconProxy合约编写函数。具体代码(src/EIP-1967/proxy.sol)如下:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "openzeppelin-contracts/contracts/proxy/beacon/BeaconProxy.sol";

contract NFTProxy is BeaconProxy {
    constructor(address beacon, bytes memory data) BeaconProxy(beacon, data) {
        _changeAdmin(msg.sender);
    }

    modifier OnlyContractOwner() {
        require(msg.sender == _getAdmin(), "Not Contract Owner");
        _;
    }

    function getAdmin() public view {
        _getAdmin();
    }

    function changeAdmin(address _newAdmin) public OnlyContractOwner {
        _changeAdmin(_newAdmin);
    }

    function upgradeProxy(address newBeacon, bytes memory data)
        public
        OnlyContractOwner
    {
        _upgradeBeaconToAndCall(newBeacon, data, false);
    }
}

首先编写constructor,在原构造器的基础上增加了_changeAdmin(msg.sender);函数。该函数在构造器内运行后实现了初始化时的owner定义,在此处我们简单的将其定义为合约创建者。

OnlyContractOwner()修饰函数的作用是保证合约内部分函数仅能由合约拥有者调用。关于modifier函数的具体说明,我们在上篇已经进行了介绍。

getAdmin()changeAdmin()函数都比较简单,在此不给出具体说明。

upgradeProxy该函数为此合约内的一个核心函数,用于逻辑合约升级,该函数的主要部分是从ERC1967Upgrade中继承的_upgradeBeaconToAndCall函数,此函数的说明已在上文给出。

对于用于存储逻辑合约地址的UpgradeableBeacon合约,审查源代码可以发现openzeppelin给出了我们所需要的所有功能,所以我们不需要编写新合约进行继承。

除了上述代理合约体系所需要的工具合约外,我们也需要自行编写逻辑合约,此次使用的逻辑合约较为简单,你可以在src/EIP-1967/NFTImplementation.sol。此合约的具体代码如下:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "openzeppelin-contracts/contracts/proxy/utils/Initializable.sol";

contract NFTData {
    string public name;
    uint256 public currentTokenId;
    uint256 public totalSupply;
}

contract NFTImplementation is NFTData, Initializable {
    function initialize(
        string memory _name,
        uint256 _totalSupply
    ) public initializer {
        name = _name;
        totalSupply = _totalSupply;
    }

    function mint() public returns (uint256) {
        require(currentTokenId+1 < totalSupply, "Over Max");
        currentTokenId += 1;
        return currentTokenId;
    }
}

contract NFTImplementationUp is NFTImplementation {
    function burn() public returns (uint256) {
        currentTokenId -= 1;
        return currentTokenId;
    }
}

与我们之前编写的逻辑合约类似,此合约也选择了数据与逻辑相分离的设计思路,我们将合约所需要的数据存储在NFTData,具体操作数据的逻辑则放在NFTImplementationNFTImplementationUp中,这种设计方便我们后期进行升级。与之前不同的是初始化函数initialize函数使用了initializer修饰符。该修饰符由Initializable合约提供,可以有效避免初始化中的一系列问题,如多次初始化等。在BeaconProxy体系中,我们不建议在后续升级中重新进行初始化。重新进行初始化会导致BeaconProxy升级出现问题,我们不能仅通过改变UpgradeableBeacon中存储的逻辑合约地址实现升级,而需要调用代理合约内的upgradeProxy函数,这显然与我们的需要不同。

以上就是代理合约、信标合约和逻辑合约的所有内容,我们接下来会介绍合约部署和测试的相关情况。

合约测试

由于BeaconProxy的复杂性,合约测试有近100行,由于篇幅限制,我们不会在此处给出所有的合约测试函数的分析,你可以在这里找到完整代码。

我们给出setUp()函数的分析,setUp()函数作为初始化函数,其中的内容主要涉及合约部署部分,具体代码如下:

function setUp() public {
    NFT = new NFTImplementation();
    NFTUp = new NFTImplementationUp();
    upgrade = new UpgradeableBeacon(address(NFT));
    proxy = new NFTProxy(
        address(upgrade),
        abi.encodeWithSignature("initialize(string,uint256)", "TEST", 1000)
    );
}

大部份代码逻辑与我们熟悉的ERC1822的初始化相同,但此处值得注意的是在初始化NFTProxy,我们使用的不是逻辑合约的地址而是存储逻辑合约的UpgradeableBeacon合约的地址,这也是实现一次升级所有代理合约升级的基石。

其次,对于大家比较重要的测试是测试一次升级是否可以实现多个代理合约的直接升级,我们给出对此测试的代码:

function testMultiProxy() public {
    NFTProxy proxyNext = new NFTProxy(
        address(upgrade),
        abi.encodeWithSignature("initialize(string,uint256)", "TEST2", 1000)
    );
    uint256 ProxyNextBeforeMint = ProxyMint(address(proxyNext));       
    assertEq(ProxyNextBeforeMint, 1); 
    uint256 ProxyBeforeMint = ProxyMint(address(proxy));
    assertEq(ProxyBeforeMint, 1);

    upgrade.upgradeTo(address(NFTUp));

    uint256 ProxyNextBurnId = ProxyBurn(address(proxyNext));
    assertEq(ProxyNextBurnId, 0);  
    uint256 ProxyBurnId = ProxyBurn(address(proxy));
    assertEq(ProxyBurnId, 0); 
}

由于逻辑合约升级后增加了burn函数,所以此处我们在合约升级前调用mint铸造而在合约升级后使用burn销毁。这样既测试了合约升级后数据是否会丢失,也测试了逻辑合约是否真正升级成功。注意此测试函数中出现的ProxyMintProxyBurn都是我自行编写的,具体实现可以参考源代码,这里个函数作用就是提供代理合约调用对应的逻辑合约内的函数。

在此测试函数内实现逻辑合约升级的为upgrade.upgradeTo(address(NFTUp));,通过此行代码,我们对UpgradeableBeacon中存储逻辑合约的状态变量进行了升级,进而升级了两份代理合约。

EIP-2535💎

EIP-2535标准就是著名的钻石模型。钻石模型的基本功能如下:

  • 在合约中原子性的增加、减少或替换函数
  • 将合约内函数的增加、减少、替换通过约定的event给出
  • 通过提供合约查询公开函数的信息
  • 解决以太坊合约最大24KB的限制
  • 允许可升级函数在未来更改为不可升级函数

此模型与我们之前介绍的EIP-1967等传统代理模型不同,此模型没有采用无序存储合约地址的方法,而是在通过映射约定不同的函数和对应的合约地址,此方法属于有序存储。有序存储的特点使代理合约可以实现一个合约对应多个逻辑合约。当然,此模型也是使用delegatecall完成函数调用,合约数据仅保存在代理合约内。

facetFunction.jpeg

上图中给出了一个示意图,其中的切面(Facet)实际就是代理合约所调用的逻辑合约,此图中展示了三个逻辑合约。

由于此方法暂时没有openzeppelin的完整实现,我们需要自己编写合约的大部分内容,但我们仍会引入部分openzeppelin库以减少代码编写量。

标准内容

本节内容参考了Introduction to EIP-2535 DiamondsEIP-2535标准

在编写真正的代码前,我们需要了解EIP-2535标准的内容。本文主要介绍核心部分,即数据存储和函数部分。

数据存储

我们之前一直强调代理合约的关键在于逻辑合约地址存储和合约数据存储,在此之前我们介绍了通过继承解决合约存储问题和通过随机地址槽存储数据解决数据冲突问题。在EIP2535中,由于涉及多个逻辑合约及其对应的状态变量,我们需要一些更加复杂的机制解决此类数据冲突问题,但基本思路与我们之前介绍的方法相同。

由于本节内容仍涉及solidity底层数据的存储方式,请先阅读Understanding Ethereum Smart Contract Storage

第一种方法是继承存储(Inherited Storage),顾名思义就是通过继承解决数据存储冲突问题。这种方法我们在上文进行过实践,可以参考本文上篇的EIP-897 Proxy节中的原理实现。该方案最大的优点就是简单易用而且在业界也有广泛的使用。

但也具有以下缺点:

  1. 严重影响合约复用,你无法将当前合约部署到任何一个其他项目中。

  2. 使用钻石模型的合约一般都非常庞大,存储合约可能存在几百个状态变量,在编写合约时我们需要继承存储合约,这意味着我们必须小心翼翼地避免合约内的函数或本地变量与之前定义的状态函数名不同。我们使用的常规编辑器不会提示所有命名冲突问题,这意味我们需要自己查找项目中是否存在可能的命名冲突,这是麻烦的。

继承方案影响合约复用的原因在于继承子合约在编译时会加入继承母合约的代码。在继承存储方案中,逻辑合约在编译后会加入存储合约中的代码。这意味你将逻辑合约进行复用时就会在你原本的代理合约中加入一系列无用的状态变量,导致代理合约内数据混乱。下表给出了一种可能的情形:

地址槽代理合约继承存储合约的逻辑合约
0Test 1原存储合约内的第1个变量
1Test 2原存储合约内的第2个变量
2原存储合约内的第3个变量
3原存储合约内的第4个变量

此情况假设原本代理合约内存在Test 1Test 2两个变量; 继承存储合约的逻辑合约内有4个变量。一旦将继承存储合约的逻辑合约部署到代理合约中,就会发生严重的数据冲突问题。这导致了继承方案在合约复用性中存在严重缺陷。

第二种方法依旧是选择随机存储槽存储逻辑合约所需要的数据,此方案通常被称为Diamond Storage。与之前仅提供随机数据存储槽存储代理合约地址不同,在EIP2535中,我们需要为不同类型的逻辑合约设计存储地址以保证其数据存储不会与其他逻辑合约冲突。在具体实现上,通常采用library库合约实现存储,如下代码:

library MyStructStorage {
  bytes32 constant MYSTRUCT_POSITION = 
    keccak256("com.mycompany.projectx.mystruct");

  struct MyStruct {
    uint var1;
    bytes var2;
    mapping (address => uint) var3;
  }

  function myStructStorage()
    internal 
    pure 
    returns (MyStruct storage mystruct) 
  {
    bytes32 position = MYSTRUCT_POSITION;
    assembly {
      mystruct.slot := position
    }
  }
}

与我们之前所常见的合约不同,上述代码描述了solidity中的库library。库是一种特殊的合约类型,库需要部署但只能通过delegatecall的方式调用且库合约不能存储状态变量。当然,与一般合约不同,库合约的调用是不需要使用delegatecall关键词的,如下调用库合约的代码:

contract TestStruct {
    function myFunction(uint256 inputUint) external {
        MyStructStorage.MyStruct storage mystruct = MyStructStorage
            .myStructStorage();

        mystruct.var1 = inputUint;
    }
}

我们可以使用library.function()的形式直接调用库中的函数。上述调用库函数的代码含义为对var3进行赋值,你可以在src/EIP-2535/storageLibraryTest.sol找到完整代码。

我们给出的库合约首先声明了一个结构体MyStruct,在结构体内声明了我们需要到所有的变量。将所有变量声明在结构体内有利于我们在myStructStorage()直接操作结构体存储位置,也方便了后期在逻辑合约内直接一次性规定所有变量的存储位置。如果我们单独规定每一个变量,这意味着我们需要手动操作每一个变量的存储位置,这是很大的工作量。对于操作结构体的存储位置,在solidity中提供了slot属性进行直接调整,你可以在官方文档找到一些资料。在myStructStorage()函数中,我们使用solidity inline assembly的方法直接规定了结构体的开始存储槽位置。在此处我们规定合约结构体存储槽的开始位置为keccak256("com.mycompany.projectx.mystruct"),其中com.mycompany.projectx.mystruct可以自行规定。

在函数调用时,我们声明符合MyStructStorage.MyStruct形式的变量mystruct,并调用MyStructStorage.myStructStorage()对其进行赋值。MyStructStorage.myStructStorage()函数会返回一个存储在keccak256("com.mycompany.projectx.mystruct")位置的结构体。这与EIP1967对逻辑合约地址存储槽等进行定义类似,只不过此处定义了一个结构体。

上述描述可能过于抽象,我们可以使用forge debug进行具体的代码分析。forge debug会给出合约操作码、栈情况、内存情况和源代码四者之间的关系,在项目根目录终端中键入以下命令:

forge debug --debug src/EIP-2535/storageLibraryTest.sol:TestStruct --sig "myFunction(uint256)" 16

该命令会打开debug窗口,如下所示:

debugInit.png

自上而下依次为合约EVM操作码、EVM堆栈情况、EVM内存和合约源代码。我建议您读者进入此页面后单击mt键会显示更多信息。使用鼠标滚动即可逐步运行EVM操作码,当然也可以kj键。跟多关于键位操作可以参考Foundry 官方文档。如果你想查询所有的操作码含义可以参考此网站

如果你的终端中没有显示contract call,那说明你的代码可能被编译过,你需要在原代码中加入一些修改,如果增加空行,再次运行上述命令重新编译即可。

debug中大量的操作码用于初始化合约,我们在此不对初始化操作码进行介绍。关键在于03905a位置的操作码。

039位置时的输出如下:

039debug

039位置的操作码将结构体存储的位置75fd42e3768eaf1c351f0f1eee6ed52a2603059b48bfb1eee0baed20051c00ef,即keccak256("com.mycompany.projectx.mystruct") 推入栈。

05a位置时的输出如下:

05adebug

我们可以在栈中看到上一步压入的75fd42e3768eaf1c351f0f1eee6ed52a2603059b48bfb1eee0baed20051c00ef10。后者就是我们指定的inputUint,我们在debug命令中将其设置为16(其16进制即为10),可见我们的此处进行的数据存储是成功的。

上述给出的方法就是被称为Diamond Storage的存储方案,在下面常见的介绍钻石模型的图中,就使用了此方案。

DiamondDiagram.png

该方案也是使用最广泛的方案。我们在后文进行代码实现时会采取这种存储方案。如果读者没有特殊需求,可以选择此方案。值得注意的是,选择此方案增加状态变量只能增加到结构体的最后,否则会因为数据排序错位而导致数据冲突。

此方案的问题在于我们每次进行数据调用都需要调用一次MyStructStorage.MyStruct storage mystruct = MyStructStorage.myStructStorage();代码获得mystruct对象再进行数据修改,这是麻烦和乏味的。

在阅读EVM操作码时,你可以需要以下工具:

第三种方案是对通过继承解决数据存储冲突问题方案的改进,我们通过合约的internal标识避免变量名冲突问题。

使用此方案有以下好处:

  1. 合约可读性和组织性好
  2. 获取数据更加方便,不需要每次书写MyStructStorage.MyStruct storage mystruct = MyStructStorage.myStructStorage();获取结构体
  3. 可以与Diamond Storage方案一同使用
  4. gas费用方面更有效率
  5. 合约复用功能更好,不会出现数据冲突问题

使用此方案需要按照以下步骤: 第一步,在AppStorage.sol文件中把所有需要的存储变量到AppStorage结构体中,如下代码:

struct AppStorage {
  uint256 secondVar;
  uint256 firstVar;
  uint256 lastVar;
}

第二步,在需要使用变量的切面函数使用AppStorage internal s声明结构体。如下代码:

import "./AppStorage.sol"

contract StakingFacet {
  AppStorage internal s;

  function myFacetFunction() external {
    s.lastVar = s.firstVar + s.secondVar;
  }
}

经过以上两步就完成变量的存储和调用。如果读者后期需要升级合约,需要在struct AppStorage结构体后增加变量,不可以打乱变量排列顺序,这与继承存储方案是一致的。

我们可以混用Diamond StorageAppStorage方案。但需要在AppStorage.sol加入以下代码:

library LibAppStorage {
    function diamondStorage() internal pure returns (AppStorage storage ds) {
        assembly {
            ds.slot := 0
        }
    }
}

因为我们没有对AppStorage的地址进行设置,所以我们可以直接通过ds.slot := 0方法将其从slot 0处提取出来。

该方案在业界也有实际应用,你可以查看Aavegotchi Contracts的LibAppStorage

函数部分

为了方便下文叙述,我们在此对所使用的名词进行解释:

  1. 钻石合约(Diamond),即直接与用户交互的代理合约,是delegatecall的发起者和状态变量的存储者
  2. 切面合约(Facet),即逻辑合约,用于编写操作状态变量的函数
  3. 放大镜合约(loupe)。一个特殊的切面合约,用于返回钻石中各个切面合约的具体内容,包括切面合约地址、切面合约中的函数选择器等内容。这有一个具体的实例

EIP-2535中,最重要的函数就是diamondCut,该函数的功能是增加、修改或替换钻石合约中函数和切面合约。如果钻石合约为不可变合约,则可以不实现此函数。该函数运行后会抛出DiamondCut事件。此函数的接口如下:

interface IDiamondCut {
    enum FacetCutAction {Add, Replace, Remove}
    // Add=0, Replace=1, Remove=2

    struct FacetCut {
        address facetAddress;
        FacetCutAction action;
        bytes4[] functionSelectors;
    }

    /// @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;

    event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata);
}

标准实现

为了方便讨论,此处选择的代码来自由EIP-2535的提出者Nick Mudge编写的diamond-3。下表为该作者编写过的三个不同版本的diamond合约的对比:

ImplementationdiamondCutcomplexitydiamondCutgas costloupecomplexityloupegas cost
diamond-1lowmediummediumhigh
diamond-2highlowhighhigh
diamond-3mediumhighlowlow

*此图来自这里

diamond-3原始仓库的实现使用了pragma solidity ^0.7.6,直接使用会出现版本报错。为了解决此问题和保持教程的一致性,我推荐继续使用我的仓库,下面的讨论也是基于我的仓库。

我们首先分析较为简单的钻石合约,该合约的实现位于src/EIP-2535/Diamond.sol。此合约完成了存储的初始化和委托转发功能。

由于委托转发或称代理功能是钻石合约最重要的功能,所以我们先解释回调函数。完整代码如下:

fallback() external payable {
    LibDiamond.DiamondStorage storage ds;
    bytes32 position = LibDiamond.DIAMOND_STORAGE_POSITION;
    assembly {
        ds.slot := position
    }
    address facet = ds.selectorToFacetAndPosition[msg.sig].facetAddress;
    require(facet != address(0), "Diamond: Function does not exist");
    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())
            }
    }
}

与之前的代理合约相比,钻石合约首先通过下述代码获得钻石合约的设置数据:

LibDiamond.DiamondStorage storage ds;
bytes32 position = LibDiamond.DIAMOND_STORAGE_POSITION;
assembly {
    ds.slot := position
}

钻石合约的设置数据包含函数与合约地址的对应关系,即映射selectorToFacetAndPosition。我们可以在src/EIP-2535/libraries/LibDiamond.sol中找到对此映射的定义。

数据读取过程为首先读取结构体的结构,通过此代码 LibDiamond.DiamondStorage storage ds;, 再读取结构体的位置,通过此代码ds.slot := position。数据类型与数据存储槽结合就可以完成数据的读取。

读取获得的数据包含函数与合约地址的对应关系,我们通过address facet = ds.selectorToFacetAndPosition[msg.sig].facetAddress;获得函数对应的切面合约地址。最后通过查找的切面合约地址进行delegatecall

msg.sig是用户与合约交互时发送的函数选择器。上篇已经介绍函数选择器方面的内容

除了此核心部分,我们可以看到钻石合约也通过LibDiamond.setContractOwner(_args.owner);规定了合约拥有者。 但仅实现了ERC173接口功能,完整的接口实现可参考src/EIP-2535/facets/OwnershipFacet.sol

ERC173功能可参考接口文件src/EIP-2535/interfaces/IERC173.sol

钻石合约也初始化了ERC165的内容。ERC165用于判断合约是否实现了某个接口,允许用户花费最多3000 gas调用supportsInterface函数获得合约是否支持某接口的信息。如果支持则返回true。对于ERC165的具体实现参考src/EIP-2535/facets/DiamondLoupeFacet.sol中的supportsInterface函数。钻石合约在构造器内已经声明了支持的部分接口。

对于ERC165ERC173,我给出的信息不多,建议大家直接阅读文中给出的源代码和阅读ERC标准文档

由上文可知,在EIP535中最重要的函数就是diamondCut函数,我们在下文将着重介绍此函数。

diamond-3的实现中,diamondCut等关键函数位于src/EIP-2535/libraries/LibDiamond.sol。此库中也包括上文提到的代理设置数据。其中较为重要的数据由以下两个:

一是selectorToFacetAndPosition,此映射的功能是在已知函数的前提下,寻找对应的合约地址,具体实现如下:

mapping(bytes4 => FacetAddressAndPosition) selectorToFacetAndPosition
struct FacetAddressAndPosition {
    address facetAddress;
    uint16 functionSelectorPosition; // position in facetFunctionSelectors.functionSelectors array
}

此处给出的代码经过了调整,为了优化阅读体验,我们将FacetAddressAndPosition一并给出。

二是facetFunctionSelectors,此映射的功能是在已知地址的前提下,寻找地址内的对应函数,具体实现如下:

mapping(address => FacetFunctionSelectors) facetFunctionSelectors;
struct FacetFunctionSelectors {
    bytes4[] functionSelectors;
    uint16 facetAddressPosition; // position of facetAddress in facetAddresses array
}

我们可以看到除了直接的对应关系,结构体内还加入了functionSelectorPositionfacetAddressPosition参数用于标识参数在集合中的位置。

三是facetAddresses,即切面合约地址集合。

address[] facetAddresses;

四是FacetCutAction,该参数是枚举类型,定义在src/EIP-2535/interfaces/IDiamondCut.sol中,具体的实现如下:

enum FacetCutAction {Add, Replace, Remove}

有了以上参数,我们就可以分析具体的函数实现方式。在diamondCut中,根据FacetCutAction不同,分别使用了以下函数:

  1. addFunctions
  2. replaceFunctions
  3. removeFunctions

addFunctions需要两个参数分别为:

  1. _facetAddress 切面合约地址
  2. _functionSelectors 需要增加的函数的集合

Add function

上图给出了addFunctions的逻辑框架,但缺少了部分赋值和计算的细节。其中较难理解的是functionSelectorPositionfacetAddressPosition。前者是函数选择器在cetFunctionSelectors.functionSelectors中的位置; 后者是切面合约地址在facetAddresses中的地址。

这里的映射关系较为混乱,建议大家多读几遍源代码。对于函数存在两个存储变量,分别是facetFunctionSelectors中的bytes4[] functionSelectors;selectorToFacetAndPosition,后者通过FacetAddressAndPosition中的functionSelectorPosition记录对应函数选择器在functionSelectors中的位置。

读者可能发现enforceHasContractCode函数中存在一个特殊的汇编命令extcodesize,该汇编命令由eip-1052规定,其功能为当地址为空或不存在时返回0值。在此处,我们使用此函数用于判断切面合约是否存在。

removeFunctions所需要的参数与addFunctions相同,该函数的核心是它迭代调用的另一个函数removeFunctionremoveFunction的作用原理如下:

removeFunction

总体而言,代码复杂的地方在于多映射关系之间的互相关系。我们首先通过selectorToFacetAndPosition获得需要删除的函数的位置,然后通过facetFunctionSelectors获得此地址下functionSelectors集合最后的索引位置。由于solidity没有提供按索引删除集合元素的功能,我们只能使用pop函数删除最后一个元素。如果需要删除的函数就在对应functionSelectors的最后,我们可以直接使用pop删除。如果不在最后,我们需要使用其他手段。在代码实现中,作者通过将原来的最后函数先提取出来,使用原最后一个函数覆盖需要替换的函数。这样的话需要删除的函数就被原最后一个函数覆盖了,就可以使用pop删除。

如果删除函数后切面合约内没有存在的函数时,我们就需要删除切面合约。删除过程与删除函数的过程基本类似,也是使用了覆盖的方法,我们在此不再赘述。

removeFunctions实际上可以认为是删除函数和增加函数的联合体。读者应该可以自行阅读并理解代码。

另一个比较重要的函数是louper函数,它的接口定义在src/EIP-2535/interfaces/IDiamondLoupe.sol,具体实现可以参考src/EIP-2535/facets/DiamondLoupeFacet.sollouper定义了以下函数:

  1. facets(),返回所有切面合约的地址和切面合约内存储的函数
  2. facetFunctionSelectors(address),返回特定切面合约中的函数
  3. facetAddresses(),返回所有切面合约的地址
  4. facetAddress(bytes4),返回函数对应的切面
  5. supportsInterface(bytes4),返回合约是否支持某接口

合约编写

我们将在下文中,以diamond-3的代码为基础构建我们的钻石合约。首先,我们需要在合约中加入存储,在此处我们使用AppStorage方案进行存储。在src/EIP-2535/libraries/LibAppStorage.sol写入以下内容:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

struct AppStorage {
    string name;
    uint256 totalSupply;
    uint256 maxSupply;
}

library LibAppStorage {
    function diamondStorage() internal pure returns (AppStorage storage ds) {
        assembly {
            ds.slot := 0
        }
    }
}

其中AppStorage是核心组件,而使用LibAppStorage库是为了方便与使用DiamondStorage方案的合约一同使用,在此处,我们不会用到此函数。

接下来,我们需要修改钻石合约的部分内容,主要为数据初始化,此部分修改较少,不再解释,具体参考src/EIP-2535/Diamond.sol

注意此处我把逻辑数据的初始化放在了钻石合约中,这种方式可能与完全解耦的思想有所违背,读者可以自行修改。在此处,这样进行初始化是没有问题的。此处将所有初始化数据放在一个结构体中是为了防止栈溢出报错。我们在之前已经提及EVM规定单一solidity函数中最多有6个变量,通过结构体的组合,我们可以绕过这一限制。

然后,我们编写切面合约TestFacet,具体代码参考src/EIP-2535/facets/TestFacet.sol,代码如下:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
pragma experimental ABIEncoderV2;

import "../libraries/LibAppStorage.sol";

contract TestFacet {

    AppStorage internal s;
    
    function name() external view returns (string memory) {
        return s.name;
    }

    function totalSupply() external view returns (uint256) {
        return s.totalSupply;
    }

    function maxSupply() external view returns (uint256) {
        return s.maxSupply;
    }

    function setName(string memory _name) external {
        s.name = _name;
    } 
}

对于读者而言,此代码应该较好理解,我们在此处不再赘述。

综上所述,了解基本原理后,钻石模型的编写较为简单,我们可以在diamond-3的基础山快速构建我们需要的钻石合约。比较复杂的是钻石合约的测试和部署。

合约测试

此节代码可以参考test/EIP2535/Diamond.t.sol

在代码开头声明协议和所需要的solidity的版本后,我们进行了一系列的导入:

import "../../src/EIP-2535/interfaces/IDiamondCut.sol";
import "../../src/EIP-2535/interfaces/IDiamondLoupe.sol";
import "../../src/EIP-2535/interfaces/IERC173.sol";

import "forge-std/Test.sol";

import "../../src/EIP-2535/Diamond.sol";
import "../../src/EIP-2535/facets/TestFacet.sol";
import "../../src/EIP-2535/facets/DiamondCutFacet.sol";
import "../../src/EIP-2535/facets/DiamondLoupeFacet.sol";
import "../../src/EIP-2535/facets/OwnershipFacet.sol";

自上而下可以将导入的文件分为三部分:

  1. 接口,因为此处的调用往往需要复杂的参数,我们选择使用接口而不是call的方式进行跨合约调用
  2. forge标准库的测试合约
  3. 切面合约

setUp()函数中,我们需要将各个合约进行初始化,对于切面合约而言,初始化较为简单,只需要使用new关键词即可,如下:

cutfacet = new DiamondCutFacet();
loupefacet = new DiamondLoupeFacet();
ownerfacet = new OwnershipFacet();
testfacet = new TestFacet();

而对于钻石合约而言,我们可以看到它的构造器如下:

constructor(
    IDiamondCut.FacetCut[] memory _diamondCut,
    DiamondArgs memory _args
)

其中最为关键的参数为IDiamondCut.FacetCut[],查看IDiamondCut接口获得FacetCut结构,如下:

struct FacetCut {
    address facetAddress;
    FacetCutAction action;
    bytes4[] functionSelectors;
}

我们需要获得每个切面的地址和functionSelectors。前者可以通过address()函数获得; 后者是由切面内函数选择器组成的array,我们首先对器进行声明:

bytes4[] memory cutFunctions = new bytes4[](1);
bytes4[] memory loupeFunctions = new bytes4[](4);
bytes4[] memory ownerFunctions = new bytes4[](2);
IDiamondCut.FacetCut[] memory _diamondCut = new IDiamondCut.FacetCut[](
    3
);

关于数组声明可以参考官方文档WTFSolidity 第6讲

接下来,我们需要进行赋值操作,一个完整的案例如下:

对于我们而言,关键在于获得函数选择器。一个完整的函数选择器是对函数名和参数类型组成的字符串进行keccak256哈希计算后取前八位获得,如我们在后文会用到的transferOwnership函数,此函数在src/EIP-2535/interfaces/IERC173.sol定义,定义如下:

function transferOwnership(address _newOwner) external;

其函数选择器字符串应为transferOwnership(address),注意函数选择器字符串内不包括变量名_newOwner。获得函数选择器字符串后,我们可以在终端内允许以下命令:

cast sig "transferOwnership(address)"

输出结果为0xf2fde38b,这正是我们需要的。考虑到常用的函数是有限的,有以太坊开发者组建了一个可以根据函数选择器逆向选择器字符串的网站,我们可以在这个网站测试函数选择器输出是否正确: ethSigDb 显然,上述查询结果证明我们是正确的。当然,此功能也被集成到了cast命令中,读者可以运行以下命令:

cast 4byte 0xf2fde38b

输出为transferOwnership(address)

我们在后文均采用此种手动获得函数选择器的方法。另一种方法是使用Hardhat编写脚本自动获得,由于我们此教程不涉及Hardhat,读者可以自行参考Foundry-Hardhat-Diamonds中的genSelectors.js

对于读者而言,最难实现的函数应该是diamondCut的选择器,其定义如下:

function diamondCut(
    FacetCut[] calldata _diamondCut,
    address _init,
    bytes calldata _calldata
) external;

struct FacetCut {
    address facetAddress;
    FacetCutAction action;
    bytes4[] functionSelectors;
}

其中包含结构体FacetCut。对于结构体在函数选择器字符串中,我们需要对其进行展开,根据FacetCut的定义,展开后的结构如下(address,uint8,bytes4[])。由于此处使用了FacetCut[],故在(address,uint8,bytes4[])后需要增加[],最终整体如下:

cast sig "diamondCut((address,uint8,bytes4[])[],address,bytes)"

此处将枚举类型FacetCutAction的类型定义为uint8,是因为两者可以隐式互相转换。

获得函数选择器后,我们可以轻松对functionSelectors进行赋值,一个完整的例子如下:

cutFunctions[0] = bytes4(0x1f931c1c); //diamondCut((address,uint8,bytes4[])[],address,bytes)
_diamondCut[0] = (
    IDiamondCut.FacetCut({
        facetAddress: address(cutfacet),
        action: IDiamondCut.FacetCutAction.Add,
        functionSelectors: cutFunctions
    })
);

为了方便读者核对函数选择器,我将函数选择器的字符串以注释的形式进行了附注

在此处我们基本解决了diamond部署中最难的问题,所以我们不在给出setUp()函数中的其他部分,读者可自行查阅仓库

在合约测试中,为了优化合约测试代码数量,大量使用了接口进行函数调用,接口的使用方法如下:

IERC173(address(diamond)).owner();

较为简单,不再详细说明。

部署与升级

由于此次合约部署较为复杂,我们使用script的方式进行合约部署,对于合约的部署而言,与测试中的setUp函数基本一致,读者可以自行查看script/EIP-2535/Diamond.s.sol中的SetupScript合约。编写完成后,使用以下命令进行部署:

source .env
forge script script/EIP-2535/Diamond.s.sol:SetupScript --private-key $LOCAL_ACCOUNT --broadcast --rpc-url http://127.0.0.1:8545

对于已部署的钻石合约进行DiamondCut操作,直接使用函数较难填写参数,理论上我们需要自行构造calldata,然后进行调用,较为复杂。如果选择部署合约后进行升级,这也不太合适。通过script操作,我们可以简化操作,具体代码如下:

contract UpdateScript is Script {
    function run() external {
        vm.startBroadcast();
        TestFacet testfacet = new TestFacet();
        bytes4[] memory testFunctions = new bytes4[](3);
        IDiamondCut.FacetCut[] memory _testDiamondCut = new IDiamondCut.FacetCut[](1);

        testFunctions[0] = bytes4(0x06fdde03); //name
        testFunctions[1] = bytes4(0x18160ddd); //totalSupply
        testFunctions[2] = bytes4(0xd5abeb01); //maxSupply

        _testDiamondCut[0] = (
            IDiamondCut.FacetCut({
                facetAddress: address(testfacet),
                action: IDiamondCut.FacetCutAction.Add,
                functionSelectors: testFunctions
            })
        );
        
        IDiamondCut(address(0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9)).diamondCut(
            _testDiamondCut,
            address(0x0),
            new bytes(0)
        );
        vm.stopBroadcast();
    }
}

上述代码可以实现对钻石合约进行diamondCut操作。使用以下命令启动:

forge script script/EIP-2535/Diamond.s.sol:UpdateScript --private-key $LOCAL_ACCOUNT --broadcast --rpc-url http://127.0.0.1:8545

此处0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9为钻石合约地址,读者可以根据实际情况自行替换

总结

本篇文章以上下篇的形式介绍了以下标准:

  • EIP-897 Proxy
  • EIP-1822 UUPS
  • EIP-1967
  • EIP-2535

如果读者不需要部署非常大的合约,我建议使用EIP-1967作为首选。因为它的结构简单、易于部署、标准化程度高且有etherscan的支持。合约开发过程中,我们可以依靠openzeppelin的框架,对于开发者十分友好。而且还有beacon这种模型支持多代理开发,方便平台为客户提供标准化合约部署服务。

如果读者需要编写合约非常复杂,可以使用EIP-2535钻石模型,该模型显然非常适合大规模合约代理,但另一方面其部署难度非常高,代码编写具有一定的复杂性,存储模型不直观,较为考验开发者的开发能力。

本文没有涉及到全部的代理模型,且本文是以以太坊标准为主线展开。读者如果想进一步学习,openzeppelin blog中的Contract Upgrade是一份非常好的材料。本文可能会在后期增加补充文章,可以订阅本博客的RSS