Solidity学习-可升级合约(Transparent/UUPS/Beacon)
September 8th, 2022

以太坊合约原生并不支持升级,目前的升级一般是采用代理模式实现的。

代理模式
代理模式

在代理模式中,有2个合约:Proxy和Implementation,用户总是和Proxy进行交互。Proxy在收到用户的调用请求后,并不执行自身的代码,而是通过delegatecall去执行Implementation合约的代码。delegatecall的特殊之处就是,它并不切换上下文,因此Implementation的代码所处理的存储空间是Proxy合约的存储空间,而非自己的。

Proxy合约里存储了Implementation合约的地址,这个地址是可修改的,当我们需要进行合约升级的时候,只需要重新部署一个新的Implementation合约,同时把Proxy合约中存储的地址改成新合约的地址就行了。升级前后,合约的存储空间没有任何变化(依然是Proxy的存储空间),地址也没有变化(依然是Proxy的地址),因此升级过程对用户完全透明。

合约升级之后,要保证storage变量的兼容性。新版本可以在旧版本的变量之后增加新的变量,但是不可以删除或者修改旧版本的变量。因为自始自终,变量都只存储在Proxy合约里,升级之后,storage变量的值是不会改变的。

下面简单看一下Proxy是如何把用户请求转发到Implementation合约的(为了方便说明对代码做了一些简化)。

我们的Proxy合约是不必写出Implementation里的接口的(而且也没法写,因为后续升级可能会增加接口)。EVM有一个特性——如果调用的函数名在合约中不存在,那么会调用合约的fallback函数。因此当用户与合约交互时,会首先进入fallback中。我们看到fallback首先通过 _implementation函数拿到了implementation合约的地址,然后通过_delegate函数进行了delegatecall。_delegate的代码是用汇编YUL实现的,本文不对此进行深究。

contract Proxy {
    fallback () external payable virtual {
        _delegate(_implementation());
    }

    function _implementation() internal view virtual returns (address);

    function _delegate(address implementation) internal virtual {
        // solhint-disable-next-line no-inline-assembly
        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()) }
        }
    }
}

上面只是简单说明了一下原理,如果具体考虑可升级合约的实现细节,还有几个问题需要考虑。

(1)implementation合约的地址存放在何处,后续合约不断升级可能会用到更多空间,是否可能和存放合约地址的空间相冲突?

(2)合约升级的逻辑代码放在何处?通过何种方式安全触发?

对这两个问题的不同回答产生了不同的可升级合约范式,本文将介绍3种:transparent,uups,beacon。

1 Transparent

Transparent可升级合约
Transparent可升级合约

在Transparent模式中,除了Proxy和Implementation,还有第3个合约——ProxyAdmin,这个合约是专门用于合约升级的,它只能被管理员Admin调用。

如果Proxy合约发现自己被ProxyAdmin合约调用,那么它会调用自身的函数代码;如果调用者是ProxyAdmin之外的账户,那么它会通过delegatecall去调用Implementation的代码。这样就保障了合约升级的安全性。

在代码中,是通过下面的修饰符ifAdmin实现的,Proxy合约的所有外部函数都被加上了这样一个修饰符。如果调用者是ProxyAdmin会正常执行,如果调用者是其他人,那么会直接进入fallback。我们在上面已经解释过,fallback会通过delegatecall去调用Implementation的代码。

modifier ifAdmin() {
    if (msg.sender == _getAdmin()) {
        _;
    } else {
        _fallback();
    }
}

还有一个问题需要解决:Implementation的地址存放在何处?

这个存放的位置关键是不能和合约存放其他数据的位置冲突。定长数据类型在合约storage里是从slot0开始依次存放的,如果只有定长数据类型,那么我们选一个编号足够大的slot即可。不过变长数据类型(变长array和map)的存放位置是通过hash算出来的,所以理论上无论我们把Implementation的地址放在何处,都没有办法百分百避免碰撞的可能。不过在实践中,由于storage有2的256次方个slot,通过hash随机算出的地址冲突的概率小到可以忽略。

在openzeppelin给出的代码中,选用的存放位置是:

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

对于transparent合约而言,还需要存放ProxyAdmin的地址

bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1)

下面我们看一个简单的例子,来体会一下transparent可升级合约的部署、调用、升级过程。

观察下面的代码,可以发现一个很明显的特点是:它并不在构造函数中实现变量初始化,而是在initialize中初始化。因为对于代理模式而言,只有Proxy合约的storage是有效的,Implementation的storage是不被使用过的。在我们部署Implementation的时候,如果写了构造函数,那么它修改的是Implementation合约的storage,并不能达到我们想要的目的。所以我们必须在部署合约之后,通过Proxy调用initialize来初始化。

下面的代码是V1和V2版本的Implementation,我们先部署V1,然后升级到V2。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract TransparentV1 is Initializable {
    event IncreaseV1();

    uint public val;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(uint _val) public initializer {
        val = _val; // set initial value in initializer
    }

    function increase() public {
        val += 1;
        emit IncreaseV1();
    }
}

contract TransparentV2 is Initializable {
    event IncreaseV2();

    uint public val;

    function increase() public {
        val += 2;
        emit IncreaseV2();
    }
}

(1)合约部署

首先部署implementation合约。

然后部署ProxyAdmin合约,部署账户自动成为ProxyAdmin的管理员。

最后部署Proxy合约,部署时会把Proxy的Implementation地址和ProxyAdmin地址设置为上面两个合约的地址,同时调用Implementation的initialize函数进行初始化。

(2)合约调用

用户直接和Proxy合约交互,然后Proxy通过delegatecall调用Implementation合约。(其实上一步调用initialize函数也是通过这个逻辑实现的,不同的是initialize函数加了initializer修饰符,只能调用一次)。

(3)合约升级,V1→V2

首先需要部署V2的Implementation合约。

然后,管理员账户通过ProxyAdmin调用Proxy的upgradeTo函数进行升级。升级之后,Proxy中的存储的Implementation的V1版本地址会被改成V2版本的地址。

2 UUPS

UUPS的名字来自于EIP-1822(Universal Upgradeable Proxy Standard)。上面介绍的Transparent模式把升级函数放在了Proxy合约中,而UUPS则是把升级函数放在Implementation合约中。因此该模式中,不需要再像Transparent模式那样区分管理员升级和普通函数调用,Proxy直接把所有的请求都通过delegatecall丢给Implementation(如果是升级,Implementation的升级函数会确认一下是否为管理员),因此不再需要ProxyAdmin。

UUPS可升级合约
UUPS可升级合约

普通函数的调用基本和Transparent一样,所以我们重点关注一下upgradeTo的实现。

因为需要实现upgradeTo,所以和Transparent相比,UUPS的Implementation合约会复杂一些,openzeppelin的UUPSUpgradeable给出了实现,我们写自己的Implementation时继承这个合约即可。在我们自己的合约中,我们只需要实现_authorizeUpgrade函数以确保只有管理员账户可以进行升级。

其实这里并没有什么神秘的,具体的实现无非就是通过keccak256('eip1967.proxy.implementation')找到implementation地址的存储位置,修改为新地址而已,有兴趣的同学可以自行查看openzeppelin的完整代码研究。

abstract contract UUPSUpgradeable is Initializable, IERC1822ProxiableUpgradeable, ERC1967UpgradeUpgradeable {
    function upgradeTo(address newImplementation) external virtual onlyProxy {
        _authorizeUpgrade(newImplementation);
        _upgradeToAndCallUUPS(newImplementation, new bytes(0), false);
    }

    // implement it in your own contract
    function _authorizeUpgrade(address newImplementation) internal virtual;
}

接下来依然是看一个简单的例子。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract UUPSV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    event IncreaseV1();

    uint public val;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(uint _val) initializer public {
        val = _val;
        __Ownable_init();
        __UUPSUpgradeable_init();
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        onlyOwner
        override
    {}

    function increase() public {
        val += 1;
        emit IncreaseV1();
    }
}

contract UUPSV2 is Initializable, OwnableUpgradeable , UUPSUpgradeable {
    event IncreaseV2();

    uint public val;

    function _authorizeUpgrade(address newImplementation)
        internal
        onlyOwner
        override
    {}

    function increase() public {
        val += 2;
        emit IncreaseV2();
    }
}

(1)合约部署

首先部署Implementation合约。

然后部署Proxy合约,部署时会把Proxy的Implementation地址设置为上面合约的地址,同时调用Implementation的initialize函数进行初始化。

(2)合约调用

用户直接和Proxy合约交互,然后Proxy通过delegatecall调用Implementation合约。

(3)合约升级,V1→V2

首先需要部署V2的Implementation合约。

然后,管理员账户通过Proxy调用implementV1的upgradeTo函数进行升级。升级之后,Proxy中的存储的Implementation的V1版本地址会被改成V2版本的地址。(注意,虽然最终调用的是implementation的upgradeTo函数,但因为是delegatecall,所以修改的依然是Proxy的storage空间的内容。)

这一步正是UUPS和Transparent的核心区别所在。

Transparent VS UUPS

Transparent把升级逻辑放在Proxy中,而UUPS把升级逻辑放在implementation中,这样的差异会造成哪些使用体验上的不同呢?

(1)Transparent需要多部署一个ProxyAdmin合约,所以如果不考虑后续升级,那么Transparent是更消耗gas的。但是如果考虑后续升级,情况就不是这样了。因为对于UUPS而言,升级的逻辑代码需要出现在后续的每一个版本的Implementation合约中(否则合约将失去可升级特性),所以如果考虑到多次升级,反而是Transparent更加省gas。

(2)对于升级之外的合约交互,Transparent总是需要判断是否来自ProxyAdmin账户(否则它没法知道是升级还是普通交互),因此这里会多一次storage load操作。而UUPS是把包括升级在内的所有交互都无脑丢给implementation,所以不需要这个判断。因此,对于普通用户而言,UUPS总是会更省gas一些。

(3)刚才有说到,升级的逻辑代码需要出现在后续的每一个版本的Implementation合约中,但实际上也未必。有可能在某个版本,我们决定这就是最终版本,以后再也不会再升级了,那么我们就不给这个版本添加升级函数,这样一来可升级合约就变成了不可升级合约。而Transparent的可升级特性是不可取消的(当然可以有其他间接方式做到,比如放弃ProxyAdmin合约的管理权限),所以UUPS相对Transparent具有更大的灵活性。

3 Beacon

和前面2种模式不同,Beacon模式的Implementation地址并不存放在Proxy合约里,而是存放在Beacon合约里,Proxy合约里存放的是Beacon合约的地址。

在合约交互的时候,用户同样是和Proxy合约打交道,不过此时因为Proxy合约中并未保存Implementation地址,所以它要先访问Beacon合约获取Implementation地址,然后再通过delegatecall调用Implementation。

在合约升级的时候,管理员并不需要和Proxy合约打交道,而只需要交互Beacon合约,把Beacon合约存储的Implementation改掉就行了。

Beacon可升级合约
Beacon可升级合约

BeaconProxy的主要函数如下,注意_implementation()的实现,需要到Beacon合约中获取地址。

contract BeaconProxy is Proxy, ERC1967Upgrade {
    constructor(address beacon, bytes memory data) payable {
        assert(_BEACON_SLOT == bytes32(uint256(keccak256("eip1967.proxy.beacon")) - 1));
        _upgradeBeaconToAndCall(beacon, data, false);
    }

    function _beacon() internal view virtual returns (address) {
        return _getBeacon();
    }

    function _implementation() internal view virtual override returns (address) {
        return IBeacon(_getBeacon()).implementation();
    }
}

Implementation的地址存放在Beacon合约中,升级时调用upgradeTo改变Implementation地址。

contract UpgradeableBeacon is IBeacon, Ownable {
    address private _implementation;

    function implementation() public view virtual override returns (address) {
        return _implementation;
    }

    function upgradeTo(address newImplementation) public virtual onlyOwner {
        _setImplementation(newImplementation);
        emit Upgraded(newImplementation);
    }

    function _setImplementation(address newImplementation) private {
        require(Address.isContract(newImplementation), "UpgradeableBeacon: implementation is not a contract");
        _implementation = newImplementation;
    }
}

再看一个简单的例子。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";

contract ShipV1 is Initializable {
    event Move();

    string public name;
    uint public fuel;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(string calldata _name, uint _fuel) initializer public {
        name = _name;
        fuel = _fuel;
    }

    function move() public {
        require(fuel > 0, "no fuel");
        fuel -= 1;
        emit Move();
    }
}

contract ShipV2 is Initializable {
    event Move();
    event Refuel();

    string public name;
    uint256 public fuel;

    function move() public {
        require(fuel > 0, "no fuel");
        fuel -= 1;
        emit Move();
    }

    function refuel() public {
        fuel += 1;
        emit Refuel();
    }
}

(1)合约部署

首先部署Beacon合约,部署账户成为Beacon合约的管理员。

然后部署Proxy合约,部署时会把Proxy的Beacon地址设置为上面合约的地址。

同时在这个交易中,也会完成Implementation的部署和初始化,同时把Implementation的地址存入Beacon合约。

(在这里并没有先单独创建Implementation合约,而是在创建Proxy的时候直接把Implementation合约的creationCode带了进去。不过在我看来,这并非Beacon模式和另外两种模式的本质差异,完全可以不这样做。)

(2)合约调用

用户直接和Proxy合约交互,Proxy首先调用Beacon合约从中获得Implementation合约地址,然后通过delegatecall调用Implementation合约。

(3)合约升级,V1→V2

首先需要部署V2的Implementation合约。

然后管理员账户和Beacon合约交互,把Beacon合约所记录的Implementation地址改成新地址。

升级过程完全不涉及Proxy合约。

和另外两种模式相比,Beacon模式最大的不同就是它的Implementation地址并不直接存放在Proxy中,着看起来似乎有点故意给自己找麻烦。不过这种模式在一种场景下有优势,就是多个Proxy共享相同的Implementation、需要批量升级的场景。此时,如果想把所有Proxy都升级,那么升级Beacon就天然可以达到升级所有Proxy的效果。如果采用transparent或者UUPS模式,每个Proxy都要进行一次升级。

Subscribe to rbtree
Receive the latest updates directly to your inbox.
Nft graphic
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.
More from rbtree

Skeleton

Skeleton

Skeleton