以太坊合约原生并不支持升级,目前的升级一般是采用代理模式实现的。
在代理模式中,有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。
在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版本的地址。
UUPS的名字来自于EIP-1822(Universal Upgradeable Proxy Standard)。上面介绍的Transparent模式把升级函数放在了Proxy合约中,而UUPS则是把升级函数放在Implementation合约中。因此该模式中,不需要再像Transparent模式那样区分管理员升级和普通函数调用,Proxy直接把所有的请求都通过delegatecall丢给Implementation(如果是升级,Implementation的升级函数会确认一下是否为管理员),因此不再需要ProxyAdmin。
普通函数的调用基本和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具有更大的灵活性。
和前面2种模式不同,Beacon模式的Implementation地址并不存放在Proxy合约里,而是存放在Beacon合约里,Proxy合约里存放的是Beacon合约的地址。
在合约交互的时候,用户同样是和Proxy合约打交道,不过此时因为Proxy合约中并未保存Implementation地址,所以它要先访问Beacon合约获取Implementation地址,然后再通过delegatecall调用Implementation。
在合约升级的时候,管理员并不需要和Proxy合约打交道,而只需要交互Beacon合约,把Beacon合约存储的Implementation改掉就行了。
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都要进行一次升级。