升级智能合约
使用 OpenZeppelin Upgrades Plugins 部署的智能合约可以被升级以修改其代码,同时保留其地址、状态和余额。这允许您迭代地向您的项目添加新功能,或者修复您在 生产环境 中可能发现的任何错误。
在本指南中,我们将学习:
升级的意义
Ethereum 中的智能合约默认是不可变的。一旦您创建了它们,就没有办法更改它们,实际上充当参与者之间不可破坏的合约。
然而,对于某些场景,能够修改它们是可取的。想象一下双方之间的传统合同:如果他们都同意更改它,他们就可以这样做。在 Ethereum 上,他们可能希望更改智能合约以修复他们发现的错误(这甚至可能导致黑客窃取他们的资金!),添加其他功能,或者只是更改它所执行的规则。
以下是修复您无法升级的合约中的错误所需执行的操作:
-
部署合约的新版本
-
手动将所有状态从旧合约迁移到新合约(这在 Gas 费用方面可能非常昂贵!)
-
更新所有与旧合约交互的合约以使用新合约的地址
-
联系您的所有用户并说服他们开始使用新的部署(并处理同时使用的两个合约,因为用户迁移速度很慢)
为了避免经历这种混乱,我们将合约升级直接构建到我们的插件中。这使我们可以*更改合约代码,同时保留状态、余额和地址*。让我们看看它的实际效果。
使用 Upgrades Plugins 进行升级
每当您在 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,我们使用 脚本 来部署可升级合约。
我们将创建一个脚本来使用 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
复制到新合约。
让我们通过调用新的 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 作为 代理 的管理员。
这里,代理 是一个简单的合约,它只是将所有调用*委托*给一个实现合约。*委托调用*类似于常规调用,不同之处在于所有代码都在调用者的上下文中执行,而不是在被调用者的上下文中执行。因此,实现合约代码中的 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
时)并提供我们要使用的管理员地址。
// 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();
对于所有实际目的而言,初始化器充当构造函数。但是,请记住,由于它是一个常规函数,因此您需要手动调用所有基合约的初始化器(如果有)。
您可能已经注意到,我们既包括构造函数,也包括初始化器。此构造函数的作用是使实现合约处于已初始化状态,从而缓解某些潜在的攻击。
要了解更多关于编写可升级合约时的注意事项,请查看我们的 编写可升级合约 指南。
升级
由于技术限制,当您将合约升级到新版本时,您无法更改该合约的存储布局。
这意味着,如果您已经在合约中声明了一个状态变量,则您无法删除它、更改其类型或在其之前声明另一个变量。在我们的 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 会在您尝试升级时发出警告。
要了解更多关于此限制的信息,请访问 修改您的合约 指南。
测试
为了测试可升级合约,我们应该为实现合约创建单元测试,同时创建更高级别的测试以测试通过代理的交互。我们可以像部署时一样在我们的测试中使用 deployProxy
。
当我们想要升级时,我们应该为新的实现合约创建单元测试,同时在升级后使用 upgradeProxy
创建更高级别的测试,以测试通过代理的交互,检查状态是否在升级过程中保持不变。