# Solidity学习-可升级合约(Transparent/UUPS/Beacon) **Published by:** [rbtree](https://paragraph.com/@rbtree/) **Published on:** 2022-09-08 **URL:** https://paragraph.com/@rbtree/solidity-transparent-uups-beacon ## Content 以太坊合约原生并不支持升级,目前的升级一般是采用代理模式实现的。代理模式在代理模式中,有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 TransparentTransparent可升级合约在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 UUPSUUPS的名字来自于EIP-1822(Universal Upgradeable Proxy Standard)。上面介绍的Transparent模式把升级函数放在了Proxy合约中,而UUPS则是把升级函数放在Implementation合约中。因此该模式中,不需要再像Transparent模式那样区分管理员升级和普通函数调用,Proxy直接把所有的请求都通过delegatecall丢给Implementation(如果是升级,Implementation的升级函数会确认一下是否为管理员),因此不再需要ProxyAdmin。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可升级合约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都要进行一次升级。 ## Publication Information - [rbtree](https://paragraph.com/@rbtree/): Publication homepage - [All Posts](https://paragraph.com/@rbtree/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@rbtree): Subscribe to updates