xyyme.eth

Posted on May 18, 2022Read on Mirror.xyz

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

call 与 delegatecall 的区别

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

通过 call 调用

通过 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 调用时,使用的是 B 本身的上下文。接下来我们将 call 改成 delegatecall

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

再来看看执行结果:

通过 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) - 部署一个可升级合约

关于我

欢迎和我交流

参考

https://blockchain-academy.hs-mittweida.de/courses/solidity-coding-beginners-to-intermediate/lessons/solidity-5-calling-other-contracts-visibility-state-access/topic/delegatecall/

https://www.anquanke.com/post/id/152590

http://aandds.com/blog/eth-delegatecall.html