所有以太坊开发者都清楚以太坊世界的一条铁律:合约一旦发布就无法修改。因此,对于合约的发布基本上都采用一种慎之又慎的态度,期望在发布前可以做到尽善尽美,力争合约能正常运行一万年。
可是,智者千虑必有失,合约发布百分百不出问题几乎是不可能任务。一些小问题或许还可以通过类似口头约定的方式让大家克服克服,但对于重大问题,恐怕就不得不重新发布新版了。于是乎,一系列连带更新也随之而来:合约调用方、封装合约的 SDK/API 方……搞不好还会牵涉到下一级的连带更新。比如,调用该合约的合约将地址硬编码到代码里且没有提供 setter 来改变该值……太麻烦啦!
鉴于此,可升级合约的呼声越来越高,同时也衍生了各类方案。
什么是可升级合约
“可升级”意味着可修改,这似乎与以太坊强调的 immutable 相矛盾。但让我们再深入思考一下“可升级”的内涵:
- 合约地址不能变
- 合约状态不能丢失
- 合约的行为可变
编程经验丰富的老兵此时应该会拍大腿大声叫道:引入一个中间层就可以做到! 的确如此,可升级合约技术方案的本质就是:proxy + implementation 的分离,见下图:
其中:
- proxy 作为调用方和实现方的中间人使“地址不变”成为可能。
- 将 implementation 的状态保存于 proxy 中使“状态不丢失”成为可能,这一点只需在 proxy 中使用
fallback
+delegatecall
将调用转发给 implementation 即可实现。 - 可动态注入不同的 implementation 使得“行为可变”成为可能。
可参见本系列的第二篇快速了解 solidity 语法。
How-To
使用 OpenZepplin Upgrade Plugin 可以让编写可升级合约的事情变得简单,并且考虑到 OpenZepplin 已成为合约开发中事实上的标准库以及编写可升级合约的种种限制,建议无脑采用,最简例子见下:
- 合约 v1
pragma solidity ^0.8.9;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract MyContract is Initializable {
uint256 public x;
function initialize(uint256 _x) public initializer {
x = _x;
}
}
- 合约 v2
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;
import "./MyContract.sol";
contract MyContractV2 is MyContract {
uint256 public y;
}
- 部署脚本
import { ethers, upgrades } from "hardhat";
async function main() {
const MC = await ethers.getContractFactory("MyContract");
const mc = await upgrades.deployProxy(MC, [42]);
await mc.deployed();
console.log("MyContract deployed to:", mc.address);
}
main();
- 升级脚本
import { ethers, upgrades } from "hardhat";
const MC_ADDRESS = "部署脚本显示的地址";
async function main() {
const MCV2 = await ethers.getContractFactory("MyContractV2");
await upgrades.upgradeProxy(MC_ADDRESS, MCV2);
console.log("MyContract upgraded");
}
main();
注意事项:
-
记得在
hardhat.config.ts
中引入下面语句完成初始化。import "@openzeppelin/hardhat-upgrades";
-
上述脚本需要 network 参数,即至少要运行本地测试网络:
npx hardhat node
编程限制
编写可升级合约并不是 free style,必须遵循一定的规矩。
限制 1:跟构造函数 say no
原因在于两点:
- 从语言限制上来讲,构造函数在合约部署后不属于合约的 runtime bytecode,可简单理解为部署后就消失不见了。
- 从逻辑上来讲,构造函数的执行应该只有一次,即使在升级的背景下,也应遵循这个原则。但是,升级合约的实质是“部署并替换”,这种情况下无法保证这一点。
因此,可以看到,在上面的例子中都没有使用构造函数,转而使用所谓的 initialize()
来完成初始化。同时,为了保证该函数只运行一次,还使用了 OpenZepplin 提供的 initializer
modifier。
同理,也不要使用初始化声明,即类似下面的语句:
uint256 public hasInitialValue = 42; // X
但是,constant
例外,即以下语句没有问题:
uint256 public constant hasInitialValue = 42 // √
限制 2:initialize() 只能执行一次
原因:见上。代码实现的注意点:
- 合约继承
Initializable
- 使用
initializer
modifier - 使用依赖注入来获得灵活性,上例就是如此,避免在该函数中使用硬编码。
- 在合约构造函数中调用
_disableInitializers()
,这主要是出于安全考虑。这时构造函数为:
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
限制 3:父合约的初始化也遵循 1
原因依旧同 1。
对于父合约,同样不能有构造函数,所有的初始化代码需挪到 initialize()
中,只是此时不能使用 initializer
modifier,而需用 onlyInitializing
modifier 来代替。原因也很简单:若是前者,一旦被子合约的初始化函数调用,父合约的初始化函数就只能执行一次,显然不合继承的语义。
OpenZepplin 提供了 @openzeppelin/contracts-upgradeable 来帮助已经熟悉了 @openzeppelin/contracts 的开发人员来编写可升级合约。前者提供了后者合约的可升级版,如 ERC721Upgradeable.sol
对应 ERC721.sol
限制 4:可兼容的存储布局
其中原因在于 solidity 的语言技术细节,未来会有专文细说。在此只需记住以下规则:相对于老版本合约,
- 新版本合约中的变量声明
- 只增不删
- 顺序不变
- 类型不变
- 当继承多个合约时,新版本的继承顺序不变
- 父合约中的变量声明同样需要遵循:
- 顺序不变
- 类型不变
注意
规则 3 于 1 的区别:没有“只增不删”!
其原因很容易理解,因为在父合约中新增变量后会破坏子合约的存储布局。但问题是父合约本身也会演化,必然也有新增变量的需求。为了解决这个问题,可以使用 storage gap 的技巧来解决。说白了,就是:预留存储。
// v1
contract Base {
uint256 base1;
uint256[49] __gap;
}
// v2
contract Base {
uint256 base1;
uint256 base2;
uint256[48] __gap;
}
上述代码中,v1 和 v2 的 Base 是存储布局兼容的。
注意
变量类型的长度关系重大,若使用 uint128,则可用两个。即:用连续两个 uint128 变量替代一个 uint256 变量。
限制 5:不要在子合约使用危险操作,如 delegatecall
和 selfdestruct
原因:当 implementation 地址已知后,其他第三方可以不通过 proxy 直接调用它。
虽然你可以在 implementation 里限制调用方的地址,但并不是所有情况下都可以这么做。因此避免危险操作是上策。
限制 6:确保使用可升级库
范围: import 的合约和 lib,确保它们可以正常工作于可升级场景。
除了 OpenZeppelin,还可以看看这个库 solidstate-solidity。正如其 readme 所言:Upgradeable-first Solidity smart contract development library . 未来或许有介绍它的专门文章。
Proxy Patterns
proxy 是可升级合约的底层技术基础,了解其典型模式有助于更好地编程。典型的 proxy pattern 有:
- Transparent Proxy
- UUPS
- Beacon
- Diamond
OpenZeppelin 对于前三者提供了支持,暂时不支持 diamond。相比起前三者,diamond 更复杂并且野心也更大,期望提供一种通用的支持可扩展合约开发的架构模式,它在 solidstate 中得到了广泛的应用。但由于相对复杂,此文略过。
对于前三种:
- Beacon 的应用范围不如前两种广泛,但它支持不同代理升级到不同实现;
- 但个人认为,若真是有这样的需求,不如直接采用 diamond 可能更好。
- Transparent Proxy 拥有更长的历史,OpenZeppelin 的可升级库最早基于它完成。
- UUPS 则属于后起之秀,相比 Transparent Proxy,它更轻量也更通用,这也意味着它的升级逻辑更便宜。因此 OpenZeppelin 推荐优先使用它。
在 OpenZeppelin 合约库中,三种 proxy pattern 都有对应的实现,并且文档也提供了相应的示例和部署/升级脚本,在此就不再赘述。由于文档中并没有给出 UUPS 的范例,这里简单的描述一下。针对前面的例子:
pragma solidity ^0.8.9;
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyContractUups is UUPSUpgradeable, OwnableUpgradeable {
uint256 public x;
function initialize(uint256 _x) public initializer {
x = _x;
__Ownable_init();
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
其他的 v2 合约和部署/更新几乎一样。
Transparent Proxy、UUPS 和 Beacon 的主要区别主要两点:
- 是否需要 proxy admin
- 在 Transparent Proxy 中,该组件负责完成 upgrade 逻辑。但 UUPS 和 Beacon 中都没有它。因此,Transparent Proxy 存在有 admin owner 的概念,同时其 ownership 也可以转移。
- UUPS 的升级逻辑由 implementation 完成,可以看到上面的代码示例中,它覆盖了
_authorizeUpgrade
。 - Beacon 的升级逻辑则由 beacon 的 owner 完成。
- 在哪存放 implementation 的地址
- Transparent Proxy 和 UUPS 都将该地址存在 proxy 合约中。
- Beacon 则将其存放在 beacon 合约里。
其余细节
EIP1882
支撑 UUPS 的标准是 EIP1822,有兴趣的可以自行了解。此外,从 OpenZepplin 的接口文档和代码也可了解其细节。
EIP1967
关于 implementation 的地址保存,前文说过:它存放于 proxy 合约中。但同时,支撑 proxy 的技术基础又是 delegatecall。它的特性是执行的上下文是 caller 的上下文而非 callee 的上下文。即,任何状态的变化其实发生在 caller 的空间。
那么随之而来的问题是:如果 proxy 中自己有变量定义,同时将调用转发给 implementation 时又会保留它的状态,那么此时必然会导致有冲突。
EIP1967 便是为了解决这个问题,定义了一组标准存储槽来解决这个问题。本质上是对 proxy 中的变量存储槽进行了伪随机化处理。
函数冲突
即 proxy 和 implementation 中出现同名函数时,到底该不该转发?这可以通过 caller 来处理,以 Transparant Proxy 为例:
- 若是 admin,则不转发
- 否则,总是转发
总结
至此,关于可升级合约的基本要点已经罗列完成,剩下的就是去挖掘相关的代码和文档啦!