最近由于工作需要,在学习Defi另一大明星项目:Compound。今天学习的是Unitroller和Comptroller之间的委托调用关系以及怎么升级Comptroller合约。然而自己在模仿Compound的方法写了一个很小的合约进行验证测试时却出现了问题,总是数据不对或者交易出错重置。啊哈,我被这个委托调用给卡住了!!!不过学习智能合约我也会被卡住?简直太不可思议了!(开玩笑的,任何人都可能被卡住,并且学习是无止境的)。
镇定下来,我尝试了一下Compound的原版合约,是没有问题的。看来问题出在我的测试合约上。卡住不可怕,可怕的是跳过它而不弄明白(个人觉得大致弄明白就行)。于是,反复测试开始了。
需要提前说明的是:在Solidity中可以采用委托调用的方式来执行第三方合约的逻辑,而上下文环境(包括状态变量)却是本合约的。它可以用来实现某些特定功能,例如升级控制合约。
在Compound中,Unitroller 顾名思义叫统一调度合约,它是Compound的风险控制模块,所有的市场都引用了它。但是它的内部却没有那些控制方法的具体实现,具体实现都在Comptroller里。Unitroller通过委托调用(delegatecall)来调用Comptroller中相应的方法,执行相应的逻辑。这样当需要升级调度合约时,只是简单的更新Unitroller的具体实现合约(Comptroller)地址即可。
刚才说过了,委托调用时改变的是委托方的状态变量,有人会说这还不简单,改变原合约的同名状态变量不就完了。事情真的有这么容易么?我们来实际测试一下,下面是测试合约:
pragma solidity ^0.5.0;
contract Target {
uint public x;
function setX(uint _x) external {
x = _x;
}
function getX() external view returns(uint) {
return x;
}
}
contract Source {
address public implement;
uint public x = 3;
constructor(address _implement) public {
implement = _implement;
}
function setImplement(address _implement) public {
implement = _implement;
}
function getImplement() public view returns(address) {
return implement;
}
/**
* @dev Delegates execution to an implementation contract.
* It returns to the external caller whatever the implementation returns
* or forwards reverts.
*/
function () payable external {
// delegate all other functions to current implementation
(bool success, ) = implement.delegatecall(msg.data);
assembly {
let free_mem_ptr := mload(0x40)
returndatacopy(free_mem_ptr, 0, returndatasize)
switch success
case 0 {
revert(free_mem_ptr, returndatasize) }
default {
return(free_mem_ptr, returndatasize) }
}
}
}
我们接下来的测试都是在此合约上修修改改。合约中最后的function ()
函数是直接复制的Compound中的实现,它的作用在于当匹配不到相应的函数(找不到函数选择器或者methodID)时,执行该函数。如果没有定义该函数,找不到匹配函数时就会报错。在该函数内部,它将调用信息(msg.data)直接传递给实现合约来委托调用。
合约有了,还少测试脚本。本测试还是使用Truffle+ Ganache来本机部署测试,部署很简单,就不写了,测试脚本为:
const Source = artifacts.require("Source");
const Target = artifacts.require("Target");
module.exports = async (deployer) => {
let instanceS = await Source.deployed();
let instance = await Target.at(instanceS.address);
let x = await instance.getX()
console.log("x:",x) //读取委托方的插槽0,实质上为implement的值(地址类型)
let xs = await instanceS.x()
console.log("xs:",xs.toNumber()) //3 正常调用,也是读取委托方的变量X
await instance.setX(5) //设置委托方的插槽0,会失败。
x = await instance.getX()
console.log("x:",x.toNumber()) // 读取委托方的插槽0
// xs = await instanceS.x()
// console.log("xs:",xs.toNumber()) //5 正常调用,也是读取委托方的插槽0
};
注:本测试脚本和部署脚本一样,单独放在一个js文件中在部署时统一执行。
该脚本执行到await instance.setX(5)
时就会失败,从上面的测试中可以看出,Target合约的状态变量x
和Source合约的状态变量x
没有任何关系,状态变量在合约内部是以插槽存储的,变量只是映射到插槽位置。
我们将Source合约中的x
变量与implement
变量换个位置,变成:
uint public x = 3;
address public implement;
再次修改上面的脚本并重新调用:
module.exports = async (deployer) => {
let instanceS = await Source.deployed();
let instance = await Target.at(instanceS.address);
let x = await instance.getX()
console.log("x:",x.toNumber()) //3 读取委托方的插槽0,
let xs = await instanceS.x()
console.log("xs:",xs.toNumber()) //3 正常调用,也是读取委托方的变量X
await instance.setX(5) //设置委托方的插槽0,
x = await instance.getX()
console.log("x:",x.toNumber()) // 5 读取委托方的插槽0
xs = await instanceS.x()
console.log("xs:",xs.toNumber()) //5 正常调用,也是读取委托方的插槽0
};
//输出结果依次为3,3,5,5
从输出结果可以看到,我们委托调用时修改的状态变量和合约本身的状态变量已经对上号了。
我们知道,委托调用使用原合约的上下文环境,如果写的状态变量原合约没有,怎么办?这里上面讲到了,两个合约内的状态变量名称没有任何关系,只是使用插槽位置对应。所以你将Source合约中的状态变量换个名,不会有任何影响(当然会影响可阅读性)。我们来尝试一下,将Source合约中的x
改名叫y
,同时修改脚本如下:
module.exports = async (deployer) => {
let instanceS = await Source.deployed();
let instance = await Target.at(instanceS.address);
let x = await instance.getX()
console.log("x:",x.toNumber()) //3 读取委托方的插槽0,
let ys = await instanceS.y()
console.log("ys:",ys.toNumber()) //3 正常调用,也是读取委托方的变量y
await instance.setX(5) //设置委托方的插槽0,
x = await instance.getX()
console.log("x:",x.toNumber()) // 5 读取委托方的插槽0
ys = await instanceS.y()
console.log("ys:",ys.toNumber()) //5 正常调用,也是读取委托方的插槽0
};
//输出结果仍然为3,3,5,5
重新编译部署并测试,输出结果仍然和上面一样,可见变量名称不会有任何影响。
到这里,Compound中的委托调用就很清楚了。由于Unitroller合约与Comptroller合约均继承了一个相同的合约,所以它们前面的状态变量(插槽编号与存储类型)是完全相同的。而Unitroller除此之外没有别的状态变量了,因此Comptroller中其它状态变量从下一个插槽位置(实际为5)开始保存。更新升级Comptroller后,只要新的Comptroller合约中的状态变量定义的顺序及类型未变,它就不会对原有存储插槽造成影响,更新后的Unitroller合约就能正常运行。
结论:委托调用时,虽然被委托方操作的是委托方的状态变量(底层为插槽),但其插槽位置是被委托方的。因此要让两个合约相应的状态变量位置与类型完全相同,避免冲突。一般正式应用时,要么两者的状态变量完全相同,要么被委托方完全包含委托方的状态变量(类型与顺序也要一致),相同的状态变量建议使用相同的名称,防止造成阅读上的混乱。
上面就是今天的学习所得,由于个人能力有限,难免会出现理解或者认知错误,还请大家指正。