编写可升级合约

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

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

初始化函数

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

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

// NOTE: 不要使用此代码片段,它是不完整的,并且存在严重的漏洞!

pragma solidity ^0.6.0;

contract MyContract {
    uint256 public x;

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

然而,尽管 Solidity 确保 constructor 在合约的生命周期内只被调用一次,但一个常规函数可以被多次调用。为了防止合约被多次 initialized,你需要添加一个检查来确保 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 还是另一个智能合约库,始终确保该包已设置为处理可升级合约。

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

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

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

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

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

contract MyContract is Initializable {
    uint256 public hasInitialValue;

    function initialize() public initializer {
        hasInitialValue = 42; // 在初始化函数中设置初始值
    }
}
定义 constant 状态变量仍然是可以的,因为编译器 不会为这些变量保留存储槽,并且每次出现都会被相应的常量表达式替换。因此,以下代码仍然可以在 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

修改你的合约

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

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

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

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 Contracts 的可升级变体中使用了此约定。

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

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

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