代理的 EIP 1967 存储槽

EIP 1967 是一个关于代理合约存储信息位置的标准,用于解决代理合约与实现合约之间的存储冲突问题。文章详细介绍了实现地址和管理员地址的存储位置,并解释了如何防止存储冲突及如何使用 EIP 1967 来识别代理合约。

EIP 1967 是一个关于代理合约需要存储信息的标准。UUPS(通用可升级代理标准)和 透明可升级代理模式 都使用它。

请记住:EIP 1967 仅说明了某些存储变量的位置以及它们变化时会发出的日志,没有更多内容。它不说明这些变量如何更新或谁可以管理它们。 它不定义任何公共函数来实现。更新这些变量的规范在透明可升级代理模式或 UUPS 规范中给出。

代理需要操作的两个关键变量是 实现地址管理员。实现地址是代理委派调用的地方。在升级期间,实现地址 被更改为升级后的合约;只有来自 管理员 的调用会被接受以进行更改。

前置条件

本文假设读者对代理和 delegatecall 的工作原理、存储槽是什么、函数选择器 是什么以及在代理上下文中函数选择器冲突是什么有基本的了解。

代理槽的错误设计方式

以下是一个 不好的 代理设计:

不良代理设计的图像

首先,changeAdmin() 的函数选择器与实现中的某个函数发生冲突的概率是不可忽视的。EIP 1967 规范并没有说明如何处理这个问题 - 正确的避免这个问题的方法在透明可升级代理规范或 UUPS 规范中处理。EIP 1967 与函数选择器冲突无关。

ERC 1967 解决的问题是,implementationadmin 变量 非常可能 与实现合约中定义的存储变量发生冲突。具体来说,它们使用存储槽 0 和 1,这些是实现合约可能使用的槽。

防止冲突

由于管理员和实现地址可以更改,因此这些需要在存储变量中,不能是不可变的。但它们必须位于不会与实现合约中的存储变量发生冲突的存储槽中。

关键的想法是:可能的存储槽空间是极其庞大的:2**256 - 1。

如果我们随机选择一个存储槽,实现合约基本上不可能选择相同的槽。实现合约选择相同槽的几率与哈希函数碰撞大致相同,因此风险几乎是不存在的。

实现地址和存储槽

实现地址存储在槽中

0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

管理员地址存储在槽中

0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103

这些槽是通过伪随机方式派生的

bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)

bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) 分别。

用十进制表示,存储槽的实现和管理员是

24440054405305269366569402256811496959409073762505157381672968839269610695612

81955473079516046949633743016697847541294818689821282749996681496272635257091

分别。

没有合约能有那么多变量,因此来自存储变量的冲突可以忽略不计。动态映射和数组采用槽编号和键值的哈希,因此使用了伪随机存储槽。再次强调,伪随机数的冲突是微不足道的。

派生存储槽

如果我们对一个字符串进行 keccak256 哈希,输出本质上是一个伪随机数。通过从结果中减去 1,我们产生一个没有已知哈希原象的随机数,因此没有办法让合约将某个东西放入 keccak256 以衍生出与之发生冲突的存储槽。

关于存储槽使用的假设

当然,编写实现合约的开发者可以 故意 用以下代码写入那些存储槽

assembly {
    // 实现槽
    sstore(
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, 0x00
    )
}

这将通过将代理指向零地址来破坏代理!假设是开发者 不会 这样做。

EIP 1967使Etherscan轻松判断是否查看代理合约

以下是一个 Compound Finance 的代理合约 的例子。

Compound Finance 代理合约的图像

通过查看合约在上述槽中是否有非零值,区块浏览器可以判断合约是否为代理合约。关于上面截图的一些观察:

  • 在紫色圈中,我们看到 Etherscan 已确定该合约遵循 EIP-1967 模式。
  • 在橙色圈中,我们看到关于实现合约的位置以及之前所处位置的注释。区块浏览器仅查看当前实现槽的值,并且记住其过去的值。
  • 在红色圈中,我们看到我们可以选择从代理或实现中写入和读取。通常,我们希望读取或写入代理,因为它保存着合约的状态。

什么是信标槽?

如果你阅读原始的 EIP 1967,你会看到对信标槽的引用。信标在实践中很少使用,因此我们将其讨论推迟到本文末尾。

信标是另一个主题,根本上,它们是一种同时更新多个代理的机制。例如,我们可以将多个代理指向相同的实现合约。由于存储在每个代理中单独保持,代理将不相互干扰。

信标合约非常简单:它只需返回实现合约的地址:

interface IBeacon {
    function implementation() external view returns (address);
}

每个代理在进行 delegatecall 之前会询问信标当前的实现合约地址。通过更改信标中 implementation() 函数的返回值,所有代理都可以一次性更新。

信标存储槽是 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50,其派生自 bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)。值 address(0) 可以存储在这里,以供不使用信标的代理(或者可以将槽留空)。

OpenZeppelin 和 Solady 实现

OpenZeppelin 的透明可升级代理UUPS 合约均使用 ERC 1967 来定义本文讨论的变量存储位置。

高效的库 Solady 还提供了 UUPS 代理实现,该实现利用了 ERC 1967。

结论

ERC 1967 是一个关于放置实现合约、管理员以及信标的存储变量的标准。它使区块浏览器能够轻松识别合约是否为代理,并消除了代理与实现之间存储冲突的可能性。

通过 RareSkills 了解更多

本文是我们高级 solidity 训练营 的一部分。请查看该计划以了解更多信息。

原文发布于 2023 年 12 月 20 日

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

0 条评论

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