深入理解合约升级(5) - 部署一个可升级合约

前面的文章我们基本把合约升级的原理介绍完了,这篇文章我们来实际操作一下,部署一个可升级合约。我们将会使用到 hardhat 框架和 OpenZeppelin可升级合约库。这个库和 OZ 的普通合约库的区别是,所有的合约中都没有构造函数,作为代替的是 initialize 函数,用来作初始化操作。

初始化

首先执行下面的命令来做一些初始化的工作:

  1. mkdir upgradeable_demo && cd upgradeable_demo
  2. npm init -y
  3. npm install --save-dev hardhat
  4. npx hardhat,创建实例项目,并且按照步骤进行
  5. npm install --save-dev @openzeppelin/hardhat-upgrades,安装 hardhat 的升级组件
  6. npm install --save-dev @nomiclabs/hardhat-ethers ethers,这两个包主要用于合约部署和测试
  7. npm install --save @openzeppelin/contracts-upgradeable,安装可升级合约库

接下来我们配置 hardhat 的配置文件:

// hardhat.config.js
require("@nomiclabs/hardhat-ethers");
require('@openzeppelin/hardhat-upgrades');

/**
 * @type import('hardhat/config').HardhatUserConfig
 */
module.exports = {
  solidity: "0.8.4",
  networks: {
    rinkeby: {
      url: // 这里填写对应网络的 rpc 地址,
      accounts: [这里填写私钥]
    }
  }
};

然后我们再来编写可升级合约,在 contracts 文件夹下创建 Demo.sol 文件:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.4;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract Demo is Initializable {
    uint256 public a;

    // 初始化函数,后面的修饰符 initializer 来自 Initializable.sol
    // 用于限制该函数只能调用一次
    function initialize(uint256 _a) public initializer {
        a = _a;
    }

    function increaseA() external {
        ++a;
    }
}

编译合约 npx hardhat compile,没有问题。

Transparent Proxy

接着我们编写部署脚本,在 scripts 文件夹下创建 deploy.js 文件:

async function main() {
    const Demo = await ethers.getContractFactory("Demo");
    console.log("Deploying Demo...");
    // initializer 后面的参数为初始化函数的名字,这里为 initialize
    // 中括号的参数为初始化函数的参数
    const demo = await upgrades.deployProxy(Demo, [101], { initializer: 'initialize' });
    // 这里打印的地址为代理合约的地址
    console.log("Demo deployed to:", demo.address);
}

// 这里也可以简化为 main(),后面的都省略也可以
main()
    .then(() => process.exit(0))
    .catch(error => {
        console.error(error);
        process.exit(1);
    });

我们来运行部署脚本,这里我们使用本地测试网络进行部署:

注意,本地测试网络需要运行 npx hardhat node。本地网络无需在配置文件中配置,如果使用真实网络,需要进行配置,并且在 --network 后面指定网络名称即可。

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

可以观察到一共部署了三个合约,对应的部署顺序分别是

  1. 逻辑合约
  2. ProxyAdmin 合约
  3. 代理合约(名为 TransparentUpgradeableProxy

注意,一个项目中只会有一个 ProxyAdmin 合约,管理着所有的代理合约。也就是说,我们在同一个项目中再去部署另外的合约,那么只会有步骤 1、3,ProxyAdmin 只在部署第一个合约时会部署。

这时,假设由于业务场景变化,需要修改合约,将 increaseA 函数修改为:

function increaseA() external {
    a += 10;
}

再次编译,没有问题。接着编写升级脚本,在 scripts 文件夹下创建 upgrade.js 文件:

async function main() {
    // 这里的地址为前面部署的代理合约地址
    const proxyAddress = '0x...';

    const Demo = await ethers.getContractFactory("Demo");
    console.log("Preparing upgrade...");
    // 升级合约
    await upgrades.upgradeProxy(proxyAddress, Demo);
}

main()
    .then(() => process.exit(0))
    .catch(error => {
        console.error(error);
        process.exit(1);
    });

接着运行

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

升级合约时, upgradeProxy 中一共有两个步骤:

  1. 部署新的逻辑合约
  2. 调用 ProxyAdmin 合约的 upgrade 函数来更换新合约,两个参数分别是代理合约和新逻辑合约的地址

这样我们就完成了可升级合约的部署与升级,注意到我们的部署过程中有 ProxyAdmin 合约,说明这是 TransparentProxy 的合约升级模式。接下来我们看看 UUPS 的模式如何部署及升级。

UUPS

若要支持 UUPS 的升级模式,需要做以下几点改动:

  1. 逻辑合约需继承 UUPSUpgradeable 合约
  2. 覆写 _authorizeUpgrade 函数
  3. 部署脚本需要添加 kind: 'uups' 参数

此时,逻辑合约变为:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.4;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

// 需要继承 UUPSUpgradeable 合约
contract Demo is Initializable, UUPSUpgradeable {
    uint256 public a;

    function initialize(uint256 _a) public initializer {
        a = _a;
    }

    function increaseA() external {
        a += 10;
    }

    // 覆写 _authorizeUpgrade 函数
    function _authorizeUpgrade(address) internal override {}
}

部署脚本变为:

async function main() {
    const Demo = await ethers.getContractFactory("Demo");
    console.log("Deploying Demo...");
    // 这里添加了参数 => kind: 'uups'
    const demo = await upgrades.deployProxy(Demo, [101], { initializer: 'initialize', kind: 'uups' });
    console.log("Demo deployed to:", demo.address);
}

main()
    .then(() => process.exit(0))
    .catch(error => {
        console.error(error);
        process.exit(1);
    });

编译合约并运行部署脚本(注意,这时最好删除 .openzeppelin 文件夹下的对应网络配置文件,因为其包含了我们上面测试的 TransparentProxy 模式的一些运行配置,可能会有影响),可以观察到一共部署了两个合约,分别是:

  1. 逻辑合约
  2. 代理合约(名为 ERC1967Proxy

此时,假设我们需要升级合约,在改动了合约之后,我们可以继续使用上面的 upgrade.js 脚本进行升级,这时的升级步骤是:

  1. 部署新的逻辑合约
  2. 调用代理合约upgradeTo 函数进行升级,参数是新的逻辑合约地址

我们可以看到,两种升级模式有所区别。TransparentProxy 模式在升级的时候,需要调用 ProxyAdmin 的升级函数。而 UUPS 模式在升级时,需要调用代理合约的升级函数。后者相比于前者少部署一个合约。

UUPS 中需要注意的权限管理问题

这里有一个重点是,由于 TransparentProxy 模式是由 ProxyAdmin 进行管理,也就是说只有 ProxyAdmin 有权限进行升级,那么我们只要保证 ProxyAdmin 合约的管理员权限安全即可保证整个可升级架构安全。而对于 UUPS 模式来说,升级合约的逻辑是需要调用代理合约的,这时的权限管理就需要开发者手动处理。具体来说,就是对于我们覆写的 _authorizeUpgrade 函数,需要加上权限管理:

function _authorizeUpgrade(address) internal 
    override onlyOwner {}

这里加上了 onlyOwner 用于限制升级权限,否则任何人都可调用代理合约的 upgradeTo 进行升级。但要注意的是,我们这里只是简单加上了 onlyOwner 做为示例的权限管理,在实际开发中,由于升级的逻辑和业务逻辑都在逻辑合约中,因此需要区分业务场景的 owner 和合约升级架构的 owner。这里可能会对开发者带来困扰,因此需要多加注意。

总结

TransparentProxyUUPS 是目前阶段比较流行的成熟的合约升级解决方案。OZ 建议开发者使用 UUPS,更加轻量级,节省 gas。我个人还是比较倾向于前者,因为我觉得前者架构清晰,权限管理简单。后者将业务逻辑和升级组件都放在逻辑合约中,当需要多次升级合约时,是否节省 gas 仍需探讨。不过建议大家两个都能够熟练掌握,毕竟难度不高,上手也很简单。

合约升级系列文章

  1. 深入理解合约升级(1) - 概括
  2. 深入理解合约升级(2) - Solidity 内存布局
  3. 深入理解合约升级(3) - call 与 delegatecall
  4. 深入理解合约升级(4) - 合约升级原理的代码实现
  5. 深入理解合约升级(5) - 部署一个可升级合约

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.