可初始化的智能合约设计模式

  • RareSkills
  • 更新于 2024-07-12 08:42
  • 阅读 443

可初始化的智能合约设计模式

初始化器是可升级合约实现构造函数行为的方式。

在部署合约时,通常会调用构造函数来初始化存储变量。例如,构造函数可能会设置代币的名称或最大供应量。在可升级合约中,这些信息需要存储在代理(合约)的存储变量中,而不是实现(合约)的存储变量中。在代理中添加一个构造函数,如下所示:

constructor(
    string memory name_,
    string memory symbol_,
    uint256 maxSupply_
) {
    name = name_;
    symbol = symbol_;
    maxSupply = maxSupply_;
}

这不是一个好的解决方案,因为在代理和实现之间对齐存储变量位置容易出错。在实现中创建构造函数也不起作用,因为它会设置实现(合约)中的存储变量。

初始化器的工作原理

解决上述所有问题的方法是在实现中创建一个 initializer() 函数,该函数以与构造函数相同的方式设置存储变量,并让代理通过委托调用 initialize() 实现。将 initializer() 放在实现中可确保存储变量对齐将自动正确。为了模仿构造函数,关键是这个函数只能被代理委托调用一次。

OpenZeppelin 的 Initializable.sol 合约的目的是提供这种初始化模式的稳健实现。目前,Initializable.sol 在 OpenZeppelin 的可升级合约中使用,如 ERC20Upgradeable.sol

本文的目的是详细解释 Initializable.sol 的工作原理。但在此之前,让我们展示如何天真地实现这种模式以及为什么天真的实现仅在最简单的情况下有效。

初始化器过程的高级示意图如下动画所示: video

一个天真的实现

可能会尝试编写如下合约,其中设计了一个修饰符来限制函数的执行仅一次且不再执行。

contract NaiveInitialization {
    // **initialized** 表示合约是否已初始化
    bool initialized = false;

    // 限制函数仅执行一次
    modifier initializer() {
        require(initialized == false, "Already initialized");
        initialized = true;
        _;
    }

    // 仅可执行一次
    function initialize() public initializer {
        // 初始化必要的存储变量
    }
}

上述代码适用于此特定合约,确保 initialize() 函数只能执行一次。然而,当与继承一起使用时,相同模式在失败。

初始化失败的实现演示

上述模式的问题在于,当合约使用继承且父合约也必须初始化时,它不支持。让我们在以下代码中查看此问题的示例。

contract Initializable {
    // **initialized** 表示合约是否已初始化
    bool initialized = false;

    // 限制函数仅执行一次
    modifier initializer() {
        require(initialized == false, "Already initialized");
        initialized = true;
        _;
    }
}

contract ParentNaive is Initializable {
    // 初始化父合约
    function initializeParent() internal initializer {
        // 初始化一些状态变量
    }
}

contract ChildNaive is ParentNaive {
    // 初始化子合约
    function initializeChild() public initializer {
        super.initializeParent();
        // 初始化其他状态变量
    }
}

上述合约的预期执行顺序如下:

  1. 调用 initializeChild() 函数。使用 initializer 修饰符,它将 initialized 变量更新为 true。此变量被继承链中的 所有 合约使用。

  2. 接下来,在 initializeChild() 中调用 initializeParent() 函数。initializeParent() 也具有 initializer 修饰符,因此它要求变量 initializedfalse

  3. 但是,当 initializeChild 运行时,initialized 变量已经设置为 true因此在调用 initializeParent() 时事务将回滚

OpenZeppelin 的 Initializable.sol 合约通过允许在继承链中的所有合约进行初始化来解决了这个问题,同时防止在初始化事务之后调用初始化器。

理解 Initializable.sol

Initializable.sol 的核心包括三个修饰符:initializerreinitializeronlyInitializing,以及两个状态变量:_initializing_initialized

每个修饰符仅在特定场景中使用,具有不同的目的。它们的使用概述如下:

  • initializer 修饰符应在可升级合约的初始部署期间使用,且仅在最子级合约中使用。

  • reinitializer 修饰符应用于初始化实现合约的新版本,同样仅在最子级合约中使用。

  • onlyInitializing 修饰符与父级初始化器一起使用,在初始化期间运行,并防止在以后的事务中调用这些初始化器。这解决了前一节提到的问题,即由于最子级初始化器禁用了父级初始化器,导致父级初始化器无法运行的问题。通过这种方案,可以初始化所有父级合约,以及最子级合约。

下面是说明这些场景的可视化图表。这些修饰符的使用将在接下来的部分中提供更详细的解释。

可视化图表显示三个核心 Initializable.sol 修饰符的目的:initializer、reinitializer 和 onlyInitializing。

Initializable.sol 实现了 ERC-7201 模式,其中状态变量在结构体中声明。如果你不了解此模式,请将 _initializing_initialized 视为状态变量。

struct InitializableStorage 的代码片段

_initializing 变量是一个布尔值,指示合约是否处于初始化过程中,而 _initialized 变量存储合约的当前版本。它从值 0 开始,在第一次初始化后将为 1。如果开发人员选择部署新实现并希望“重新初始化”存储变量以新值,则它可能会更高。

视频演示了这些部分如何协同工作:

初始化程序修饰符

initializer修饰符如下所示。稍后将详细解释代码的一些部分。

initializable.sol 初始化程序修饰符的代码片段

由于需要解决与先前版本的向后兼容性问题,上面的代码并不直观。然而,主要思想是双重的:

  1. _initialized变量设置为1,以防止函数再次执行(绿色框)。

  2. _initializing为真时,临时允许使用onlyInitializing修改的父级初始化程序运行。如上面的代码所示,当合约尚未初始化时,_initializing为假,当初始化事务正在运行时为真,当初始化事务完成时为假。

由于initializer要求_initializing为假,所以不能在继承链中的父合约中使用,因为在执行这些合约时,_initializing为真。相反,父合约的初始化函数必须使用另一个修饰符,具体是onlyInitializing,它允许函数仅在_initializing为真时执行。

仅初始化修饰符

onlyInitializing修饰符设计用于父合约中,因为它仅在_initializing为真时执行,如下所示。

modifier onlyInitializing() {
    _checkInitializing();
    _;
}

function _checkInitializing() internal view virtual {
    if (!_isInitializing()) {
        revert NotInitializing(); // 如果**_initializing**为假,则回滚
    }
}

下面是此流程的可视化表示。

onlyInitializing 修饰符流程的可视化表示

总结一下,父级初始化程序受onlyInitializing修饰符保护,防止它们被调用,除非最子级合约的inititializer当前正在执行。

重新初始化修饰符

reinitializer修饰符与initializer扮演类似的角色,但必须用于初始化实现合约的新版本,如果新版本需要在初始化时更新存储变量。

此修饰符具有一个uint64参数,表示合约版本,必须大于当前版本。如果要防止未来重新初始化,版本可以设置为 2⁶⁴-1 或type(uint64).max。将变量设置为uint64允许将_initializing布尔变量打包在同一槽中,而 2⁶⁴ − 1 个版本留下了足够的空间供未来升级使用。以下是reinitializer修饰符的代码。

modifier reinitializer(uint64 version) {
    // solhint-disable-next-line var-name-mixedcase
    InitializableStorage storage $ = _getInitializableStorage();

    if ($._initializing || $._initialized >= version) {
        revert InvalidInitialization();
    }
    $._initialized = version;
    $._initializing = true;
    _;
    $._initializing = false;

    emit Initialized(version);
}

让我们通过一个示例说明其用法。假设在 ERC20Upgradeable 合约的第一个升级中,我们想要更改代币的名称和符号。此函数必须编写如下,其中值2表示这是合约的第二个版本:

function  initialize() reinitializer(2) public {
    __ERC20_init("MyToken2", "MTK2");
}

总结升级的步骤如下:

  1. 不能在新版本上使用initializer

  2. 如果要在初始化函数中更改任何状态变量,则必须使用reinitializer

  3. 或者,如果在升级期间不需要进行状态更改,则可以没有初始化程序。

未初始化合约的漏洞

initializer修饰符中,有一行代码可能引起了你的注意,但我在最初的解释中没有提到。如下所示:

bool construction = initialized == 1 && address(this).code.length == 0;

表达式address(this).code.length == 0仅在合约部署期间为真。因此,只有在构造函数中使用initializer修饰符时,construction变量才能为真。

实际上,initializer修饰符可以在实现合约的构造函数中使用以“初始化”它。这可能看起来有些反直觉,因为实现合约的存储不应该重要。然而,正如我们将看到的,这种初始化作为一种安全措施。

涉及未初始化合约的 UUPS 漏洞

关键点是合约的初始化函数是公开的,可以通过代理或直接从 EOA 或另一个合约调用,如下图所示。在upgradeable Ownable contracts 中,通常在初始化函数中设置所有者。

  • 如果通过代理从委托调用initialize,则所有者存储在代理的存储中。

  • 如果直接在实现上调用initialize,则所有者存储在实现的存储中。

代理存储和实现存储中的所有者分别调用代理和实现中的初始化函数

作为实现合约的所有者不应该重要,因为直接在实现合约上执行函数会修改其自己的存储,这不是“真实”存储。因此,许多团队没有考虑直接在实现合约上执行初始化函数。

关键问题是,任何定义为onlyOwner的函数都可以由该“其他”所有者在实现合约上执行。

正是这种情况揭示了 OpenZeppelin 的 UUPS 合约从 v4.1.0 到 v4.3.1 中存在的漏洞。负责从一个实现合约迁移到下一个实现合约的函数还执行了对该新地址的委托调用。

Open Zeppelin 的 UUPS upgradeToAndCall 函数代码片段,其中红框高亮显示了 if(data.length>0)条件语句

这个函数受onlyOwner修饰符保护,旨在仅由“合法所有者”执行。然而,实现合约的所有者也可以执行该函数。

修改实现的存储不是问题。然而,所有者现在可以委托调用到包含自毁操作码的合约。这个操作将擦除实现合约的代码,阻止代理迁移到新实现。实质上,这个漏洞可能会导致代理中的数百万美元资产被无限期地锁定。任何使用这些 UUPS 库版本的代理,其实现合约尚未“初始化”的风险。任何人都可以执行初始化函数,成为所有者,并对具有 selfdestruct 操作码的合约执行委托调用。

攻击者获取未初始化实现合约所有权的原始缓解措施

为了缓解这个问题,OpenZeppelin 的工程团队的第一个建议是始终使用构造函数和 initializer 修饰符“初始化”实现合约,如下所示。

constructor() initializer {}

这只是一项安全措施,以防止攻击者初始化实现中的存储变量以成为所有者。这个修饰符旨在放置在继承链中所有实现合约的构造函数中。

对于最子级合约,变量initialSetup将始终为 true。然而,在实现合约的父合约中,在部署期间,initialized将为 1,address(this).code.length == 0。正是为了这种情况,construction变量存在——以启用实现合约的父合约的初始化。

换句话说,问题中的代码行旨在考虑以下合约架构的情况,其中父合约也需要初始化。应该使用initializer修饰符;onlyInitializing修饰符不打算初始化构造函数。

contract ImplementationParent is Initializable {
    // 这里 initialSetup 将为 false
    // 但 construction 将为 true
    constructor() initializer {}
}

contract ImplementationChild is Initializable, ImplementationParent {
    // 这里 initialSetup 将为 true
    constructor() ImplementationParent() initializer {}
}

一旦实现合约被“初始化”,任何人都不可能再执行初始化函数并成为合约的所有者。

这不再是推荐的缓解措施,防止攻击者成为实现合约所有者的推荐解决方案在下一节中展示,但修饰符initializer保留了变量construction,出于向后兼容性的原因。可能会在将来的合约版本中删除它。

_disableInitializers()函数

OpenZeppelin 提出的最新和推荐的初始化实现合约的方法是使用__disableInitializers()函数。

代码如下:

function _disableInitializers() internal virtual {
    // solhint-disable-next-line var-name-mixedcase
    InitializableStorage storage $ = _getInitializableStorage();
    if ($._initializing) {
        revert InvalidInitialization();
    }

    if ($._initialized != type(uint64).max) {
        $._initialized = type(uint64).max; // 将 initialized 设置为其最大值,防止重新初始化
        emit Initialized(type(uint64).max);
    }
}

因此,目前防止“未初始化”合约导致漏洞的推荐方法是在所有实现合约中包含以下构造函数:

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

实现合约本身永远不会升级,只有代理会。因此,将版本设置为type(uint64).max(实现中的_initialized)确保实现合约永远不会被初始化。在实现中锁定初始化器不会阻止代理调用它们,因为阻止委托调用的_initialized存储在代理中。

_init 和_init_unchained 函数

初始化可升级合约的函数可以具有任何名称,但 OpenZeppelin 合约遵循一个标准。实际上,它们都有两个初始化函数,使用两个名称:<Contract Name>_init<Contract Name>_init_unchained

<Contract Name>_init_unchained函数包含在初始化实现合约时必须执行的所有代码。例如,在 ERC20 代币的情况下,此函数设置代币的名称和符号。

function __ERC20_init_unchained(string memory name_, string memory symbol_)    
    internal    
    onlyInitializing
{
    ERC20Storage storage $ = _getERC20Storage();
    $._name = name_;
    $._symbol = symbol_;
}

<Contract Name>_init函数执行<Contract Name>_init_unchained 以及其所有必须初始化的父合约的<Parent Contract>_init_unchained

让我们考虑 GovernorUpgradeable.sol v5 合约的情况,该合约需要初始化自身和其一个父合约,EIP712Upgradeable合约。

GovernorUpgradeable.sol v5 合约中_Governor_init 函数的代码片段

通常,执行合约的_init函数就足够了,它还会初始化父合约。必须注意不要两次初始化同一个合约,这可能发生在两个合约共享相同父合约的继承链中。

这就是为什么有两个函数_init_init_unchained的原因。如果需要初始化一个合约而不初始化其父合约,则必须使用_init_unchained函数。

初始化 ERC20 可升级合约

可以在下面的图像中看到如何初始化可升级合约的示例,显示了 OpenZeppelin Wizard 生成的可升级 ERC20 代币合约。

由 OpenZeppelin Wizard 生成的可升级 ERC20 代币合约。

请注意,它在其父级上调用了__<Contract Name>_init。无论用于初始化合约的方案如何,确保继承链中的所有合约都得到正确初始化,没有合约被初始化两次或初始化器是幂等的是至关重要的。

警告和建议

在结束本文之前,应该给出一些关于正确使用 Initializable.sol 合约的建议。

  1. OpenZeppelin 还有另一个名为 Initializable.sol 的合约,位于其 openzeppelin-upgrades 库中。出于向后兼容性的原因,不应在新项目中使用此合约。导入的推荐方式是通过import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

  2. 由于初始化函数是常规函数,存在被另一个交易抢先的风险。如果发生这种情况,代理合约必须重新部署。为了防止这种情况,ERC1967Proxy 合约构造函数在部署时调用实现。初始化调用必须在此时进行,编码在_data变量中。

  3. 如前一节所述,在合约作为继承链的一部分时,必须小心确保不会两次调用父级初始化程序。模式不会识别此类潜在问题,因此必须手动进行验证。解决此问题的一种方法是确保所有初始化函数都是幂等的,即它们无论执行多少次都具有相同的效果。

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 2
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/