升级智能合约(Hardhat)

原文链接

使用 OpenZeppelin升级插件
部署的智能合约可以通过 升级
来修改代码,同时保留原合约地址、状态和余额。这让帮助我们为项目添加新功能,或修复在生产中可能发现的任何错误。
在本指南中,我们将学习:

  • 为什么升级很重要
  • 使用升级插件升级我们的盒子。
  • 了解升级在引擎盖下是如何工作的
  • 学习如何编写可升级合约

什么是可升级的合约

以太坊中的智能合约默认情况下是不可更改的。一旦创建了就无法改变,有效地为合约参与者扮演了不可篡改的合约的角色。
然而某些场景下,我们希望能够修改它们。想想传统合约:如果参与双方都同意改变它,就可以去对齐进行改变。同样在以太坊上,我们也希望能够修改智能合约,以修复他们发现的bug(这甚至可能导致黑客窃取他们的资金!),增加额外的功能,或者仅仅是改变它所执行的规则。
以下是你需要做的事情,以修复你无法升级的合约中的错误。

  1. 部署一个新版本的合约
  2. 手动将所有的状态从旧的合约迁移到新的合约(这可能是非常昂贵的gas费用!)
  3. 更新所有与旧合约交互的合约,使用新合约的地址
  4. 联系你的所有用户,并说服他们开始使用新的部署(并处理两个合约同时使用的问题,因为用户迁移速度较慢)

为了避免出现这种乱象,我们将合约升级直接内置到我们的插件中。这让我们可以 改变合约代码,同时保留状态、余额和地址
。让我们来看看如何实现。

使用升级插件来升级合约

使用 OpenZeppelin升级插件
中的 deployProxy
部署一个新的合约时,该合约实例就可以实现可 升级
的功能。默认情况下,只有最初部署合约的地址才有权限执行升级操作。

deployProxy
将创建以下事务;

  1. 部署执行合约(我们的 Box
    合约)

  2. 部署 ProxyAdmin
    合约(代理的管理员)

  3. 部署代理合约并运行初始化函数

让我们看看它是如何工作的,通过部署我们的 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
插件。

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升级插件
实际上部署了三个合约。

  1. 你写的合约,也就是所谓的包含 逻辑
    合约实现

  2. 一个 ProxyAdmin
    ,作为 代理
    的管理员。

  3. 一个指向 实现合约
    代理
    ,也就是你实际交互的合约。

在这里,代理是一个简单的合约,只是将所有的调用 委托
给一个实现合约。*委托调用(delegate call)*类似于普通的调用,只是所有的代码都是在调用者的上下文中执行的,而不是被调用者的上下文。正因为如此,在执行合约的代码中的 transfer
实际上会转transfer理的余额,对合约存储的任何读或写都会从代理自己的存储中读或写。

这使得我们可以将合约的状态和代码 解耦
:代理持有状态,而实现合约提供代码。而且它还允许我们改变代码,只需让代理委托给不同的实现合约即可。
升级则包括以下步骤。

  1. 部署新的实现合约
  2. 向代理发送一个事务,将其实现地址更新为新的实现地址。

注意 你可以让多个代理使用同一个实现合约,所以如果你计划部署同一个合约的多个副本,你可以使用这个模式来节省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,你有工具来修改你的合约并修复它。