编写可升级合约 - OpenZeppelin 文档

本文介绍了在使用 OpenZeppelin Upgrades 编写可升级合约时需要注意的关键事项,包括使用 initializer 代替 constructor,避免在字段声明中定义初始值,以及如何安全地修改合约存储结构。

编写可升级的合约

当使用 OpenZeppelin Upgrades 处理可升级合约时,编写 Solidity 代码时需要记住一些小的注意事项。

值得一提的是,这些限制的根源在于 Ethereum VM 的工作方式,并且适用于所有处理可升级合约的项目,而不仅仅是 OpenZeppelin Upgrades。

初始化函数

你可以将你的 Solidity 合约与 OpenZeppelin Upgrades 一起使用,而无需进行任何修改,除了它们的 构造函数。由于基于代理的升级系统的要求,可升级合约中不能使用构造函数。要了解此限制背后的原因,请访问 Proxies

这意味着,当使用带有 OpenZeppelin Upgrades 的合约时,你需要将其构造函数更改为常规函数,通常命名为 initialize,你可以在其中运行所有设置逻辑:

// 注意:不要使用此代码段,它不完整并且具有严重漏洞!

pragma solidity ^0.6.0;

contract MyContract {
    uint256 public x;

    function initialize(uint256 _x) public {
        x = _x;
    }
}

但是,虽然 Solidity 确保 constructor 在合约的生命周期中仅被调用一次,但常规函数可以被多次调用。为了防止合约被多次 初始化,你需要添加一个检查以确保 initialize 函数仅被调用一次:

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

contract MyContract {
    uint256 public x;
    bool private initialized;

    function initialize(uint256 _x) public {
        require(!initialized, "Contract instance has already been initialized");
        initialized = true;
        x = _x;
    }
}

由于此模式在编写可升级合约时非常常见,因此 OpenZeppelin Contracts 提供了一个 Initializable 基础合约,该合约具有一个 initializer 修饰符来处理此问题:

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

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

contract MyContract is Initializable {
    uint256 public x;

    function initialize(uint256 _x) public initializer {
        x = _x;
    }
}

constructor 和常规函数之间的另一个区别是,Solidity 会自动调用合约的所有祖先的构造函数。在编写初始化函数时,你需要特别注意手动调用所有父合约的初始化函数。请注意,即使在使用继承时,initializer 修饰符也只能被调用一次,因此父合约应使用 onlyInitializing 修饰符:

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

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

contract BaseContract is Initializable {
    uint256 public y;

    function initialize() public onlyInitializing {
        y = 42;
    }
}

contract MyContract is BaseContract {
    uint256 public x;

    function initialize(uint256 _x) public initializer {
        BaseContract.initialize(); // 不要忘记这个调用!
        x = _x;
    }
}

使用可升级的智能合约库

请记住,此限制不仅影响你的合约,还会影响你从库中导入的合约。例如,考虑 OpenZeppelin Contracts 中的 ERC20:该合约在其构造函数中初始化了 token 的名称和符号。

// @openzeppelin/contracts/token/ERC20/ERC20.sol
pragma solidity ^0.8.0;

...

contract ERC20 is Context, IERC20 {

    ...

    string private _name;
    string private _symbol;

    constructor(string memory name_, string memory symbol_) {
        _name = name_;
        _symbol = symbol_;
    }

    ...
}

这意味着你不应该在你的 OpenZeppelin Upgrades 项目中使用这些合约。相反,请确保使用 @openzeppelin/contracts-upgradeable,它是 OpenZeppelin Contracts 的官方分支,已经过修改以使用初始化函数而不是构造函数。看看 @openzeppelin/contracts-upgradeable 中的 ERC20Upgradeable 是什么样的:

// @openzeppelin/contracts-upgradeable/contracts/token/ERC20/ERC20Upgradeable.sol
pragma solidity ^0.8.0;

...

contract ERC20Upgradeable is Initializable, ContextUpgradeable, IERC20Upgradeable {
    ...

    string private _name;
    string private _symbol;

    function __ERC20_init(string memory name_, string memory symbol_) internal onlyInitializing {
        __ERC20_init_unchained(name_, symbol_);
    }

    function __ERC20_init_unchained(string memory name_, string memory symbol_) internal onlyInitializing {
        _name = name_;
        _symbol = symbol_;
    }

    ...
}

无论是使用 OpenZeppelin Contracts 还是其他的智能合约库,请始终确保该包已设置为处理可升级的合约。

合约:与升级一起使用中了解更多关于 OpenZeppelin Contracts Upgradeable 的信息。

避免在字段声明中使用初始值

Solidity 允许在合约中声明字段时为其定义初始值。

contract MyContract {
    uint256 public hasInitialValue = 42; // 等同于在构造函数中设置
}

这等同于在构造函数中设置这些值,因此,不适用于可升级的合约。请确保所有初始值都在初始化函数中设置,如下所示;否则,任何可升级的实例都不会设置这些字段。

contract MyContract is Initializable {
    uint256 public hasInitialValue;

    function initialize() public initializer {
        hasInitialValue = 42; // 在初始化函数中设置初始值
    }
}
定义 常量 状态变量仍然可以,因为编译器 不会为这些变量保留存储槽,并且每次出现都会被相应的常量表达式替换。因此,以下代码仍然适用于 OpenZeppelin Upgrades:
contract MyContract {
    uint256 public constant hasInitialValue = 42; // 定义为常量
}

初始化实现合约

不要让实现合约处于未初始化状态。未初始化的实现合约可能会被攻击者接管,这可能会影响代理。为了防止实现合约被使用,你应该在构造函数中调用 _disableInitializers 函数,以便在部署时自动锁定它:

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
    _disableInitializers();
}

验证初始化函数

OpenZeppelin Upgrades 插件将自动检测实现合约中初始化函数的特定问题。这些问题包括检查你的实现合约在需要调用父初始化函数时是否缺少初始化函数,是否未调用父初始化函数,或者是否多次调用了父初始化函数。如果父初始化函数未按线性顺序调用,这些插件也会发出警告。

默认情况下,重新初始化函数不包含在验证中,因为 Upgrades 插件无法确定它们是否打算用于新的部署。如果你想验证重新初始化函数作为可用于新部署的初始化函数,请使用 @custom:oz-upgrades-validate-as-initializer 注释它。请注意,无法用作初始化函数的函数总是会被忽略,例如无法从外部或子合约调用的私有函数。

从你的合约代码创建新实例

当从你的合约代码创建一个合约的新实例时, 这些创建由Solidity直接处理,而不是由 OpenZeppelin Upgrades 处理,这意味着 这些合约将不可升级

例如,在下面的例子中,即使 MyContract 被部署为可升级的,创建的 token 合约也不可升级:

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

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

contract MyContract is Initializable {
    ERC20 public token;

    function initialize() public initializer {
        token = new ERC20("Test", "TST"); // 这个合约将不可升级
    }
}

如果你希望 ERC20 实例是可升级的,实现这一点的最简单方法是简单地接受该合约的一个实例作为参数,并在创建后注入它:

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

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

contract MyContract is Initializable {
    IERC20Upgradeable public token;

    function initialize(IERC20Upgradeable _token) public initializer {
        token = _token;
    }
}

潜在的不安全操作

当使用可升级的智能合约时,你将始终与合约实例进行交互,而从不与底层逻辑合约进行交互。但是,没有什么可以阻止恶意行为者直接向逻辑合约发送交易。这不会构成威胁,因为对逻辑合约状态的任何更改都不会影响你的合约实例,因为逻辑合约的存储永远不会在你的项目中使用。

但是,有一个例外。如果直接调用逻辑合约触发了 selfdestruct 操作,那么逻辑合约将被销毁,并且你的所有合约实例最终会将所有调用委托给一个没有任何代码的地址。这将有效地破坏你的项目中的所有合约实例。

如果逻辑合约包含 delegatecall 操作,则可以实现类似的效果。如果该合约可以被 delegatecall 到包含 selfdestruct 的恶意合约中,那么调用合约将被销毁。

因此,不允许在你的合约中使用 selfdestructdelegatecall

修改你的合约

在编写合约的新版本时,无论是由于新功能还是错误修复,都需要遵守一个额外的限制:你不能更改合约状态变量的声明顺序,也不能更改它们的类型。你可以通过了解我们的 Proxies 来阅读更多关于此限制背后的原因。

违反任何这些存储布局限制将导致合约升级后的版本混淆其存储值,并可能导致应用程序中的严重错误。

这意味着,如果你有一个初始合约,如下所示:

contract MyContract {
    uint256 private x;
    string private y;
}

那么你不能更改变量的类型:

contract MyContract {
    string private x;
    string private y;
}

或者更改它们声明的顺序:

contract MyContract {
    string private y;
    uint256 private x;
}

或者在现有变量之前引入一个新变量:

contract MyContract {
    bytes private a;
    uint256 private x;
    string private y;
}

或者删除一个现有变量:

contract MyContract {
    string private y;
}

如果你需要引入一个新变量,请确保始终在末尾执行此操作:

contract MyContract {
    uint256 private x;
    string private y;
    bytes private z;
}

请记住,如果你重命名一个变量,那么升级后它将保持与以前相同的值。如果新变量在语义上与旧变量相同,则这可能是所需的行为:

contract MyContract {
    uint256 private x;
    string private z; // 从 `y` 的值开始
}

如果你从合约末尾删除一个变量,请注意该存储不会被清除。后续添加新变量的更新将导致该变量读取已删除变量的剩余值。

contract MyContract {
    uint256 private x;
}

然后升级到:

contract MyContract {
    uint256 private x;
    string private z; // 从 `y` 的值开始
}

请注意,你可能还会通过更改其父合约来无意中更改合约的存储变量。例如,如果你有以下合约:

contract A {
    uint256 a;
}

contract B {
    uint256 b;
}

contract MyContract is A, B {}

然后通过交换声明基本合约的顺序,或者引入新的基本合约来修改 MyContract,将改变变量的实际存储方式:

contract MyContract is B, A {}

如果子合约有任何自己的变量,你也无法向基本合约添加新变量。给定以下场景:

contract Base {
    uint256 base1;
}

contract Child is Base {
    uint256 child;
}

如果 Base 被修改为添加一个额外的变量:

contract Base {
    uint256 base1;
    uint256 base2;
}

那么变量 base2 将被分配给 child 在先前版本中拥有的槽。一种解决方法是在你将来可能想要扩展的基本合约中声明未使用的变量或存储间隙,作为“保留”这些槽的一种手段。请注意,此技巧不会增加 gas 使用量。

存储间隙

存储间隙是一种在基本合约中保留存储槽的约定,允许该合约的未来版本使用这些槽,而不会影响子合约的存储布局。

要创建存储间隙,请在基本合约中声明一个固定大小的数组,其中包含初始槽数。这可以是 uint256 数组,以便每个元素保留一个 32 字节的槽。使用名称 __gap 或以 __gap_ 开头的名称作为数组,以便 OpenZeppelin Upgrades 可以识别该间隙:

contract Base {
    uint256 base1;
    uint256[49] __gap;
}

contract Child is Base {
    uint256 child;
}

如果 Base 稍后被修改为添加额外的变量,请从存储间隙中减少适当数量的槽,同时牢记 Solidity 关于如何打包连续项目的规则。例如:

contract Base {
    uint256 base1;
    uint256 base2; // 32 字节
    uint256[48] __gap;
}

或者:

contract Base {
    uint256 base1;
    address base2; // 20 字节
    uint256[48] __gap; // 数组始终从一个新槽开始
}

或者:

contract Base {
    uint256 base1;
    uint128 base2a; // 16 字节
    uint128 base2b; // 16 字节 - 从与上面相同的槽继续
    uint256[48] __gap;
}

为了帮助确定合约新版本中的适当存储间隙大小,你可以简单地尝试使用 upgradeProxy 进行升级,或者只使用 validateUpgrade 运行验证(请参阅 Hardhat UpgradesFoundry Upgrades 的文档)。如果存储间隙未正确减少,你将看到一条错误消息,指示存储间隙的预期大小。

命名空间存储布局

ERC-7201:命名空间存储布局 是另一种可以用于避免在修改基本合约或更改合约继承顺序时出现存储布局错误的约定。从 5.0 版本开始,OpenZeppelin 合约的可升级变体中使用了此约定。

此约定涉及将合约的所有存储变量放入一个或多个结构中,并使用 @custom:storage-location erc7201:<NAMESPACE_ID> 注释这些结构。命名空间 id 是一个字符串,它唯一地标识合约中的每个命名空间,因此在合约或其任何基本合约中,不得多次定义同一 id。

当使用命名空间存储布局时,OpenZeppelin Upgrades 插件将自动检测命名空间 id,并验证升级期间命名空间内的每个更改是否安全,根据与 修改你的合约 中描述的相同规则。

要将 Upgrades 插件与命名空间存储布局一起使用,需要 Solidity 版本 0.8.20 或更高版本。如果这些插件检测到具有较旧版本 Solidity 的 @custom:storage-location 注释,则会给出错误,因为较旧版本的编译器不会生成足够的信息来验证命名空间存储布局。

← OpenZeppelin Defender 集成

代理升级模式 →

  • 原文链接: docs.openzeppelin.com/up...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
OpenZeppelin
OpenZeppelin
江湖只有他的大名,没有他的介绍。