可初始化的智能合约设计模式
初始化器是可升级合约实现构造函数行为的方式。
在部署合约时,通常会调用构造函数来初始化存储变量。例如,构造函数可能会设置代币的名称或最大供应量。在可升级合约中,这些信息需要存储在代理(合约)的存储变量中,而不是实现(合约)的存储变量中。在代理中添加一个构造函数,如下所示:
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();
// 初始化其他状态变量
}
}
上述合约的预期执行顺序如下:
调用 initializeChild()
函数。使用 initializer
修饰符,它将 initialized
变量更新为 true
。此变量被继承链中的 所有 合约使用。
接下来,在 initializeChild()
中调用 initializeParent()
函数。initializeParent()
也具有 initializer
修饰符,因此它要求变量 initialized
为 false
。
但是,当 initializeChild
运行时,initialized
变量已经设置为 true
,因此在调用 initializeParent()
时事务将回滚。
OpenZeppelin 的 Initializable.sol
合约通过允许在继承链中的所有合约进行初始化来解决了这个问题,同时防止在初始化事务之后调用初始化器。
Initializable.sol
的核心包括三个修饰符:initializer
、reinitializer
和 onlyInitializing
,以及两个状态变量:_initializing
和 _initialized
。
每个修饰符仅在特定场景中使用,具有不同的目的。它们的使用概述如下:
initializer
修饰符应在可升级合约的初始部署期间使用,且仅在最子级合约中使用。
reinitializer
修饰符应用于初始化实现合约的新版本,同样仅在最子级合约中使用。
onlyInitializing
修饰符与父级初始化器一起使用,在初始化期间运行,并防止在以后的事务中调用这些初始化器。这解决了前一节提到的问题,即由于最子级初始化器禁用了父级初始化器,导致父级初始化器无法运行的问题。通过这种方案,可以初始化所有父级合约,以及最子级合约。
下面是说明这些场景的可视化图表。这些修饰符的使用将在接下来的部分中提供更详细的解释。
Initializable.sol 实现了 ERC-7201 模式,其中状态变量在结构体中声明。如果你不了解此模式,请将 _initializing
和 _initialized
视为状态变量。
_initializing
变量是一个布尔值,指示合约是否处于初始化过程中,而 _initialized
变量存储合约的当前版本。它从值 0
开始,在第一次初始化后将为 1
。如果开发人员选择部署新实现并希望“重新初始化”存储变量以新值,则它可能会更高。
视频演示了这些部分如何协同工作:
initializer
修饰符如下所示。稍后将详细解释代码的一些部分。
由于需要解决与先前版本的向后兼容性问题,上面的代码并不直观。然而,主要思想是双重的:
将_initialized
变量设置为1
,以防止函数再次执行(绿色框)。
在_initializing
为真时,临时允许使用onlyInitializing
修改的父级初始化程序运行。如上面的代码所示,当合约尚未初始化时,_initializing
为假,当初始化事务正在运行时为真,当初始化事务完成时为假。
由于initializer
要求_initializing
为假,所以不能在继承链中的父合约中使用,因为在执行这些合约时,_initializing
为真。相反,父合约的初始化函数必须使用另一个修饰符,具体是onlyInitializing
,它允许函数仅在_initializing
为真时执行。
onlyInitializing
修饰符设计用于父合约中,因为它仅在_initializing
为真时执行,如下所示。
modifier onlyInitializing() {
_checkInitializing();
_;
}
function _checkInitializing() internal view virtual {
if (!_isInitializing()) {
revert NotInitializing(); // 如果**_initializing**为假,则回滚
}
}
下面是此流程的可视化表示。
总结一下,父级初始化程序受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");
}
总结升级的步骤如下:
不能在新版本上使用initializer
。
如果要在初始化函数中更改任何状态变量,则必须使用reinitializer
。
或者,如果在升级期间不需要进行状态更改,则可以没有初始化程序。
在initializer
修饰符中,有一行代码可能引起了你的注意,但我在最初的解释中没有提到。如下所示:
bool construction = initialized == 1 && address(this).code.length == 0;
表达式address(this).code.length == 0
仅在合约部署期间为真。因此,只有在构造函数中使用initializer
修饰符时,construction
变量才能为真。
实际上,initializer
修饰符可以在实现合约的构造函数中使用以“初始化”它。这可能看起来有些反直觉,因为实现合约的存储不应该重要。然而,正如我们将看到的,这种初始化作为一种安全措施。
关键点是合约的初始化函数是公开的,可以通过代理或直接从 EOA 或另一个合约调用,如下图所示。在upgradeable Ownable contracts 中,通常在初始化函数中设置所有者。
如果通过代理从委托调用initialize
,则所有者存储在代理的存储中。
如果直接在实现上调用initialize
,则所有者存储在实现的存储中。
作为实现合约的所有者不应该重要,因为直接在实现合约上执行函数会修改其自己的存储,这不是“真实”存储。因此,许多团队没有考虑直接在实现合约上执行初始化函数。
关键问题是,任何定义为onlyOwner
的函数都可以由该“其他”所有者在实现合约上执行。
正是这种情况揭示了 OpenZeppelin 的 UUPS 合约从 v4.1.0 到 v4.3.1 中存在的漏洞。负责从一个实现合约迁移到下一个实现合约的函数还执行了对该新地址的委托调用。
这个函数受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
,出于向后兼容性的原因。可能会在将来的合约版本中删除它。
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
存储在代理中。
初始化可升级合约的函数可以具有任何名称,但 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合约。
通常,执行合约的_init
函数就足够了,它还会初始化父合约。必须注意不要两次初始化同一个合约,这可能发生在两个合约共享相同父合约的继承链中。
这就是为什么有两个函数_init
和_init_unchained
的原因。如果需要初始化一个合约而不初始化其父合约,则必须使用_init_unchained
函数。
可以在下面的图像中看到如何初始化可升级合约的示例,显示了 OpenZeppelin Wizard 生成的可升级 ERC20 代币合约。
请注意,它在其父级上调用了__<Contract Name>_init
。无论用于初始化合约的方案如何,确保继承链中的所有合约都得到正确初始化,没有合约被初始化两次或初始化器是幂等的是至关重要的。
在结束本文之前,应该给出一些关于正确使用 Initializable.sol 合约的建议。
OpenZeppelin 还有另一个名为 Initializable.sol 的合约,位于其 openzeppelin-upgrades 库中。出于向后兼容性的原因,不应在新项目中使用此合约。导入的推荐方式是通过import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
。
由于初始化函数是常规函数,存在被另一个交易抢先的风险。如果发生这种情况,代理合约必须重新部署。为了防止这种情况,ERC1967Proxy 合约构造函数在部署时调用实现。初始化调用必须在此时进行,编码在_data
变量中。
如前一节所述,在合约作为继承链的一部分时,必须小心确保不会两次调用父级初始化程序。模式不会识别此类潜在问题,因此必须手动进行验证。解决此问题的一种方法是确保所有初始化函数都是幂等的,即它们无论执行多少次都具有相同的效果。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!