call
和 delegatecall
是 Solidity 中调用外部合约的方法,但是它俩却有挺大的区别。假设 A 合约调用 B 合约,当在 A 合约中使用 call
调用 B 合约时,使用的是 B 合约的上下文,修改的是 B 合约的内存插槽值。而在如果在 A 合约中使用 delegatecall
调用 B 合约,那么在 B 合约的函数执行过程中,使用的是 A 合约的上下文,同时修改的也是 A 合约的内存插槽值。这么说有些抽象,我们来看一个简单的示意图:
从上面的图中我们可以看出,在使用 call
调用时,B 合约使用的上下文数据均是 B 本身的。而当使用 delegatecall
调用时,B 合约使用了 A 合约中的上下文数据。我们来写段代码测试一下:
pragma solidity 0.8.13;
contract A {
address public b;
constructor(address _b) {
b = _b;
}
function foo() external {
(bool success, bytes memory data) =
b.call(abi.encodeWithSignature("foo()"));
require(success, "Tx failed");
}
}
contract B {
event Log(address sender, address me);
function foo() external {
emit Log(msg.sender, address(this));
}
}
上面代码中,我们在 A 合约中使用 call
调用 B 合约,通过 Log
事件记录一些信息。先部署 B 合约,然后将其地址作为参数部署 A 合约,接着我们调用 foo
函数,可以获取到 Log
事件的内容为:
与我们前面的说的规则一致,使用 call
调用时,使用的是 B 本身的上下文。接下来我们将 call
改成 delegatecall
:
function foo() external {
(bool success, bytes memory data) =
b.delegatecall(abi.encodeWithSignature("foo()"));
require(success, "Tx failed");
}
再来看看执行结果:
可以看到当使用了 delegatecall
调用时,使用了 A 合约的上下文。
上面我们还提到,当使用 delegatecall
时,修改的是调用合约的内存插槽值,这是什么意思呢,我们来看一个例子:
pragma solidity 0.8.13;
contract A {
uint256 public alice;
uint256 public bob;
address public b;
constructor(address _b) {
b = _b;
}
function foo(uint256 _alice, uint256 _bob) external {
(bool success, bytes memory data) =
b.delegatecall(abi.encodeWithSignature("foo(uint256,uint256)",
_alice, _bob));
require(success, "Tx failed");
}
}
contract B {
uint256 public alice;
uint256 public bob;
function foo(uint256 _alice, uint256 _bob) external {
alice = _alice;
bob = _bob;
}
}
这段代码中,我们使用 delegatecall
来调用 foo
函数,foo
函数的作用是给 B 合约的两个变量赋值。但是实际调用后的结果是,A 合约的两个变量被赋值,而 B 中的变量仍为空。这就是我们前面说的,delegatecall
会修改调用合约的内存插槽值,我们来看一个图示:
在 A 合约中有三个状态变量,B 合约中有两个状态变量。当 A 合约使用 delegatecall
调用 B 合约时,对 B 合约状态变量的赋值会通过插槽顺序分别影响 A 合约的各个变量。也就是说,对 B 合约插槽 0 的变量 alice
赋值,实际上是把值赋给了 A 合约插槽 0 的变量 alice
。同理,对 B 合约的第 n 个插槽赋值,实际上会对 A 合约的第 n 个插槽赋值。注意,这里仅仅和插槽顺序有关,而和变量名无关。如果我们将 B 合约改为:
contract B {
// 调换了变量声明顺序
uint256 public bob;
uint256 public alice;
function foo(uint256 _alice, uint256 _bob) external {
// 调换了赋值内容
bob = _alice;
alice = _bob;
}
}
这段代码中,虽然变量声明以及赋值的顺序调换,但是 foo
的内容仍然是将 _alice
赋值给插槽 0 的变量,将 _bob
赋值给插槽 1 的变量,因此 A 合约的结果不变。
学习理解 delegatecall
是我们后面学习合约升级的基础,合约升级的原理就是代理合约通过 delegatecall
调用逻辑合约,此时逻辑合约的上下文以及数据都是来自于代理合约,那么即使升级,更换了逻辑合约,所有的数据仍然存在于代理合约中,没有影响。可升级合约还有一个限制是,在升级合约时,不能更改已有的状态变量的顺序,如果需要新添变量,只能放在当前所有变量之后,不能在其中插入,原因就是这会改变插槽对应关系,使变量内容混乱。例如,若升级前的插槽为:
此时,变量 a 和 b 的值分别存储在代理合约的插槽 0,1 中。若添加变量 c,将其放在 a 和 b 中间,那么后续对于 c 的修改实际修改的是 b 的插槽,而对于 b 的修改则是在一个新的插槽上操作,造成数据混乱。
delegatecall
会在被调用合约中使用调用合约的上下文,同时影响的是调用合约的内存插槽,这有时会对合约开发带来一些困扰。在使用时,一定要多考虑各方面的影响。同时,delegatecall
也是代理合约升级模式的基石,要理解合约升级,必须要明白这种调用方式的方方面面。
欢迎和我交流