深入理解合约升级(3) - call 与 delegatecall

call 与 delegatecall 的区别

calldelegatecall 是 Solidity 中调用外部合约的方法,但是它俩却有挺大的区别。假设 A 合约调用 B 合约,当在 A 合约中使用 call 调用 B 合约时,使用的是 B 合约的上下文,修改的是 B 合约的内存插槽值。而在如果在 A 合约中使用 delegatecall 调用 B 合约,那么在 B 合约的函数执行过程中,使用的是 A 合约的上下文,同时修改的也是 A 合约的内存插槽值。这么说有些抽象,我们来看一个简单的示意图:

通过 call 调用
通过 call 调用
通过 delegatecall 调用
通过 delegatecall 调用

从上面的图中我们可以看出,在使用 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 调用
通过 call 调用

与我们前面的说的规则一致,使用 call 调用时,使用的是 B 本身的上下文。接下来我们将 call 改成 delegatecall

function foo() external {
    (bool success, bytes memory data) = 
        b.delegatecall(abi.encodeWithSignature("foo()"));
    require(success, "Tx failed");
}

再来看看执行结果:

通过 delegatecall 调用
通过 delegatecall 调用

可以看到当使用了 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 是我们后面学习合约升级的基础,合约升级的原理就是代理合约通过 delegatecall 调用逻辑合约,此时逻辑合约的上下文以及数据都是来自于代理合约,那么即使升级,更换了逻辑合约,所有的数据仍然存在于代理合约中,没有影响。可升级合约还有一个限制是,在升级合约时,不能更改已有的状态变量的顺序,如果需要新添变量,只能放在当前所有变量之后,不能在其中插入,原因就是这会改变插槽对应关系,使变量内容混乱。例如,若升级前的插槽为:

此时,变量 a 和 b 的值分别存储在代理合约的插槽 0,1 中。若添加变量 c,将其放在 a 和 b 中间,那么后续对于 c 的修改实际修改的是 b 的插槽,而对于 b 的修改则是在一个新的插槽上操作,造成数据混乱。

总结

delegatecall 会在被调用合约中使用调用合约的上下文,同时影响的是调用合约的内存插槽,这有时会对合约开发带来一些困扰。在使用时,一定要多考虑各方面的影响。同时,delegatecall 也是代理合约升级模式的基石,要理解合约升级,必须要明白这种调用方式的方方面面。

合约升级系列文章

  1. 深入理解合约升级(1) - 概括
  2. 深入理解合约升级(2) - Solidity 内存布局
  3. 深入理解合约升级(3) - call 与 delegatecall
  4. 深入理解合约升级(4) - 合约升级原理的代码实现
  5. 深入理解合约升级(5) - 部署一个可升级合约

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.