如何使用OpenZeppelin的可升级合约

如何使用OpenZeppelin的可升级合约

已经部署的智能合约可以使用OpenZeppelin升级插件通过修改代码但保留原合约地址、状态和余额来进行升级。这点允许我们迭代自己的项目来添加新功能或修复发现的Bug。

正常情况下以太坊的智能合约是不能改变的。合约一旦创建就没办法改变。

但是,在很多场景下,人们还是希望可以修改合约。想象一下传统合约的参与双方,如果双方都同意改变,他们就可以改变合约。在以太坊上,人们也会希望通过修改合约来修复他们发现的Bug或添加其他功能。

如果不升级合约,可以通过以下方法来修复合约中发现的Bug。

  1. 重新部署一个新合约;
  2. 手工迁移所有的老合约到新合约,这个成本很高需要消耗gas费;
  3. 使用新合约地址更新所有老的合约;
  4. 通知所有用户并且说服他们开始使用新部署的合约。

使用升级插件进行升级

无论何时通过OpenZeppelin升级插件的deployProxy部署的新合约,后面都可以升级该合约实例。默认情况下,只有原来部署合约的地址有权升级它。

deployProxy 会做以下工作:

  1. 部署合约实现;
  2. 部署ProxyAdmin 合约;
  3. 部署proxy合约并且执行初始化函数。

// contracts/Box.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;

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;

    }

}

首先我们需要安装升级插件。

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 = {

...

};

为了升级合约我们首先需要将它部署为一个可升级合约,和以前合约部署步骤不同。我们将通过值42调用store初始化合约。

Hardhat当前还没有本地化部署系统,我们可以使用脚本来部署合约。

以下是使用deployProxy部署和升级合约的脚本,保存该文件路径为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();

可以使用以下命令部署Box合约到开发者网络上。

npx hardhat run --network localhost scripts/deploy_upgradeable_box.js

可以使用Hardhat console来和Box合约交互。

npx hardhat console --network localhost

> const Box = await ethers.getContractFactory('Box');

> const box = await Box.attach('0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0');

> (await box.retrieve()).toString();

本示例中,我们想要给合约增加一个功能点:可以增加值的函数。

// contracts/BoxV2.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;

contract BoxV2 {

    // ... code from Box.sol

    // Increments the stored value by 1

    function increment() public {

        _value = _value + 1;

        emit ValueChanged(_value);

    }

}

这个Solidity文件创建好以后,就可以使用upgradeProxy函数来升级已经部署的合约实例。

upgradeProxy 会做以下操作:

  1. 部署新合约BoxV2;
  2. 调用ProxyAdmin 更新proxy合约以使用BoxV2。

我们可以创建脚本使用upgradeProxy升级合约,将这个脚本存储为scripts/upgrade_box.js。脚本中需要列出proxy合约的地址。

然后使用run命令部署可升级合约Box到开发者网络上。

npx hardhat run --network localhost scripts/upgrade_box.js

命令执行完成后,Box实例就升级为最新版本,当然状态和地址与以前合约相同。我们不需要在一个新地址上部署一个新合约,也不需要手动复制旧合约的值到新合约中。

现在调用新合约中新增的increment 函数,当然需要指出proxy合约的地址。

npx hardhat console --network localhost

> const BoxV2 = await ethers.getContractFactory('BoxV2');

> const box = await BoxV2.attach('0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0');

> await box.increment();

> (await box.retrieve()).toString();

执行完成后会发现值42变成了43。

关于可升级合约原理可以稍作了解:

当我们创建可升级合约时,OpenZeppelin升级插件实际上部署了三个合约:

  1. 正常我们所写的合约实例;
  2. ProxyAdmin 合约;
  3. proxy 合约,就是我们我们与之交互的合约;

可升级合约的局限性:

Initialization:可升级合约不能有自己的constructor。OpenZeppelin提供了一个Initializable基础合约以标识一个方法为initializer,确保它只能运行一次。

现在试着写一个Box合约的新版本,存储能改变合约内容的管理员的地址。

// contracts/AdminBox.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;

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

    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时)并提供我们想要使用的管理员地址。

由于可升级合约的局限性,如果你已经在合约中声明了一个状态变量,就不能删除它,也不能改变它的类型,或者在它之前声明另一个变量。在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;

    // ...

}

幸运的是,这种局限性仅仅影响状态变量。我们可以按自己的需要修改合约的函数和事件。