前言

很多做区块链技术的朋友对智能合约应该是熟悉的,应该也常听到,智能合约一旦发布上链便不可更改的技术性质。

这是正确的,合约发布后,该合约本身的逻辑就固定了,无法再更改了,但我们却可以通过一些技术手段来调整合约的逻辑,从而实现可变合约的效果,本文便来简单讨论这些技术手段,主要是:多重合约与代理合约这两块。

本文会基于Remix IDE来给出合约的效果,你可以基于【搭建Remix IDE本地开发环境】一文,搭建与我一样的开发环境。

写到一半,发现内容比较多,分成上、下两篇来发,上篇主要讨论多重合约这种实现方式。

多重合约

合约间的调用

要比较好的理解多重合约,你需要理解合约间是如何相互调用这一基础知识。

这里,我们开启Remix IDE,写一个简单合约逻辑。

// SPDX-License-Identifier: MITpragma solidity ^0.8.4;contract ContractA {  uint256 public x = 0;  event TransferLog(address sender_addr, uint amount, uint gas);  function add_x(uint256 _x) external payable {      x = _x;      if (msg.value > 0) {          emit TransferLog(msg.sender, msg.value, gasleft());        }    }  function getBalance() view public returns(uint) {      return address(this).balance;    }}

这个合约里有一个公共变量x以及一个事件TransferLog,用于记录转账信息(事件的主要作用就是将链上数据同步到链下),此外还有2个函数:

  • add_x():设置了external payable的函数,这个函数会接受_x参数设置公共变量x,此外因为有payable关键字,所以这个方法也可以实现转账效果。

  • getBalance():获得当前合约ETH余额。

将其部署一下,可以发现,ContractA的ETH余额为0,公共变量x也为0。

接着,我们在实现一个名为CallContract的合约,其主要作用就是调用ContractA合约,代码如下:

// SPDX-License-Identifier: MITpragma solidity ^0.8.4;contract CallContract {    function call_add_x(address contract_addr, uint256 _x) payable external {        // 实例化ContractA,并调用add_x函数,设置公共变量x的同时进行转账操作        ContractA(contract_addr).add_x{value: msg.value}(_x);    }}

在CallContract合约中,我们定义了call_add_x函数,该函数会接受ContractA合约的地址和_x变量,因为ContractA中的add_x方法是payable的,为了可以进行转账操作,call_add_x函数也需要通过payable关健字声明一下。

部署一下CallContract合约,然后将ContractA的地址复制,传入call_add_x函数,并将10 wei转账给ContractA,如下图:

然后再看回ContractA,ETH余额变成了10 wei,公共变量x也变成了666。

多重合约

多重合约其实就是利用合约间相互调用的技术形式来实现的,那用多重合约和我用普通合约之间有什么差异呢?或者,更直接点,用多重合约有什么好处?

举一个具体的例子。

假设我们在弄一个NFT项目,我们设计了白名单的玩法,用户要完成某些操作,才能将地址加入到合约的白名单列表中,在白名单列表中的用户才能Mint NFT。因为项目还OK,已经有500个用户完成了NFT mint操作,但此时,我们发现白名单的逻辑有点问题。

如果白名单和Mint的逻辑在同一个合约,就无法单独更新白名单的逻辑,如果一定要更新,就必须重新弄个合约,这样的话,之前500个已经mint NFT的用户就需要给他们退款。

如果将白名单逻辑单独放在了一个合约中,白名单逻辑出问题则替换一下白名单逻辑则可,原本已经mint NFT的500个用户,不需要动(因为mint 合约不需要调整)。

为了直观,这里我写一段合约逻辑模拟一下上述逻辑,首先,弄个白名单合约:

// SPDX-License-Identifier: MITpragma solidity ^0.8.4;contract WhiteListContract {    address owner;    mapping(address => bool) public isWhiteListed;    constructor() {        owner = msg.sender;    }    modifier onlyOwner {        require(msg.sender == owner);        _;    }    function addWhiteList(address addr) external onlyOwner {        isWhiteListed[addr] = true;    }    function getWhiteListStatus(address addr) external view returns(bool) {        return isWhiteListed[addr];    }}

WhiteListContract合约中有owner变量和isWhiteListed变量,owner变量用于记录合约部署者的地址,其主要目的是配合onlyOnwer这个modifier使用,而isWhiteListed变量则用于记录哪些地址被添加进了白名单。

此外,WhiteListContract合约中还有addWhiteList函数,用于将用户地址添加进白名单,和getWhiteListStatus函数,用于判断地址是否在白名单中。

如果你有看过USDT的源码,会发现,我写的这个WhiteListContract合约,其实就是抄USDT合约中黑名单逻辑。

部署WhiteListContract合约,然后测试一下。

通过addWhiteList函数将当前用户地址添加到白名单中,然后通过getWhiteListStatus函数可以获得ture的结果。

如果是没有添加过的白名单的地址,getWhiteListStatus函数将返回False。

然后我们实现一个合约来模拟给用户发送代币的过程,只有白名单中的地址才能获得代币,代码如下:

contract ContractMain{    mapping(address => uint256) addr_token;    function add_token(address addr, uint256 token) public returns(bool) {        // 实例化WhiteListContract合约,判断当前地址是否在白名单中        if (WhiteListContract(addr).getWhiteListStatus(msg.sender)) {            addr_token[msg.sender] += token;            return true;        } else {            return false;        }    }    function get_addr_token() public view returns(uint256) {        return addr_token[msg.sender];    }}

ContractMain合约的逻辑简单,通过add_token函数向用户地址发token,但发之前会判断一下,地址是否在白名单内,如果在,则可以发送成功,如下:

如果地址不在白名单中,则无法转账。

如果此时,白名单的逻辑有问题,重新开发白名单合约,add_token函数中,将新的白名单合约的地址传入则可。

细心的你,应该注意到了,如果我们换一个白名单合约,旧白名单合约中已经加入白名单的这些地址数据,将会丢失。

多重合约在实际项目中的使用

虽然我感觉自己构造的例子很不错了,但为了让大家更直观的理解,我找了一个使用了多重合约的真实项目:CloneX,地址:https://etherscan.io/token/0x49cf6f5d44e70224e2e23fdcdd2c053f30ada28b

这是一个NFT的项目,我们看到它的合约代码,看到它的mint逻辑:mintTransfer函数,如下图:

mintTransfer函数会使用_safeMint函数实现NFT的mint,但要调用成功,需要过它的require校验,这里的require校验会判断当前调用者是否为mintvialAddress。

嗯,为了方便你理解,我先说一下CloneX项目使用多重合约的方式。

通常,mint NFT过程是类似的,说白了,即是记录一下值,将地址与NFT关联起来,CloneX项目将这个逻辑写在clonex.sol中(即上图),但项目方将分发的逻辑写到了另外的合约,这个合约会通过clonex.sol合约的地址来调用clonex.sol合约中的mintTransfer函数。

这样,mint的数据会留到clonex.sol中,但项目方可以修改分发逻辑,比如玩法变了,项目方更新一下分发逻辑的合约,再将clonex.sol关联到新的合约,则可以实现玩法更新,但mint过的用户,数据依旧在。

怎么找到分发逻辑的合约呢?突破口就是mintvialAddress,在clonex.sol中,可以找到setMintvialAddress函数,用于设置mintvialAddress。

按经验,这里应该会是一个合约地址,如果是普通的钱包地址,那就是让人手动操作来完成分发,这是不合理的(谁会让人来一个个帮用户mint呢…所以必然是合约地址了)。

我们需要找到调用了setMintvialAddress函数的地方,因为该函数有onlyOwner,所以必然是当前合约的创建地址调用的。

找到创建地址

找到函数对于的函数选择器,即下图中的 0xad6c9962

所谓函数选择器其实就是函数签名hash后的前4个字节,solidity会基于函数选择器来匹配用户调用的函数。

我们可以写一段代码来验证一下函数选择器:

// SPDX-License-Identifier: MITpragma solidity ^0.8.4;contract test {    function get_function_selector() public pure returns(bytes4) {        return bytes4(keccak256("setMintvialAddress(address)"));    }}

基于函数选择器搜索一下,便可以发现调用setMintvialAddress方法的交易。

进入交易细节,查看传入的参数,可以发现是个合约地址,这便是CloneX项目实现分发逻辑的地方。

查看这个合约的代码,搜索mintTransfer函数,看看它是怎么被调用的,如下图:

它实例化了clonex.sol合约,然后调用了其中的mintTransfer函数,实现NFT的mint,嗯,一个典型的多重合约调用形式,基于这种形式,如果玩法变了,换一个玩法合约,再关联一下clonex.sol则可。

结尾

嗯,多重合约大概就这样,下篇文章,我们会重点讨论代理合约,以及与其相关的透明代理和UUPS这两种代理合约解决方案。

我是二两,下篇文章见。