15

升级智能合约(Hardhat)

 3 years ago
source link: https://learnblockchain.cn/article/1990
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

原文链接

使用 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 实例已经升级到了最新版本的代码, 同时保持了它的状态和之前的地址 。我们不需要在新的地址部署一个新的合约,也不需要手动将旧 Boxvalue 复制到新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'

就是这样! 请注意,在整个升级过程中, Boxvalue 以及它的地址被保存下来了。而且无论你是在本地区块链,测试网,还是主网络上工作,这个过程都是一样的。

让我们来看看 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,你有工具来修改你的合约并修复它。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK