原文链接
使用OpenZeppelin升级插件部署的智能合约可以通过升级来修改代码,同时保留原合约地址、状态和余额。这让帮助我们为项目添加新功能,或修复在生产中可能发现的任何错误。
在本指南中,我们将学习:
- 为什么升级很重要
- 使用升级插件升级我们的盒子。
- 了解升级在引擎盖下是如何工作的
- 学习如何编写可升级合约
什么是可升级的合约
以太坊中的智能合约默认情况下是不可更改的。一旦创建了就无法改变,有效地为合约参与者扮演了不可篡改的合约的角色。
然而某些场景下,我们希望能够修改它们。想想传统合约:如果参与双方都同意改变它,就可以去对齐进行改变。同样在以太坊上,我们也希望能够修改智能合约,以修复他们发现的bug(这甚至可能导致黑客窃取他们的资金!),增加额外的功能,或者仅仅是改变它所执行的规则。
以下是你需要做的事情,以修复你无法升级的合约中的错误。
- 部署一个新版本的合约
- 手动将所有的状态从旧的合约迁移到新的合约(这可能是非常昂贵的gas费用!)
- 更新所有与旧合约交互的合约,使用新合约的地址
- 联系你的所有用户,并说服他们开始使用新的部署(并处理两个合约同时使用的问题,因为用户迁移速度较慢)
为了避免出现这种乱象,我们将合约升级直接内置到我们的插件中。这让我们可以改变合约代码,同时保留状态、余额和地址。让我们来看看如何实现。
使用升级插件来升级合约
使用OpenZeppelin升级插件中的deployProxy
部署一个新的合约时,该合约实例就可以实现可升级的功能。默认情况下,只有最初部署合约的地址才有权限执行升级操作。
deployProxy
将创建以下事务;
- 部署执行合约(我们的
Box
合约) - 部署
ProxyAdmin
合约(代理的管理员) - 部署代理合约并运行初始化函数
让我们看看它是如何工作的,通过部署我们的Box
合约的可升级版本,使用与之前部署时相同的设置:
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Box {
uint256 private value;
// Emitted when the stored value changes
event ValueChanged(uint256 newValue);
// Stores a new value in the contract
function store(uint256 newValue) public {
value = newValue;
emit ValueChanged(newValue);
}
// Reads the last stored value
function retrieve() public view returns (uint256) {
return value;
}
}
首先需要安装升级插件(Upgrades Plugin)。
安装Hardhat Upgrades插件。
代码语言:javascript复制npm install --save-dev @openzeppelin/hardhat-upgrades
我们需要配置Hardhat使用我们的@openzeppelin/hardhat-upgrades
插件。可以通过在hardhat.config.js文件中添加以下代码来添加插件。
// hardhat.config.js
require('@nomiclabs/hardhat-ethers');
require('@openzeppelin/hardhat-upgrades');
module.exports = {
...
};
为了升级像Box
这样的合约,我们需要首先将其部署为一个可升级的合约,这与我们之前看到的部署过程不同。通过调用store
来初始化Box合约,其值为42。
Hardhat目前没有原生的部署系统,所以需要使用脚本来部署合约。
创建一个脚本,使用deployProxy
部署可升级的Box合约。把文件保存为scripts/deploy_upgradeable_box.js
。
// scripts/deploy_upgradeable_box.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const Box = await ethers.getContractFactory("Box");
console.log("Deploying Box...");
const box = await upgrades.deployProxy(Box, [42], { initializer: 'store' });
await box.deployed();
console.log("Box deployed to:", box.address);
}
main();
下面我们就可以部署我们的可升级的合约。
使用run
命令,可以部署Box
合约到development
网络。
$ npx hardhat run --network localhost scripts/deploy_upgradeable_box.js
All contracts have already been compiled, skipping compilation.
Deploying Box...
Box deployed to: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
我们可以通过Box
合约来retrieve
我们在初始化时存入的值。
我们使用Hardhat console来与升级合约Box
交互。
我们需要在部署Box
合约的时候指定代理合约的地址。
$ npx hardhat console --network localhost
All contracts have already been compiled, skipping compilation.
> const Box = await ethers.getContractFactory("Box")
undefined
> const box = await Box.attach("0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0")
undefined
> (await box.retrieve()).toString()
'42'
为了方便举例,假设我们想要添加一个新功能:在新版的Box
中创建一个自增函数,将存储的value
之加一。
// contracts/BoxV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract BoxV2 {
// ... code from Box.sol
// Increments the stored value by 1
function increment() public {
value = value 1;
emit ValueChanged(value);
}
}
在创建Solidity文件后,我们现在使用upgradeProxy
函数升级之前部署的实例。
upgradeProxy
将创建以下事务:
- 部署执行合约(我们的
BoxV2
合约) - 调用
ProxyAdmin
来更新代理合约以应用新的实现
创建一个脚本,使用 upgradeProxy
将 Box
合约升级为使用 BoxV2
。把这个文件保存为scripts/upgrade_box.js
。需要指定部署Box
合约时的代理合约地址。
// scripts/upgrade_box.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const BoxV2 = await ethers.getContractFactory("BoxV2");
console.log("Upgrading Box...");
const box = await upgrades.upgradeProxy("0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", BoxV2);
console.log("Box upgraded");
}
main();
然后就可以部署我们的可升级合约。
使用run
命令,可以在development
网络中部署升级Box
合约。
$ npx hardhat run --network localhost scripts/upgrade_box.js
All contracts have already been compiled, skipping compilation.
Box deployed to: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
完成!我们的Box
实例已经升级到了最新版本的代码,同时保持了它的状态和之前的地址。我们不需要在新的地址部署一个新的合约,也不需要手动将旧Box
的value
复制到新Box中。
通过调用新的increment
函数来尝试一下,并在检查value
值。
需要指定我们部署Box
合约时的代理合约地址。
$ npx hardhat console --network localhost
All contracts have already been compiled, skipping compilation.
> const BoxV2 = await ethers.getContractFactory("BoxV2")
undefined
> const box = await BoxV2.attach("0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0")
undefined
> await box.increment()
...
> (await box.retrieve()).toString()
'43'
就是这样! 请注意,在整个升级过程中,Box
的value
以及它的地址被保存下来了。而且无论你是在本地区块链,测试网,还是主网络上工作,这个过程都是一样的。
让我们来看看OpenZeppelin升级插件是如何实现的。
升级是如何工作的
这一节会比其他章节理论性更强:可以跳过,如果感兴趣再回来读。
当创建一个新的可升级合约实例时,OpenZeppelin升级插件实际上部署了三个合约。
- 你写的合约,也就是所谓的包含逻辑的合约实现。
- 一个
ProxyAdmin
,作为代理的管理员。 - 一个指向实现合约的代理,也就是你实际交互的合约。
在这里,代理是一个简单的合约,只是将所有的调用委托给一个实现合约。*委托调用(delegate call)*类似于普通的调用,只是所有的代码都是在调用者的上下文中执行的,而不是被调用者的上下文。正因为如此,在执行合约的代码中的transfer
实际上会转transfer理的余额,对合约存储的任何读或写都会从代理自己的存储中读或写。
这使得我们可以将合约的状态和代码解耦:代理持有状态,而实现合约提供代码。而且它还允许我们改变代码,只需让代理委托给不同的实现合约即可。
升级则包括以下步骤。
- 部署新的实现合约
- 向代理发送一个事务,将其实现地址更新为新的实现地址。
注意 你可以让多个代理使用同一个实现合约,所以如果你计划部署同一个合约的多个副本,你可以使用这个模式来节省gas。
智能合约的用户总是与代理进行交互,代理永远不会改变其地址。这使您可以推出升级或修复错误,而无需要求用户在他们的端部改变任何东西 - 他们只是一如既往地与相同的地址进行交互。
注意 如果你想了解更多关于OpenZeppelin代理的工作原理,请查看Proxies。
可升级合约的局限
虽然任何智能合约都可以进行升级,但Solidity语言的一些限制需要解决。在编写初始版本的合约和我升级新版本时,都会出现这些问题。
初始化
可升级合约不能有构造函数constructor
。为了帮助你初始化代码, OpenZeppelin Contracts提供了Initializable
基础合约,通过在方法上添加initializer
标签,确保只被初始化一次。
举例说明,我们通过initializer来写一个新版本的Box
合约,设置一个admin
为唯一一个可以修改内容的地址。
// contracts/AdminBox.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/proxy/Initializable.sol";
contract AdminBox is Initializable {
uint256 private value;
address private admin;
// Emitted when the stored value changes
event ValueChanged(uint256 newValue);
function initialize(address _admin) public initializer {
admin = _admin;
}
// Stores a new value in the contract
function store(uint256 newValue) public {
require(msg.sender == admin, "AdminBox: not admin");
value = newValue;
emit ValueChanged(newValue);
}
// Reads the last stored value
function retrieve() public view returns (uint256) {
return value;
}
}
部署合约时,我们需要指定initializer
函数名(只有当名字不是initialize
时需要),并提供一个管理员地址。
// scripts/deploy_upgradeable_adminbox.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const AdminBox = await ethers.getContractFactory("AdminBox");
console.log("Deploying AdminBox...");
const adminBox = await upgrades.deployProxy(AdminBox, ['0xACa94ef8bD5ffEE41947b4585a84BdA5a3d3DA6E'], { initializer: 'initialize' });
await adminBox.deployed();
console.log("AdminBox deployed to:", adminBox.address);
}
main();
出于实践目的,initializer作为构造函数。然而,请记住,由于它是一个常规函数,你将需要手动调用所有基础合约(base contract)的初initializer(如果有的话)。
要了解更多关于这一点以及编写可升级合约时的其他注意事项,请查看我们的Writing Upgradeable Contracts 指南。
升级
由于技术上的限制,当你将一个合约升级到新版本时,你不能改变该合约的存储布局(storage layout)。
这意味着,如果你已经在合约中声明了一个状态变量,你就不能删除它,不能改变它的类型,也不能在它之前声明其他变量。在我们的Box
例子中,这意味着我们只能在value
之后添加新的状态变量。
// contracts/Box.sol
contract Box {
uint256 private value;
// We can safely add a new variable after the ones we had declared
address private owner;
// ...
}
幸运的是,这种限制只影响状态变量。你可以随心所欲地改变合约的功能和事件。
注意 如果你不小心弄乱了合约的存储布局,当尝试升级时,升级插件提出警告。
前往Modifying Your Contracts 指南了解更多限制。
测试
为了测试可升级的合约,我们应该为实现合约创建单元测试,同时创建更高级别的测试,来测试与代理的交互。可以在测试中使用deployProxy
,就像我们部署时一样。
当要升级时,我们应该为新的实现合约创建单元测试,同时创建更高级别的测试,以便在升级后使用 upgradeProxy
通过代理测试交互,检查在升级过程中是否保持状态一致。
接下来的步骤
现在你已经知道如何升级智能合约,并且可以迭代开发你的项目,是时候把你的项目带到测试网和正式网中去了。你可以放心,如果出现bug,你有工具来修改你的合约并修复它。