前面的文章我们提到,合约升级的原理是将合约架构分为 代理合约
与 逻辑合约
,通过前面对于内存结构以及 delegatecall
的学习,我们已经基本掌握的合约升级的基础。这篇文章我们就从代码层面来看看合约升级到底应该如何实现。
这是我们在第一篇文章中的图例,当我们学习了内存结构以及 delegatecall
之后,我们再来看这幅图,就能够很好地理解了:数据都存放在代理合约的内存插槽中,而由于代理合约使用了 delegatecall
,因此函数执行都在逻辑合约中运行,修改的却是代理合约中的数据。这样就可以方便替换逻辑合约,实现合约升级。
首先我们考虑,对于代理合约而言,如何将请求转发到逻辑合约。最简单的方法就是对于逻辑合约中的每个函数,都在代理合约中包装一层,然后通过 delegatecall
来分别调用各个函数。这种方法是不可行的,因为既然我们都用到了可升级合约,那就说明我们后期会对合约做改动,我们总不能每添加一个函数,都在代理合约中添加一个包装层。一是这样很冗余,二是这样无法实现,因为代理合约也是区块链上的智能合约,它本身是不可变的。
这时我们想到,能不能够利用 Solidity 中的 fallback
与 receive
函数,它们的作用就是接收并处理一切未实现的函数(两者的区别是,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();
}
}
这段代码中,我们将 fallback
和 receive
函数都指向了 _delegate
函数,它会将有请求都转发给逻辑合约。乍一看没有什么问题,但是需要注意:
implementation
和 admin
,分别存储逻辑合约地址和管理员地址(管理员地址用户更换逻辑合约升级)。它们分别占据了插槽 0 和 1 的位置,那么这就有可能和我们的逻辑合约中的内存发生冲突,如果逻辑合约中有对这俩插槽的修改,那就直接把这两个很重要的变量给改掉了,那就乱套了。changeAdmin
等,如果逻辑合约中恰好也有这些方法,那么用户的请求就不会转发到逻辑合约。该如何解决这个问题呢?EIP-1967 提出了一个解决办法,它把 implementation
和 admin
这两个字段放在了两个特殊的插槽中:
# 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 提出了解决方案,它主要从两方面解决这个问题:
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);
// ......
}
此时整个合约的架构为:
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 库已经实现了完善的可升级合约库,我们在开发过程中可以直接使用现有的合约进行部署,避免重复造轮子出现错误。同时也提供了相应的文档以供参考。
欢迎和我交流