本文介绍了使用 OpenZeppelin Upgrades Plugins 升级智能合约的方法,包括升级的重要性、如何使用插件升级合约、升级的工作原理以及编写可升级合约的注意事项。通过代理合约和实现合约的分离,实现了在保持合约地址、状态和余额不变的情况下修改合约代码。
使用 OpenZeppelin Upgrades Plugins 部署的智能合约可以被升级以修改其代码,同时保留其地址、状态和余额。这允许你迭代地向你的项目添加新功能,或者修复你可能在生产环境中发现的任何错误。
在本指南中,我们将学习:
以太坊中的智能合约默认是不可变的。一旦你创建了它们,就没有办法改变它们,实际上就像参与者之间不可破坏的合约。
然而,在某些情况下,能够修改它们是可取的。想想双方之间的传统合约:如果他们都同意更改它,他们就可以这样做。在以太坊上,他们可能希望更改智能合约以修复他们发现的错误(这甚至可能导致黑客窃取他们的资金!),添加额外的功能,或仅仅是更改它所执行的规则。
这里是你需要做的事情来修复一个你无法升级的合约中的错误:
部署一个新版本的合约
手动将所有状态从旧合约迁移到新合约(这在 gas 费用方面可能非常昂贵!)
更新所有与旧合约交互的合约,以使用新合约的地址
联系你所有的用户,并说服他们开始使用新的部署(并处理同时使用两个合约的情况,因为用户迁移速度很慢)
为了避免经历这种混乱,我们直接在插件中构建了合约升级。这允许我们更改合约代码,同时保留状态、余额和地址。让我们看看它是如何运作的。
无论何时你在 OpenZeppelin Upgrades Plugins 中使用 deployProxy
部署一个新合约,该合约实例以后都可以被升级。默认情况下,只有最初部署该合约的地址才有权升级它。
deployProxy
将创建以下交易:
部署实现合约(我们的 Box
合约)
部署代理合约并运行任何初始化函数。
ProxyAdmin
合约(我们代理的管理)。让我们通过部署我们 Box
合约的可升级版本来看看它是如何工作的,使用与我们之前部署时相同的设置:
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Box {
uint256 private _value;
// Emitted when the stored value changes
// 当存储的值更改时发出
event ValueChanged(uint256 value);
// Stores a new value in the contract
// 在合约中存储一个新值
function store(uint256 value) public {
_value = value;
emit ValueChanged(value);
}
// Reads the last stored value
// 读取最后存储的值
function retrieve() public view returns (uint256) {
return _value;
}
}
我们首先需要安装 Upgrades Plugin。
安装 Hardhat Upgrades 插件。
npm install --save-dev @openzeppelin/hardhat-upgrades
然后,我们需要配置 Hardhat 以使用我们的 @openzeppelin/hardhat-upgrades
插件。为此,请在你的 hardhat.config.js
文件中添加该插件,如下所示。
// hardhat.config.js
...
require("@nomicfoundation/hardhat-ethers");
require('@openzeppelin/hardhat-upgrades');
...
module.exports = {
...
};
为了升级像 Box
这样的合约,我们需要首先将其部署为可升级合约,这与我们目前所见的部署程序不同。我们将通过调用 store
并传入值 42 来初始化我们的 Box 合约。
使用 Hardhat,我们使用 scripts 部署可升级合约。
我们将创建一个脚本来使用 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.waitForDeployment();
console.log('Box deployed to:', await box.getAddress());
}
main();
然后我们可以部署我们的可升级合约。
使用 run
命令,我们可以将 Box
合约部署到 development
网络。
$ npx hardhat run --network localhost scripts/deploy_upgradeable_box.js
Deploying Box...
Box deployed to: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
然后,我们可以与我们的 Box
合约交互,以 retrieve
我们在初始化期间存储的值。
我们将使用 Hardhat console 与我们升级后的 Box
合约进行交互。
我们需要指定我们在部署 Box
合约时生成的代理合约的地址。
$ npx hardhat console --network localhost
Welcome to Node.js v20.17.0.
Type ".help" for more information.
> 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.8.0;
contract BoxV2 {
// ... code from Box.sol
//... Box.sol 中的代码
// Increments the stored value by 1
// 将存储的值递增 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...');
await upgrades.upgradeProxy('0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0', BoxV2);
console.log('Box upgraded');
}
main();
然后我们可以部署我们的可升级合约。
使用 run
命令,我们可以升级 development
网络上的 Box
合约。
$ npx hardhat run --network localhost scripts/upgrade_box.js
Compiled 1 Solidity file successfully (evm target: paris).
Upgrading Box...
Box upgraded
完成!我们的 Box
实例已升级到最新版本的代码,同时保持其状态和与之前相同的地址。我们不需要在新地址部署一个新合约,也不需要手动将 value
从旧的 Box
复制到新的 Box
。
让我们通过调用新的 increment
函数,并在之后检查 value
来尝试一下:
我们需要指定我们在部署 Box
合约时生成的代理合约的地址。
$ npx hardhat console --network localhost
Welcome to Node.js v20.17.0.
Type ".help" for more information.
> 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 Upgrades Plugins 是如何实现这一点的。
本节将比其他章节更偏重理论:如果你好奇,可以随意跳过它并在以后返回。
当你创建一个新的可升级合约实例时,OpenZeppelin Upgrades Plugins 实际上会部署三个合约:
你编写的合约,被称为实现合约,包含逻辑。
代理到实现合约,这是你实际与之交互的合约。
一个 ProxyAdmin 作为 proxy 的管理员。
这里,代理是一个简单的合约,它只是将所有调用委托给实现合约。委托调用类似于常规调用,但所有代码都在调用者的上下文中执行,而不是被调用者的上下文中执行。因此,实现合约代码中的 transfer
实际上会转移代理的余额,并且对合约存储的任何读取或写入都将从代理自己的存储中读取或写入。
这允许我们解耦合约的状态和代码:代理持有状态,而实现合约提供代码。它还允许我们通过仅让代理委托给不同的实现合约来更改代码。
然后,升级涉及以下步骤:
部署新的实现合约。
向代理发送一个交易,该交易将其实现地址更新为新的地址。
你可以使用相同的实现合约拥有多个代理,因此如果你计划部署同一合约的多个副本,则可以使用此模式来节省 gas 。 |
智能合约的任何用户始终与代理交互,代理的地址永远不会更改。这允许你推出升级或修复错误,而无需请求你的用户更改他们那边的任何内容 - 他们只是继续与始终相同的地址交互。
如果你想了解更多关于 OpenZeppelin 代理如何工作的信息,请查看 Proxies。 |
虽然任何智能合约都可以被制成可升级的,但 Solidity 语言的一些限制需要解决。这些限制出现在编写合约的初始版本和我们将要升级到的版本时。
可升级合约不能有 constructor
。为了帮助你运行初始化代码,OpenZeppelin Contracts 提供了 Initializable
基础合约,允许你将一个方法标记为 initializer
,确保它只能运行一次。
例如,让我们编写一个带有初始化的 Box
合约的新版本,存储 admin
的地址,该 admin
将是唯一允许更改其内容的人。
// contracts/AdminBox.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract AdminBox is Initializable {
uint256 private _value;
address private _admin;
// Emitted when the stored value changes
// 当存储的值更改时发出
event ValueChanged(uint256 value);
function initialize(address admin) public initializer {
_admin = admin;
}
/// @custom:oz-upgrades-unsafe-allow constructor
// /@custom:oz-upgrades-unsafe-allow 构造函数
constructor() initializer {}
// Stores a new value in the contract
// 在合约中存储一个新值
function store(uint256 value) public {
require(msg.sender == _admin, "AdminBox: not admin");
_value = value;
emit ValueChanged(value);
}
// Reads the last stored value
// 读取最后存储的值
function retrieve() public view returns (uint256) {
return _value;
}
}
在部署这个合约时,我们需要指定 initializer
函数名(只有当名称不是默认的 initialize
时)并提供我们要使用的 admin 地址。
// 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.waitForDeployment();
console.log('AdminBox deployed to:', await adminBox.getAddress());
}
main();
对于所有实际目的,initializer 充当构造函数。但是,请记住,由于它是一个常规函数,你将需要手动调用所有基础合约的 initializer(如果有)。
你可能已经注意到我们包括了一个构造函数以及一个 initializer。这个构造函数的作用是使实现合约保持在已初始化状态,这是一种针对某些潜在攻击的缓解措施。
要了解更多关于编写可升级合约的这个和其他注意事项,请查看我们的 Writing Upgradeable Contracts 指南。
由于技术限制,当你将合约升级到新版本时,你不能更改该合约的存储布局。
这意味着,如果你已经在你的合约中声明了一个状态变量,你不能删除它,更改它的类型,或者在其之前声明另一个变量。在我们的 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;
// ...
}
幸运的是,这个限制只影响状态变量。你可以随意更改合约的函数和事件。
如果你不小心弄乱了合约的存储布局,Upgrades Plugins 会在你尝试升级时警告你。 |
要了解更多关于这个限制的信息,请访问 Modifying Your Contracts 指南。
要测试可升级合约,我们应该为实现合约创建单元测试,同时为通过代理测试交互创建更高级别的测试。我们可以像部署时一样在我们的测试中使用 deployProxy
。
当我们想要升级时,我们应该为新的实现合约创建单元测试,同时为在使用 upgradeProxy
升级后测试通过代理的交互创建更高级别的测试,检查状态是否在升级过程中保持不变。
在学习如何升级合约时,你可能会发现自己处于本地环境中合约冲突的情况。 要解决此问题,请考虑使用以下步骤:
停止使用 npx hardhat node
运行的节点 ctrl+C。执行清理:npx hardhat clean
。
现在你已经知道如何升级你的智能合约,并且可以迭代地开发你的项目,现在是将你的项目带到 testnet 和 production 的时候了!你可以放心,如果出现错误,你有工具来修改你的合约并更改它。
- 原文链接: docs.openzeppelin.com/le...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!