前言
很多做区块链技术的朋友对智能合约应该是熟悉的,应该也常听到,智能合约一旦发布上链便不可更改的技术性质。
这是正确的,合约发布后,该合约本身的逻辑就固定了,无法再更改了,但我们却可以通过一些技术手段来调整合约的逻辑,从而实现可变合约的效果,本文便来简单讨论这些技术手段,主要是:多重合约与代理合约这两块。
本文会基于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这两种代理合约解决方案。
我是二两,下篇文章见。