所有以太坊开发者都清楚以太坊世界的一条铁律:合约一旦发布就无法修改。因此,对于合约的发布基本上都采用一种慎之又慎的态度,期望在发布前可以做到尽善尽美,力争合约能正常运行一万年。
可是,智者千虑必有失,合约发布百分百不出问题几乎是不可能任务。一些小问题或许还可以通过类似口头约定的方式让大家克服克服,但对于重大问题,恐怕就不得不重新发布新版了。于是乎,一系列连带更新也随之而来:合约调用方、封装合约的 SDK/API 方……搞不好还会牵涉到下一级的连带更新。比如,调用该合约的合约将地址硬编码到代码里且没有提供 setter 来改变该值……太麻烦啦!
鉴于此,可升级合约的呼声越来越高,同时也衍生了各类方案。
“可升级”意味着可修改,这似乎与以太坊强调的 immutable 相矛盾。但让我们再深入思考一下“可升级”的内涵:
编程经验丰富的老兵此时应该会拍大腿大声叫道:引入一个中间层就可以做到! 的确如此,可升级合约技术方案的本质就是:proxy + implementation 的分离,见下图:
其中:
fallback
+ delegatecall
将调用转发给 implementation 即可实现。可参见本系列的第二篇快速了解 solidity 语法。
使用 OpenZepplin Upgrade Plugin 可以让编写可升级合约的事情变得简单,并且考虑到 OpenZepplin 已成为合约开发中事实上的标准库以及编写可升级合约的种种限制,建议无脑采用,最简例子见下:
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;
}
}
// 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,必须遵循一定的规矩。
原因在于两点:
因此,可以看到,在上面的例子中都没有使用构造函数,转而使用所谓的 initialize()
来完成初始化。同时,为了保证该函数只运行一次,还使用了 OpenZepplin 提供的 initializer
modifier。
同理,也不要使用初始化声明,即类似下面的语句:
uint256 public hasInitialValue = 42; // X
但是,constant
例外,即以下语句没有问题:
uint256 public constant hasInitialValue = 42 // √
原因:见上。代码实现的注意点:
Initializable
initializer
modifier_disableInitializers()
,这主要是出于安全考虑。这时构造函数为:/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
原因依旧同 1。
对于父合约,同样不能有构造函数,所有的初始化代码需挪到 initialize()
中,只是此时不能使用 initializer
modifier,而需用 onlyInitializing
modifier 来代替。原因也很简单:若是前者,一旦被子合约的初始化函数调用,父合约的初始化函数就只能执行一次,显然不合继承的语义。
OpenZepplin 提供了 @openzeppelin/contracts-upgradeable 来帮助已经熟悉了 @openzeppelin/contracts 的开发人员来编写可升级合约。前者提供了后者合约的可升级版,如 ERC721Upgradeable.sol
对应 ERC721.sol
其中原因在于 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 变量。
delegatecall
和 selfdestruct
原因:当 implementation 地址已知后,其他第三方可以不通过 proxy 直接调用它。
虽然你可以在 implementation 里限制调用方的地址,但并不是所有情况下都可以这么做。因此避免危险操作是上策。
范围: import 的合约和 lib,确保它们可以正常工作于可升级场景。
除了 OpenZeppelin,还可以看看这个库 solidstate-solidity。正如其 readme 所言:Upgradeable-first Solidity smart contract development library . 未来或许有介绍它的专门文章。
proxy 是可升级合约的底层技术基础,了解其典型模式有助于更好地编程。典型的 proxy pattern 有:
OpenZeppelin 对于前三者提供了支持,暂时不支持 diamond。相比起前三者,diamond 更复杂并且野心也更大,期望提供一种通用的支持可扩展合约开发的架构模式,它在 solidstate 中得到了广泛的应用。但由于相对复杂,此文略过。
对于前三种:
在 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 的主要区别主要两点:
_authorizeUpgrade
。支撑 UUPS 的标准是 EIP1822,有兴趣的可以自行了解。此外,从 OpenZepplin 的接口文档和代码也可了解其细节。
关于 implementation 的地址保存,前文说过:它存放于 proxy 合约中。但同时,支撑 proxy 的技术基础又是 delegatecall。它的特性是执行的上下文是 caller 的上下文而非 callee 的上下文。即,任何状态的变化其实发生在 caller 的空间。
那么随之而来的问题是:如果 proxy 中自己有变量定义,同时将调用转发给 implementation 时又会保留它的状态,那么此时必然会导致有冲突。
EIP1967 便是为了解决这个问题,定义了一组标准存储槽来解决这个问题。本质上是对 proxy 中的变量存储槽进行了伪随机化处理。
即 proxy 和 implementation 中出现同名函数时,到底该不该转发?这可以通过 caller 来处理,以 Transparant Proxy 为例:
至此,关于可升级合约的基本要点已经罗列完成,剩下的就是去挖掘相关的代码和文档啦!
觉得有帮助的话,不妨考虑购买付费文章来支持我们 🙂 :
付费文章