xyyme.eth

Posted on May 23, 2022Read on Mirror.xyz

深入理解合约升级(4) - 合约升级原理的代码实现

前面的文章我们提到,合约升级的原理是将合约架构分为 代理合约逻辑合约,通过前面对于内存结构以及 delegatecall 的学习,我们已经基本掌握的合约升级的基础。这篇文章我们就从代码层面来看看合约升级到底应该如何实现。

这是我们在第一篇文章中的图例,当我们学习了内存结构以及 delegatecall 之后,我们再来看这幅图,就能够很好地理解了:数据都存放在代理合约的内存插槽中,而由于代理合约使用了 delegatecall,因此函数执行都在逻辑合约中运行,修改的却是代理合约中的数据。这样就可以方便替换逻辑合约,实现合约升级。

合约升级实现过程

简版 Proxy

首先我们考虑,对于代理合约而言,如何将请求转发到逻辑合约。最简单的方法就是对于逻辑合约中的每个函数,都在代理合约中包装一层,然后通过 delegatecall 来分别调用各个函数。这种方法是不可行的,因为既然我们都用到了可升级合约,那就说明我们后期会对合约做改动,我们总不能每添加一个函数,都在代理合约中添加一个包装层。一是这样很冗余,二是这样无法实现,因为代理合约也是区块链上的智能合约,它本身是不可变的。

这时我们想到,能不能够利用 Solidity 中的 fallbackreceive 函数,它们的作用就是接收并处理一切未实现的函数(两者的区别是,receive 接收所有 msg.data 为空的调用,fallback 接收所有未匹配函数的调用)。如果我们将所有的函数调用都通过 fallback 转发给逻辑合约,那么是否就达到目标了呢?我们来看看代码:

// 注:这个实现有问题(后文有描述),不要直接使用!
contract Proxy {
    address public implementation;
    address public admin;

    constructor() public {
        admin = msg.sender;
    }
    function setImplementation(address newImplementation) external {
        require(msg.sender == admin, "must called by admin");
        implementation = newImplementation;
    }
    function changeAdmin(address newAdmin) external {
        require(msg.sender == admin, "must called by admin");
        admin = newAdmin;
    }

    function _delegate() internal {
        require(msg.sender != admin, "admin cannot fallback to proxy target");
        address _implementation = implementation;
        // 下面代码是利用 delegatecall 把请求转发给 _implementation 所指定地址的合约中去
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())

            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    fallback () payable external {
        _delegate();
    }
    // Will run if call data is empty.
    receive () payable external {
        _delegate();
    }
}

这段代码中,我们将 fallbackreceive 函数都指向了 _delegate 函数,它会将有请求都转发给逻辑合约。乍一看没有什么问题,但是需要注意:

  1. 此合约中有两个字段,implementationadmin,分别存储逻辑合约地址和管理员地址(管理员地址用户更换逻辑合约升级)。它们分别占据了插槽 0 和 1 的位置,那么这就有可能和我们的逻辑合约中的内存发生冲突,如果逻辑合约中有对这俩插槽的修改,那就直接把这两个很重要的变量给改掉了,那就乱套了。
  2. 还有一个问题就是代理合约本身也存在一些自身的方法,比如 changeAdmin 等,如果逻辑合约中恰好也有这些方法,那么用户的请求就不会转发到逻辑合约。

EIP-1967

该如何解决这个问题呢?EIP-1967 提出了一个解决办法,它把 implementationadmin 这两个字段放在了两个特殊的插槽中:

# bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

# bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103

这两个插槽是经过哈希计算出来的值,根据概率来看,不可能与逻辑合约中的其他内存位置发生冲突。此时,我们的代码就变成了:

bytes32 private constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

bytes32 private constant _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

注意这里指用了 constant 来保存插槽位置,constant 关键字保存的是常量,并不保存在插槽中。这样我们就解决了前面提到的第一个问题。

我们再来看看第二个问题,这个问题看似很好解决,比如我们给代理合约中的函数都起一些不常用的名字就行。例如,把上面的 changeAdmin 改成 changeAdmin12345。但是问题没有这么简单,我们要知道,智能合约匹配请求是根据函数签名 keccak256 值的前 4 个字节来判断的,如果两个函数的哈希值前 4 位相同,那么它们就被判断为同一个函数,例如:

# keccak256("proxyOwner()") 前 4 字节为 025313a2
proxyOwner()

# keccak256("clash550254402()") 前 4 字节为 025313a2
clash550254402()

这个问题被称为 Proxy selector clashing。这可能会造成一些问题,如果我们的逻辑合约中恰好有函数的哈希值前 4 位与代理合约中的某个函数相同,那就会造成用户的请求实际上是在代理合约中执行的,而执行结果必然是我们不希望发生的。

Transparent Proxy

Transparent Proxy 提出了解决方案,它主要从两方面解决这个问题:

  1. 来自普通用户的请求全部转发给逻辑合约,即使代理合约与逻辑合约发生了名称冲突,也要转发
  2. 来自管理员 admin 的请求,全部不转发,由代理合约处理

主要的代码如下:

contract Proxy {
    bytes32 private constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    bytes32 private constant _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

    modifier ifAdmin() {
        // 如果调用者是 admin,就执行;否则转发到 Implementation 合约
        if (msg.sender == admin()) {
            _;
        } else {
            _delegate();
        }
    }

    constructor() public {
        address admin = msg.sender;
        bytes32 slot = _ADMIN_SLOT;
        assembly {
            sstore(slot, admin)
        }
    }
    function implementation() public ifAdmin returns (address) {
        return _implementation();
    }

    function _implementation() internal view returns (address impl) {
        bytes32 slot = _IMPLEMENTATION_SLOT;
        assembly {
            impl := sload(slot)
        }
    }

    function setImplementation(address newImplementation) external ifAdmin {
        bytes32 slot = _IMPLEMENTATION_SLOT;
        assembly {
            sstore(slot, newImplementation)
        }
    }
    function admin() public ifAdmin returns (address) {
        return _admin();
    }

    function _admin() internal view returns (address adm) {
        bytes32 slot = _ADMIN_SLOT;
        assembly {
            adm := sload(slot)
        }
    }
    function changeAdmin(address newAdmin) external ifAdmin {
        bytes32 slot = _ADMIN_SLOT;
        assembly {
            sstore(slot, newAdmin)
        }
    }

    function _delegate() internal {
        address _implementation = _implementation();
        // 下面代码是利用 delegatecall 把请求转发给 _implementation 所指定地址的合约中去
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())

            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    fallback () payable external {
        // 来自 admin 的请求不转发
        require(msg.sender != _admin(), "admin cannot fallback to proxy target");
        _delegate();
    }

    // 来自 admin 的请求不转发
    receive () payable external {
        require(msg.sender != _admin(), "admin cannot fallback to proxy target");
        _delegate();
    }
}

可以看到,合约中添加了 ifAdmin 修饰符,用于判断请求的来源。对于代理合约自身的一些函数如 changeAdmin 等,均使用了该修饰符。这样即使出现了签名冲突的情况,只要是来自于普通用户的请求,均直接转发给了逻辑合约执行。这样就解决了前面的第二个问题。

不过,仍然存在一个小问题,就是 admin 用户无法作为普通用户的视角正常调用。这个问题也比较好解决,一般可以准备一个特殊账户作为 admin 用户,仅调用管理员方法即可。也有另一个解决方法,就是使用一个 ProxyAdmin 合约来作为管理员,这样所有的账户都可以正常调用合约了。

ProxyAdmin 的合约代码片段如下:

contract ProxyAdmin is Ownable {
    // 管理员EOA地址通过调用该方法来更换逻辑合约
    function upgrade(IProxy proxy, address newImplementation) public onlyOwner {
        proxy.setImplementation(implementation);
    }
    // ......
}

interface IProxy {
    function setImplementation(address newImplementation);
    // ......
}

此时整个合约的架构为:

可升级合约架构

Universal Upgradeable Proxy Standard (UUPS)

UUPS 是 OpenZeppelin 在近期推出的一种新的合约升级模式,与上面的 Transparent 代理模式原理相同,都是利用 delegatecall 通过代理合约调用逻辑合约。所不同的是,Transparent 模式是将替换逻辑合约的函数放在代理合约中,而 UUPS 则是将其放在了逻辑合约中。也就是说,前者模式中,每个代理合约中都有一个类似 upgradeTo 这样的函数,用来更换逻辑合约。而在后者模式中,这样的函数是放在了逻辑合约的实现中。

官方文档中对于两者的对比,主要表述了 UUPS 模式更加轻量级,更加节省 gas,并且更推荐使用这种模式。由于代理合约中不用再包含替换逻辑合约的函数,因此节省了 gas。我个人目前对于这种模式持保留意见,因为对于可升级合约来说,代理合约和逻辑合约是一对多的关系。在之前的模式中,把替换逻辑合约的部分放在代理合约中,这也只需要部署一份代理合约。而新模式中,每个逻辑合约都要包含这部分升级组件,这样是否更加耗费 gas。而且将这部分逻辑放在逻辑合约中,是否会造成逻辑合约变得更加臃肿。毕竟在之前的模式中,开发逻辑合约时只需要关注业务逻辑即可。也许是因为目前我对于 UUPS 模式的理解不够深入,暂时先将疑问抛出,待后续继续深入学习。

可升级合约的一些限制

要实现合约升级,有一些限制需要我们注意

第一个就是我们在上篇文章最后提到的,在升级合约,也就是更换逻辑合约的时候,新合约的新增状态变量必须添加在当前所有变量之后,不能在前面的变量中插入,否则会更改内存插槽对应关系。同理,如果合约涉及到继承关系,不能在基类中添加变量(在基类中添加变量就相当于在基类和子类的状态变量之前插入变量)。也不能够更改或删除之前的状态变量。

第二个是不能使用构造函数 constructor,由于合约的构造函数是在合约初始化的时候就被调用,这时它的一些赋值操作会直接影响到自身的内存。合约升级的前提是代理合约通过 delegatecall 调用逻辑合约来影响代理的内存布局,如果逻辑合约自己使用了构造函数去初始化一些变量,那么对于代理合约而言,内存是没有任何变化的。对于该问题,替代方法是使用 initialize 函数来代替构造函数。在合约部署完成后需要手动调用 initialize 函数。同时要记得,逻辑合约中实现的 initialize 函数中要手动实现调用基类的 initialize 方法。例如:

pragma solidity ^0.6.0;

import "@openzeppelin/upgrades/contracts/Initializable.sol";

contract BaseContract is Initializable {
    uint256 public y;

    function initialize() public initializer {
        y = 42;
    }
}

contract MyContract is BaseContract {
    uint256 public x;

    function initialize(uint256 _x) public initializer {
        // 手动调用基类中的初始化方法
        BaseContract.initialize();
        x = _x;
    }
}

第三个是所有状态变量不能在声明时就赋初始值,例如:

contract MyContract {
    uint256 public hasInitialValue = 42;
}

这种行为类似于在构造函数中赋值,不可行。需要改为在 initialize 函数中赋值。

代码

Openzeppelin 库已经实现了完善的可升级合约库,我们在开发过程中可以直接使用现有的合约进行部署,避免重复造轮子出现错误。同时也提供了相应的文档以供参考。

合约升级系列文章

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

关于我

欢迎和我交流

参考

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

https://blog.openzeppelin.com/the-transparent-proxy-pattern/

https://ethereum.stackexchange.com/questions/81994/what-is-the-receive-keyword-in-solidity

https://docs.openzeppelin.com/contracts/4.x/upgradeable

https://docs.openzeppelin.com/contracts/4.x/api/proxy#transparent-vs-uups

https://forum.openzeppelin.com/t/uups-proxies-tutorial-solidity-javascript/7786