以太坊Delegate Call详解

廖雪峰
资深软件开发工程师,业余马拉松选手。

在以太坊合约中,一个合约可以调用另一个合约,以实现功能模块化。

除了普通的跨合约调用,以太坊还提供了delegatecall来跨合约调用。

delegatecall跨合约调用与普通跨合约调用不同,它不会改变代码执行的上下文环境,而是基于当前合约的上下文来执行目标合约代码,就像这些代码是当前合约自己的代码一样。

什么是合约上下文?我们以一个简单的示例来说,就可以正确理解delegatecall的调用方式。

先编写两个合约:TargetDelegate,完整代码如下:

pragma solidity ^0.8.25;

contract Target {

    event Log(string msg, address thisAddr, address msgSender, uint256 msgValue, address txOrigin);

    string public name = "target";
    uint256 public version = 1;

    function save(string memory _name, uint256 _version) public payable {
        name = _name;
        version = _version;
        emit Log("Target.save", address(this), msg.sender, msg.value, tx.origin);
    }
}

contract Delegate {

    event Log(string msg, address thisAddr, address msgSender, uint256 msgValue, address txOrigin);

    string public name = "delegate";
    uint256 public version = 10;

    Target public target;

    constructor (address _target) {
        target = Target(_target);
    }

    function save(string memory _name, uint256 _version) public payable {
        emit Log("Delegate.save", address(this), msg.sender, msg.value, tx.origin);
        target.save(_name, _version);
    }

    function delegateSave(string memory _name, uint256 _version) public payable {
        emit Log("Delegate.delegateSave", address(this), msg.sender, msg.value, tx.origin);
        (bool success, bytes memory returndata) = address(target).delegatecall(
            abi.encodeWithSelector(
                Target.save.selector,
                _name,
                _version
            )
        );
        if (! success) {
            revert("delegate call failed.");
        }
    }
}

这两个合约部署后,Delegate地址为0x2c70...Target地址为0xdC31...,此处地址仅为示例合约部署到某一条ETH链的特定地址,重复本文实验会得到不同的部署地址。

Delegate合约中,编写两个函数:

  • save()函数,以正常方式调用Target合约的save()函数;
  • delegateSave()函数,以delegatecall方式调用Target合约的save()函数。

调用关系如下图所示:

 Delegate (0x2c70...)
┌─────────────────────────────┐     Target (0xdC31...)
│save() {                     │    ┌───────────────────┐
│  target.save();─────────────┼───▶│save(n, v) {       │
│}                            │ ┌─▶│  name = n;        │
├─────────────────────────────┤ │  │  version = v;     │
│delegateSave() {             │ │  │}                  │
│  delegateCall(target.save);─┼─┘  └───────────────────┘
│}                            │
└─────────────────────────────┘

另外注意到我们在两个合约中均存储了nameversion,并设定了初始值。部署合约后,两个合约的初始状态如下:

 Delegate (0x2c70...)   Target (0xdC31...)
┌────────────────────┐ ┌────────────────────┐
│balance = 0         │ │balance = 0         │
├────────────────────┤ ├────────────────────┤
│name = "delegate"   │ │name = "target"     │
├────────────────────┤ ├────────────────────┤
│version = 10        │ │version = 1         │
└────────────────────┘ └────────────────────┘

下一步,我们用地址0x98fd...这个外部地址调用Delegatesave()函数,传入参数:

  • name = "bob"
  • version = 123
  • ETH = 0.01

Delegate合约的save()函数内部,打印出的日志为:

  • msg = "Delegate.save"
  • thisAddr = 0x2c70...
  • msgSender = 0x98fd...
  • msgValue = 0.01
  • txOrigin = 0x98fd...

Target合约的save()函数内部,打印出的日志为:

  • msg = "Target.save"
  • thisAddr = 0xdC31...
  • msgSender = 0x2c70...
  • msgValue = 0
  • txOrigin = 0x98fd...

可见,正常调用Target合约函数,在Target合约内部执行save()函数时,address(this)总是指向当前合约,msg.sender是调用方Delegate的地址,msg.value不再是外部传入的0.01,这就是跨合约调用函数时,上下文会自动切换。

执行后,我们检测两个合约的状态如下:

 Delegate (0x2c70...)   Target (0xdC31...)
┌────────────────────┐ ┌────────────────────┐
│balance = 0.01      │ │balance = 0         │
├────────────────────┤ ├────────────────────┤
│name = "delegate"   │ │name = "bob"        │
├────────────────────┤ ├────────────────────┤
│version = 10        │ │version = 123       │
└────────────────────┘ └────────────────────┘

可见,Target合约的save()函数修改了自身状态,不会修改Delegate合约的状态,而外部传入的ETH则留在Delegate合约中。

现在我们再以外部地址0x98fd...调用Delegate合约的delegateSave()函数,传入参数:

  • name = "alice"
  • version = 456
  • ETH = 0.02

这个时候,Delegate合约的delegateSave()函数内部,以delegateCall调用Target合约的save()函数,我们先观察执行后两个合约的状态:

 Delegate (0x2c70...)   Target (0xdC31...)
┌────────────────────┐ ┌────────────────────┐
│balance = 0.03      │ │balance = 0         │
├────────────────────┤ ├────────────────────┤
│name = "alice"      │ │name = "bob"        │
├────────────────────┤ ├────────────────────┤
│version = 456       │ │version = 123       │
└────────────────────┘ └────────────────────┘

注意到Target合约的save()函数代码如下:

function save(string memory _name, uint256 _version) public payable {
    name = _name;
    version = _version;
    emit ...
}

但它却并没有修改自身状态,而是把Delegate合约的nameversion给改了!

这就是delegatecall调用时,不会切换当前上下文,导致Target合约的save()函数看起来就像是在Delegate合约中执行的。

我们检查日志,可以看到Delegate合约打印的日志:

msg = "Delegate.delegateSave"
thisAddr = 0x2c70...
msgSender = 0x98fd...
msgValue = 0.02
txOrigin = 0x98fd...

Target合约打印的日志:

msg = "Target.save"
thisAddr = 0x2c70...
msgSender = 0x98fd...
msgValue = 0.02
txOrigin = 0x98fd...

现在,我们搞明白了,所谓的上下文不切换,就是指address(this)不会变,msg.sendermsg.value也不会变,这将导致执行Target合约时,代码:

name = _name;

根据上下文address(this)返回的仍然是Delegate合约地址,所以,它修改的实际上是Delegate合约的name

因此,我们总结一下delegatecall调用的效果,其实就相当于把被调用的Target.save()看作是Delegate合约的一个内部函数:

function delegateSave(string memory _name, uint256 _version) public payable {
    emit Log("Delegate.delegateSave", address(this), msg.sender, msg.value, tx.origin);
    // address(target).delegatecall(...)
    // 相当于把Target.save()的代码搬到这里:
    {
        name = _name;
        version = _version;
    }
}

只不过此处的nameversion都是Delegate合约的数据,而不是Target合约的数据。

什么情况下需要用到delegatecall呢?如果一个合约的逻辑需要升级,那么可以把数据放到主合约,把执行逻辑放到单独的合约里,主合约与逻辑合约有相同名称的字段,就可以实现逻辑合约升级,而数据始终存储在主合约中。



Comments

Loading comments...