深入理解合约升级(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) - 部署一个可升级合约

关于我

欢迎和我交流

参考

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.