Delegatecall and Upgradeable Contract

Table of Contents

1. delegatecall 简介

Ethereum 创始人 Vitalik Buterin 在 EIP-7 中引入了 delegatecall ,本文将介绍它。

合约 A 通过 delegatecall 调用合约 B 时,会使用合约 A(而不是合约 B)的 Storage,也就是说 如果被调用的函数会修改状态变量,则这个修改实际上会反映到合约 A 中。

delegatecall 的应用场景有:1、可升级合约,2、Solidity 的 Linked Library。这里重点关注可升级合约。

1.1. delegatecall 实例演示

考虑下面两个合约 ContractA 和 ContractB。

ContractA:

// SPDX-License-Identifier: MIT
pragma solidity 0.6.12;

contract ContractA {
    address public contractB;

    constructor(address _contractB) public {
        contractB = _contractB;
    }

    function delegatecallChangeZ(uint256 _z) public {
        // 通过 delegatecall 调用合约 B 中的方法 changeZ
        // 并不会改变合约 B 中状态变量 z
        contractB.delegatecall(abi.encodeWithSignature("changeZ(uint256)", _z));
    }

    function delegatecallChangeX(uint256 _x) public {
        contractB.delegatecall(abi.encodeWithSignature("changeX(uint256)", _x));
    }

    function getValue(uint256 slot) view public returns(uint256 value) {
        // solhint-disable-next-line no-inline-assembly
        assembly {
            value := sload(slot)
        }
    }
}

ContractB:

// SPDX-License-Identifier: MIT
pragma solidity 0.6.12;

contract ContractB {
    uint256 public x;
    uint256 public y;
    uint256 public z;

    function changeX(uint256 _x) public {
        x = _x;
    }
    function changeY(uint256 _y) public {
        y = _y;
    }
    function changeZ(uint256 _z) public {
        z = _z;
    }
}

部署 ContractB,拿到合约地址后,再部署 ContractA。调用 ContractA 中方法 delegatecallChangeZ(5) 后,ContractB 中的状态变量 z 会被修改为 5 吗?答案是否定的,ContractB 中的状态变量 z 不会受到影响, changeZ 方法中的 z=_z 修改了 ContractA 中的 Storage 中第 3 个 slot(编号为 2,因为第 1 个 slot 的编号是 0)中的值为 5。这可以通过调用 ContractA 中的方法 getValue(2) 验证(会得到数字 5)。

调用 delegatecallChangeZ(5) 的过程如图 1 所示。

eth_delegatecall_example.svg

Figure 1: 调用 delegatecallChangeZ(5) 的过程

关于状态变量在 Storage 中的存储细节参考:https://solidity.readthedocs.io/en/latest/internals/layout_in_storage.html

1.2. 小心 clash

上一节中,我们可以调用 ContractA 中的 delegatecallChangeX 吗? changeX 会修改 ContractB 中的首个状态变量,但由于是通过 delegatecall 调用的,所以这会导致 ContractA 中首个状态变量(即变量 contractB)被修改!如图 2 所示。

eth_delegatecall_clash.svg

Figure 2: delegatecall 导致 Clash,地址变量 contractB 被不小心修改

这很危险,我们“不小心”修改了 ContractA 中保存的 ContractB 的地址(即变量 contractB),这导致以后再也无法成功调用 ContractA 中的 delegatecallChangeX/delegatecallChangeZ 了。

2. delegatecall 应用:可升级合约

利用 delegatecall 特性,我们可以实现合约的可升级。其基本原理是 把前面介绍的 ContractA 作为 Proxy 合约,而 ContractB 作为 Implementation 合约;Proxy 合约中保存着状态变量的数据,而 Implementation 合约保存着具体的业务逻辑。需要升级时,在 Proxy 合约中把 Implementation 合约的地址换了即可。

用户直接使用 Proxy 合约(Implementation 合约升级后,Proxy 合约的地址是不变的,这样用户就无感知了),Proxy 合约把用户的请求通过 delegatecall 转发给 Implementation 合约。如图 3 和图 4 所示(这两个图摘自 https://simpleaswater.com/upgradable-smart-contracts/)。

eth_delegatecall_upgrading_contracts_1.gif

Figure 3: 用户使用 Proxy 合约,请求转发给了 Implementation 合约

eth_delegatecall_upgrading_contracts_2.gif

Figure 4: 升级 Implementation 合约

2.1. Proxy 合约

Proxy 合约如何通过 delegatecall 把请求转给 Implementation 合约呢?如果我们确定 Implementation 合约中有哪几个方法,那么我们可以像前面介绍的那样在 Proxy 合约中为每个方法都写一个相应的方法调用 delegatecall 来转发请求到 Implementation 合约。但这显然不现实,因为既然 Implementation 合约可升级,那么我们就可以在新的 Implementation 合约中增加方法,Proxy 合约部署比较早,它不能提前知道我们会增加哪些方法。

我们需要一种“通用的机制”来转发请求到 Implementation 合约。Solidity 中的 receive, 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 {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    // Will run if no other function in the contract matches the call data.
    fallback () payable external {
        _delegate();
    }
    // Will run if call data is empty.
    receive () payable external {
        _delegate();
    }
}

上面实现有两个问题:

  1. 状态变量 implementationadmin 占据了 Storage 中的前两个 Slot,这很可能会被不小心修改,细节可参考节 1.2
  2. 如果用户调用 Proxy 合约中存在的方法(如 admin, changeAdmin 等),那么会直接调用这个方法,这个请求并不会被转发到 Implementation 合约中。

这两个问题将在后文中将解决。

2.1.1. 问题 1 的解决方案:EIP-1967

为了避免 Proxy 合约和 Implementation 合约出现状态变量的 Clash,我们不能随意在 Proxy 合约中定义状态变量。

EIP-1967: Standard Proxy Storage Slots 中规定了 Proxy 合约可以使用的两个特定 Slot:

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

这两个 Slot 分别用于保存 implementation 合约的地址及 admin 的地址。

Implementation 合约中固定大小(即除变长数组和 mapping 外)的状态变量使用的 Slot 不太可能会和这两个 Slot 冲突,因为固定大小的状态变量的 Slot 编号从 0 开始,一个合约中显然不可能有这么多的状态变量。而对于变长数组和 mapping 来说,和上面两个 Slot 冲突的可能性非常地小,关于变长数组和 mapping 计算其 Slot 编号的规则,可以参考:https://solidity.readthedocs.io/en/latest/internals/layout_in_storage.html

使用 EIP-1967 方案中定义的两个 Slot 后,Proxy 的实现可改为:

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

    constructor() public {
        address admin = msg.sender;
        bytes32 slot = _ADMIN_SLOT;
        assembly {
            sstore(slot, admin)
        }
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = _IMPLEMENTATION_SLOT;
        assembly {
            impl := sload(slot)
        }
    }
    function setImplementation(address newImplementation) external {
        require(msg.sender == admin(), "must called by admin");
        bytes32 slot = _IMPLEMENTATION_SLOT;
        assembly {
            sstore(slot, newImplementation)
        }
    }
    function admin() view public returns (address adm) {
        bytes32 slot = _ADMIN_SLOT;
        assembly {
            adm := sload(slot)
        }
    }
    function changeAdmin(address newAdmin) external {
        require(msg.sender == admin(), "must called by admin");
        bytes32 slot = _ADMIN_SLOT;
        assembly {
            sstore(slot, newAdmin)
        }
    }

    function _delegate() internal {
        address _implementation = implementation();
        // 下面代码是利用 delegatecall 把请求转发给 _implementation 所指定地址的合约中去
        assembly {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    // Will run if no other function in the contract matches the call data.
    fallback () payable external {
        _delegate();
    }
    // Will run if call data is empty.
    receive () payable external {
        _delegate();
    }
}

上面实现中 constant 状态变量并不会占据 Storage 中的 Slot,因为它们会在编译时直接展开。 所以这里并没有引入新的 Clash 点。

2.1.2. 问题 2 的解决方案:Transparent Proxy Pattern

前面提到过,如果用户调用 Proxy 合约中存在的方法,则请求不会被转发到 Implementation 合约中去。

你可能认为这个问题不太,我们在 Proxy 合约中选择一些不常用的名字,这样就不会和 Implementation 合约中的方法冲突了。但实际上,不是名字相同才会冲突,名字不同时也可能冲突。Ethereum 中函数签名的 keccak256 哈希的前 4 个字节相同,就认为是相同函数。比如下面两个不同的名字会冲突:

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

这个问题被称为“Proxy selector clashing”。

一旦冲突发生,造成的后果是: 你以为调用了 Implementation 合约中的方法,但实际上 Implementation 合约中并没有被调用,而是调用了 Proxy 合约中的方法。 这会带来潜在的安全问题,比如 Implementation 合约中有个方法的功能是把币转给某个人,但发生了名字冲突,它没有被调用,而是调用了 Proxy 合约中某个方法,这样导致币并没有转给别人,这会造成严重的问题。关于“Proxy selector clashing”问题,可参考:https://medium.com/nomic-labs-blog/malicious-backdoors-in-ethereum-proxies-62629adf3357

Transparent Proxy Pattern 可以解决这个问题,Transparent Proxy Pattern 实现了下面行为:

  1. 来自终端用户的调用“全部转发”给 Implementation 合约,就算 Proxy 合约和 Implementation 合约的名字相同也要转发;
  2. 来自 admin 的调用,“全部不转发”给 Implementation 合约。

这如何实现呢?请看下面代码:

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 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 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 {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

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

这带来了一个新问题:admin 无法调用 Implementation 合约了。这算不上一个“问题”,一般我们专门准备一个 admin 帐户用于管理性工作(如调用 setImplementation 升级合约)即可。

这个问题也可以用一个新合约:ProxyAdmin 合约来解决,它的作用就是调用 Proxy 合约中的管理方法。这样,所有 EOA 帐户都可以调用 Implementation 合约了。

ProxyAdmin 合约的代码片断如下:

contract ProxyAdmin is Ownable {
    function upgrade(IProxy proxy, address newImplementation) public onlyOwner {
        proxy.setImplementation(implementation);
    }
    // ......
}

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

2.2. 可升级合约架构

2.2.1. 前文架构总结

前面介绍的可升级合约架构,可以表示为如图 5 所示。

eth_delegatecall_upgrade_contract.svg

Figure 5: 可升级合约架构(Transparent Proxy Pattern)

2.2.2. 多 Proxy 合约对应一个逻辑合约时统一升级(Beacon 合约)

由于 Implementation 合约(逻辑合约)不保存状态数据,只保存代码,所以有些场景(如智能合约钱包)可能是多个 Proxy 合约(每个用户一个)对应到一个逻辑合约。这时,如果采用图 5 中的架构,则升级逻辑合约时,需要修改每一个 Proxy 合约中槽 _IMPLEMENTATION_SLOT 中所保存的逻辑合约地址。

有没有办法实现一次升级所有逻辑合约呢?答案是有的,引入一个 Beacon 合约,在 Proxy 合约中在槽 _BEACON_SLOT 处记录 Beacon 合约地址;Beacon 合约中用一个状态变量(比如状态变量 _implementation)记录 Implementation 合约(逻辑合约)的地址,要升级逻辑合约,只需 Beacon Owner 修改一下 Beacon 合约中的状态变量 _implementation 即可,如图 6 所示。

eth_delegatecall_upgrade_contract_beacon.gif

Figure 6: 引入了 Beacon 合约,方便统一升级逻辑合约(虚线表示记录的合约地址)

不过,这时在 Proxy 合约中并没有直接记录逻辑合约的地址, 每次 Proxy 合约通过 delegatecall 调用逻辑合约前,需要先去 Beacon 合约查询它记录的逻辑合约地址。

参考:https://eips.ethereum.org/EIPS/eip-1967#beacon-contract-address

2.2.3. OpenZeppelin 相关合约

前面提到的 Proxy 合约、ProxyAdmin 合约、Beacon 合约等等,OpenZeppelin 都已经实现,相关代码可参考:https://github.com/OpenZeppelin/openzeppelin-contracts/tree/master/contracts/proxy

OpenZeppelin 也提供了部署和升级可升级合约的相关工具(函数 deployProxy/upgradeProxy),参考:https://github.com/OpenZeppelin/openzeppelin-upgrades

2.3. 其它可升级合约架构

目前,有 3 种常见的可升级合约架构:

  1. Transparent Proxy Pattern,这就是前文介绍的方法
  2. Universal Upgradeable Proxy Standard (UUPS): EIP-1822
  3. Diamond, Multi-Facet Proxy: EIP-2532

2.3.1. Universal Upgradeable Proxy Standard (UUPS)

UUPS (EIP-1822) 和 Transparent Proxy Pattern 基本相似,区别只在于: 对于 UUPS 来说,升级相关代码位于 Implementation 合约(或称 Logic 合约)中,而对于 Transparent Proxy Pattern 来说,升级相关代码位于 Proxy 合约中。

下面是 UUPS 的例子(摘自 https://eips.ethereum.org/EIPS/eip-1822#erc-20-token )。其中 Proxy 合约为:

pragma solidity ^0.5.1;

contract Proxy {
    // Code position in storage is keccak256("PROXIABLE") = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"
    constructor(bytes memory constructData, address contractLogic) public {
        // save the code address
        assembly { // solium-disable-line
            sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, contractLogic)
        }
        (bool success, bytes memory _ ) = contractLogic.delegatecall(constructData); // solium-disable-line
        require(success, "Construction failed");
    }

    function() external payable {
        assembly { // solium-disable-line
            let contractLogic := sload(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7)
            calldatacopy(0x0, 0x0, calldatasize)
            let success := delegatecall(sub(gas, 10000), contractLogic, 0x0, calldatasize, 0, 0)
            let retSz := returndatasize
            returndatacopy(0, 0, retSz)
            switch success
            case 0 {
                revert(0, retSz)
            }
            default {
                return(0, retSz)
            }
        }
    }
}

Proxy 合约的逻辑很简单,把 Implementation 合约(或称 Logic 合约)的地址固定保存在位置 0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7 处,后续所有请求都转发给这个位置中记录的地址。

而升级相关代码中在 Implementation 合约中实现。下面是 Implementation 合约的例子,owner 调用方法 updateCode (当然是通过 Proxy 合约的 delegatecall 来调用 Implementation 合约的 updataCode )可以修改 Proxy 合约中位置 0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7 处记录的 Implementation 合约的地址,从而实现 Implementation 合约的升级。

contract Proxiable {
    // Code position in storage is keccak256("PROXIABLE") = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"

    function updateCodeAddress(address newAddress) internal {
        require(
            bytes32(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7) == Proxiable(newAddress).proxiableUUID(),
            "Not compatible"
        );
        assembly { // solium-disable-line
            sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, newAddress)
        }
    }
    function proxiableUUID() public pure returns (bytes32) {
        return 0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7;
    }
}


contract Owned {

    address owner;

    function setOwner(address _owner) internal {
        owner = _owner;
    }
    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner is allowed to perform this action");
        _;
    }
}

contract LibraryLockDataLayout {
  bool public initialized = false;
}

contract LibraryLock is LibraryLockDataLayout {
    // Ensures no one can manipulate the Logic Contract once it is deployed.
    // PARITY WALLET HACK PREVENTION

    modifier delegatedOnly() {
        require(initialized == true, "The library is locked. No direct 'call' is allowed");
        _;
    }
    function initialize() internal {
        initialized = true;
    }
}

contract ERC20DataLayout is LibraryLockDataLayout {
  uint256 public totalSupply;
  mapping(address=>uint256) public tokens;
}

contract ERC20 {
    //  ...
    function transfer(address to, uint256 amount) public {
        require(tokens[msg.sender] >= amount, "Not enough funds for transfer");
        tokens[to] += amount;
        tokens[msg.sender] -= amount;
    }
}

contract MyToken is ERC20DataLayout, ERC20, Owned, Proxiable, LibraryLock {

    function constructor1(uint256 _initialSupply) public {
        totalSupply = _initialSupply;
        tokens[msg.sender] = _initialSupply;
        initialize();
        setOwner(msg.sender);
    }
    function updateCode(address newCode) public onlyOwner delegatedOnly  {
        updateCodeAddress(newCode);
    }
    function transfer(address to, uint256 amount) public delegatedOnly {
        ERC20.transfer(to, amount);
    }
}

和 Transparent Proxy Pattern 相比,UUPS 的好处主要是部署 Proxy 合约时更省 gas。 由于 UUPS Proxy 合约中不包含升级相关代码,所以部署 UUPS Proxy 合约所消耗的 gas 比部署 Transparent Proxy Pattern 的 Proxy 合约所消耗的 gas 要更少。这对于需要多个 Proxy 合约的场景(比如智能合约钱包,每个钱包都对应一个 Proxy 合约),则会节省很多 gas。

此外,UUPS “可升级合约”可以主动变为“不可升级合约”。由于升级相关代码位于 Implementation 合约(或称 Logic 合约)中,开发者可以在某次升级时把 Implementation 合约中的升级相关代码去掉。从此后,合约将变得不再可升级。

2.3.2. Diamond, Multi-Facet Proxy

Diamond, Multi-Facet Proxy (EIP-2532) 是另一种可升级合约架构,它可以实现对单个函数的升级,稍微复杂一些,这里不介绍。

2.3.3. 3 种架构的比较

对于前面介绍的这 3 种可升级合约架构的比较如表 1 所示(摘自:https://blog.logrocket.com/using-uups-proxy-pattern-upgrade-smart-contracts/#comparing-proxy-patterns )。

Table 1: Comparing proxy patterns
Proxy pattern Pros Cons
Transparent proxy pattern Comparatively easy and simpler to implement; widely used Requires more gas for deployment, comparatively
UUPS proxy pattern Gas efficient; Flexibility to remove upgradeability Not as commonly used as it is fairly new; extra care is required for the upgrade logic (access control) as it resides in the implementation contract
Diamond proxy pattern Helps to battle the 24KB size limit via modularity; incremental upgradeability More complex to implement and maintain; uses new terminologies that can be harder for newcomers to understand; as of this writing, not supported by tools like Etherscan

3. 可升级逻辑合约的一些限制

Implementation 合约(也就是逻辑合约)要可升级,有一些限制。下面将介绍。

参考:https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable

3.1. 不能使用构造函数(使用 initialize)

可升级逻辑合约中不能使用构造函数,原因如下:

In Solidity, code that is inside a constructor or part of a global variable declaration is not part of a deployed contract’s runtime bytecode. This code is executed only once, when the contract instance is deployed. As a consequence of this, the code within a logic contract’s constructor will never be executed in the context of the proxy’s state. To rephrase, proxies are completely oblivious to the existence of constructors.

也就是说逻辑合约的构造函数中对状态变量的修改并不会体现在 Proxy 合约中。比如某个逻辑合约的构造函数中修改了一个状态变量的值,但其实这个修改并不会体现在 Proxy 合约的状态变量中。所以,可升级逻辑合约中不能使用构造函数。

相应的解决办法是:把初始化相关工作放到另外一个函数(往往名为 initialize)中,在升级完逻辑合约后,马上调用一次 initialize 函数。这两个动作(更新 Proxy 合约中记录的逻辑合约地址、调用逻辑合约的 initialize 函数),往往实现在一个名为 function upgradeToAndCall(address, bytes memory) external payable; 的函数中。

pragma solidity ^0.6.0;

contract MyContract {
    uint256 public x;
    bool private initialized;

    function initialize(uint256 _x) public {
        require(!initialized, "Contract instance has already been initialized");  // 为了模拟构造函数的语义,我们需要保证 initialize 只被执行一次
        initialized = true;
        x = _x;
    }
}

由于这个模式很常用,相关的工作在 OpenZeppelin 已经实现,可以直接使用它:

pragma solidity ^0.6.0;

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

contract MyContract is Initializable {
    uint256 public x;

    function initialize(uint256 _x) public initializer { // 修饰符 initializer 保证其只被执行一次
        x = _x;
    }
}

参考:https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies#the-constructor-caveat

3.1.1. 不要忘记调用 Base 合约中的 initialize

如果合约涉及继承,使用构造函数的话,Solidity 会自动调用每个合约的构造函数。

当使用 initialize 时,需要自己调用各个 Base 合约中的 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;
    }
}

3.2. 状态变量声明中不能有初始值

Solidity 允许在声明状态变量时,设置初始值:

contract MyContract {
    uint256 public hasInitialValue = 42;       // 可升级合约中不能使用带有初始值的状态变量
}

声明状态变量时设置初始值等效于在构造函数中设置初始值。前面提到过可升级合约中不能使用构造函数,同样的, “可升级合约”中不能使用带有初始值的状态变量。

相应的解决办法很简单,放到 initialize 函数中即可,如:

pragma solidity ^0.6.0;

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

contract MyContract is Initializable {
    uint256 public hasInitialValue;

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

需要说明的是,“可升级合约”中是可以包含 constant 变量,因为它们不会占用 Storage 的 Slot。

contract MyContract {
    // 这是 constant 变量,不会占用 Storage 的 Slot,编译时直接展开
    uint256 public constant hasInitialValue = 42;   // constant 修改后,可以在可升级合约中
}

参考:https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#avoid-initial-values-in-field-declarations

3.3. 修改合约时的一些限制

在编写新版本的逻辑合约时:

  1. 不能更改状态变量的类型
  2. 不能更改声明状态的顺序
  3. 不能删除现有状态变量
  4. 不能在现有状态变量之前引入新的状态变量

能对状态变量做的修改,差不多就仅是 “在以前状态变量后面增加新的状态变量了”。

3.3.1. 其它一些限制

如果合约涉及到继承,那么不能在 Base 合约中增加状态变量。

假设升级前,合约为:

contract Base {
    uint256 base1;
}

contract Child is Base {
    uint256 child;
}

升级后,想改为:

contract Base {
    uint256 base1;
    uint256 base2;    // Base 合约中增加状态变量,这是有问题的!
}

这是有问题的,因为升级前后 Slot 冲突了:

+---------+----------------+---------------+
| slot    | before upgrade | after upgrade |
+---------+----------------+---------------+
| slot #0 |   base1        |   base1       |
| slot #1 |   child        |   base2       |
| slot #2 |                |   child       |
+---------+----------------+---------------+

升级后的合约中,使用变量 base2 会访问到旧合约中 child 的值;而新合约中使用变量 child 会得到空。

其它的一些限制,可以参考:https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable

Author: cig01

Created: <2020-08-01 Sat>

Last updated: <2023-03-07 Tue>

Creator: Emacs 27.1 (Org mode 9.4)