跳到主要内容

可升级的合约和模式

我们知道 Ethereum 上的智能合约是不可升级的,因为代码是不可变的,一旦部署就不能改变。但第一次写出完美的代码是很难的,作为人类,我们都很容易犯错。有时,即使是经过审计的合约,也会发现有一些错误,使其损失数百万。

在这一层,我们将了解一些设计模式,这些模式可以在 Solidity 中用来编写可升级的智能合约。

它是如何工作的?

为了升级我们的合约,我们使用一种叫做代理模式的东西。Proxy 这个词对你来说可能听起来很熟悉,因为它不是一个 web3 的原生词。

从本质上讲,这种模式的工作原理是:一个合约被分成两个合约--代理合约和执行合约。

代理合约负责管理合约的状态,涉及持久性存储,而执行合约负责执行逻辑,不存储任何持久性状态。用户调用代理合约,代理合约进一步对实现合约进行委托调用,这样它就可以实现逻辑。记得我们在以前的一个级别中学习过委托调用。

当实现合约可以被替换时,这种模式就变得有趣了,这意味着执行的逻辑可以被另一个版本的实现合约所替换,而不影响存储在代理中的合约的状态。

主要有三种方式,我们可以替换/升级实现合约。

  • 钻石实现
  • 透明的实现
  • UUPS 实现

然而,我们将只关注透明和 UUPS,因为它们是最常用的。

要升级实施合约,你必须使用一些方法,比如 upgradeTo(address),这基本上会把实施合约的地址从旧的变成新的。

但重要的部分在于我们应该把 upgradeTo(address) 函数放在哪里,我们有两个选择,要么把它放在代理合约中,这基本上是透明代理模式的工作方式,要么把它放在执行合约中,这就是 UUPS 合约的工作方式。

关于这个代理模式,另一个需要注意的是,实现合约的构造器永远不会被执行。

当部署一个新的智能合约时,构造器内的代码不是合约运行时字节码的一部分,因为它只在部署阶段需要,并且只运行一次。现在,因为当实施合约被部署时,它最初没有连接到代理合约,作为一个原因,任何在构造器中发生的状态变化现在在代理合约中不存在,而代理合约是用来维护整体状态的。

作为一个原因,代理合约不知道构造函数的存在。因此,我们不使用构造函数,而是使用一个叫做初始化函数的东西,一旦实现合约与之连接,代理合约就会调用这个函数。这个函数所做的正是构造函数应该做的事情,但是现在它被包含在运行时字节码中,因为它的行为就像一个普通的函数,并且可以被代理合约调用。

使用 OpenZeppelin 合约,你可以使用他们的 Initialize.sol 合约,它可以确保你的初始化函数只被执行一次,就像构造函数一样

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

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

contract MyContract is Initializable {
function initialize(
address arg1,
uint256 arg2,
bytes memory arg3
) public payable initializer {
// "constructor" code...
}
}

上面给出的代码来自 Openzeppelin 的文档,并提供了一个示例,说明初始化器修饰符如何确保初始化函数只能被调用一次。这个修饰符来自 Initializable Contract

我们现在将详细研究代理模式 🚀 👀

透明代理模式

透明代理模式是一种分离代理和实现合约之间职责的简单方法。在这种情况下,upgradeTo 函数是代理合约的一部分,并且可以通过在代理上调用 upgradeTo 来升级实现,从而改变未来函数调用的委托位置。

不过有一些警告。可能存在代理合约和实施合约具有相同名称和参数的函数的情况。想象一下,如果 Proxy Contract 有一个 owner() 函数,Implementation Contract 也有。在透明代理合约中,这个问题由代理合约处理,它决定来自用户的调用是在代理合约本身内执行,还是在基于 msg.sender 全局变量的实施合约中执行

因此,如果 msg.sender 是代理的管理员,那么代理将不会委托调用,并且如果它理解它会尝试执行调用。如果不是管理员地址,即使匹配代理的功能之一,代理也会将调用委托给实施合约。

透明代理模式的问题

众所周知,所有者的地址必须存储在存储中,并且每次用户调用代理时,使用存储是与智能合约交互的最低效和最昂贵的步骤之一,代理会检查用户是否是管理员与否,这为发生的大多数交易增加了不必要的 gas 成本。

UUPS 代理模式

UUPS 代理模式是另一种分离代理和实施合约之间责任的方式。在这种情况下,upgradeTo 函数也是实现合约的一部分,并且由所有者通过代理使用委托调用来调用。

在 UUPS 中,无论是管理员还是用户,所有调用都发送到实施合约。这样做的好处是,每次调用时,我们都不必访问存储来检查发起调用的用户是否是管理员与否,这提高了效率和成本。也因为它是实现合约,您可以根据需要自定义功能,方法是在每个出现的新实现中添加时间锁、访问控制等内容,这在透明代理模式中是无法完成的

UUPS 代理模式的问题

现在的问题是因为 upgradeTo 函数存在于 implementation 合约开发者必须担心这个函数的实现有时可能很复杂,并且由于添加了更多代码,它增加了攻击的可能性。该功能也需要在所有升级的实施合约版本中都有,如果开发者忘记添加该功能,合约将无法升级,这会带来风险。

构建

让我们构建一个示例,您可以在其中体验如何构建可升级合约。我们将在本示例中使用 UUPS 可升级模式,尽管您也可以使用透明代理模式构建一个!

要设置安全帽项目,请打开终端并执行以下命令

创建项目

创建合约

我们将使用来自 openzeppelin 的支持可升级合约的库。要安装这些库,请在同一文件夹中执行以下命令:

npm i @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades @nomiclabs/hardhat-etherscan --save-dev

将您的 hardhat.config.js 中的代码替换为以下代码,以便能够使用这些库:

require("@nomiclabs/hardhat-ethers");
require("@openzeppelin/hardhat-upgrades");
require("@nomiclabs/hardhat-etherscan");

module.exports = {
solidity: "0.8.4",
};

首先在 contracts 目录 ca 中创建一个新文件

填充 LW3NFT.sol 并向其中添加以下代码行

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract LW3NFT is Initializable, ERC721Upgradeable, UUPSUpgradeable, OwnableUpgradeable {
// Note how we created an initialize function and then added the
// initializer modifier which ensure that the
// initialize function is only called once
function initialize() public initializer {
// Note how instead of using the ERC721() constructor, we have to manually initialize it
// Same goes for the Ownable contract where we have to manually initialize it
__ERC721_init("LW3NFT", "LW3NFT");
__Ownable_init();
_mint(msg.sender, 1);
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {

}
}

让我们尝试更详细地了解此合约中发生的事情

如果您查看 LW3NFT 正在导入的所有合约,您就会明白它们为何如此重要。首先是来自 Openzeppelin 的 Initializable 合约,它为我们提供了初始化修饰符,确保初始化函数只被调用一次。需要初始化函数,因为我们不能在实施合约中有一个构造器,在这种情况下是 LW3NFT 合约

它导入 ERC721Upgradeable 和 OwnableUpgradeable 因为原始 ERC721 和 Ownable 合约有一个不能与代理合约一起使用的构造函数。

最后,我们有 UUPSUpgradeable 合约,它为我们提供了 upgradeTo(address) 功能,在 UUPS 代理模式的情况下,该功能必须放在实施合约中。

在合约声明之后,我们有一个带有初始化器修饰符的初始化函数,我们从 Initializable 合约中获得。初始化器修饰符确保初始化函数只能被调用一次。另请注意,我们初始化 ERC721 和 Ownable 合约的新方式。这是初始化可升级合约的标准方法,你可以在这里查看函数。之后,我们只需使用通常的铸币功能铸币。

function initialize() public initializer  {
__ERC721_init("LW3NFT", "LW3NFT");
__Ownable_init();
_mint(msg.sender, 1);
}

另一个我们在普通 ERC721 合约中看不到的有趣功能是 _authorizeUpgrade,这是开发人员在从 Openzeppelin 导入 UUPSUpgradeable 合约时需要实现的功能,可以在这里找到。现在为什么必须覆盖这个函数是很有趣的,因为它让我们能够添加对谁可以实际升级给定合约的授权,它可以根据要求进行更改,但在我们的例子中,我们只是添加了一个 onlyOwner 修饰符。

function _authorizeUpgrade(address newImplementation) internal override onlyOwner {

}

现在让我们在 contracts 目录中创建另一个名为 LW3NFT2.sol 的新文件,它将是 LW3NFT.sol 的升级版本

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "./LW3NFT.sol";

contract LW3NFT2 is LW3NFT {

function test() pure public returns(string memory) {
return "upgraded";
}
}

这个智能合约要容易得多,因为它只是继承 LW3NFT 合约,然后添加一个名为 test 的新函数,它只返回一个 upgraded 字符串。

很容易吧? 🤯

哇 🙌,好的,我们已经完成了实现合约的编写,我们现在还需要编写代理合约吗?

好消息是,我们不需要编写代理合约,因为当我们使用那里的库部署实施合约时,Openzeppelin 会自动部署和连接代理合约。

因此,让我们尝试这样做,在您的测试目录中创建一个名为 proxy-test.js 的新文件,让代码玩得开心

const { expect } = require("chai");
const { ethers } = require("hardhat");
const hre = require("hardhat");

describe("ERC721 Upgradeable", function () {
it("Should deploy an upgradeable ERC721 Contract", async function () {
const LW3NFT = await ethers.getContractFactory("LW3NFT");
const LW3NFT2 = await ethers.getContractFactory("LW3NFT2");

let proxyContract = await hre.upgrades.deployProxy(LW3NFT, {
kind: "uups",
});
const [owner] = await ethers.getSigners();
const ownerOfToken1 = await proxyContract.ownerOf(1);

expect(ownerOfToken1).to.equal(owner.address);

proxyContract = await hre.upgrades.upgradeProxy(proxyContract, LW3NFT2);
expect(await proxyContract.test()).to.equal("upgraded");
});
});

让我们看看发生了什么,在这里,我们首先使用 getContractFactory 函数获取 LW3NFTLW3NFT2 实例,该函数对我们迄今为止教授的所有级别都是通用的。在那之后,最重要的一行是:

let proxyContract = await hre.upgrades.deployProxy(LW3NFT, {
kind: "uups",
});

该函数来自您安装的 @openzeppelin/hardhat-upgrades 库,它本质上是使用升级类调用 deployProxy 函数并将类型指定为 uups。调用该函数时,它会部署代理合约、LW3NFT 合约并将它们连接起来。可以在此处找到有关此的更多信息。

请注意,initialize 函数 可以命名为其他任何名称,只是默认情况下 deployProxy 调用具有名称 initialize 的函数作为初始化程序,但您可以通过更改默认值来修改它 😇

部署后,我们通过调用 Token ID 1 的 ownerOf 函数并检查 NFT 是否确实被铸造来测试合约是否真正被部署。

现在下一部分是我们要部署 LW3NFT2 的地方,这是 LW3NFT 的升级合约。

为此,我们再次从 @openzeppelin/hardhat-upgrades 库中执行 upgradeProxy 方法,该库升级并用 LW3NFT2 替换 LW3NFT 而不改变系统状态

proxyContract = await hre.upgrades.upgradeProxy(proxyContract, LW3NFT2);

为了测试它是否真的被替换,我们调用 test() 函数,并确保它返回“升级”,即使该函数在原始 LW3NFT 合约中不存在。

要运行此测试,请打开指向此级别目录根目录的终端并执行以下命令:

npx hardhat test

如果你所有的测试都通过了,这意味着你已经学会了如何升级智能合约。

读数

  • 给定文章中提到了时间锁,要了解更多信息,您可以阅读以下文章
  • 还提到了访问控制,您可以在此处了解更多信息

参考