EIP 1967 是一个关于代理合约存储信息位置的标准,用于解决代理合约与实现合约之间的存储冲突问题。文章详细介绍了实现地址和管理员地址的存储位置,并解释了如何防止存储冲突及如何使用 EIP 1967 来识别代理合约。
EIP 1967 是一个关于代理合约需要存储信息的标准。UUPS(通用可升级代理标准)和 透明可升级代理模式 都使用它。
请记住:EIP 1967 仅说明了某些存储变量的位置以及它们变化时会发出的日志,没有更多内容。它不说明这些变量如何更新或谁可以管理它们。 它不定义任何公共函数来实现。更新这些变量的规范在透明可升级代理模式或 UUPS 规范中给出。
代理需要操作的两个关键变量是 实现地址 和 管理员。实现地址是代理委派调用的地方。在升级期间,实现地址 被更改为升级后的合约;只有来自 管理员 的调用会被接受以进行更改。
本文假设读者对代理和 delegatecall 的工作原理、存储槽是什么、函数选择器 是什么以及在代理上下文中函数选择器冲突是什么有基本的了解。
以下是一个 不好的 代理设计:
首先,changeAdmin()
的函数选择器与实现中的某个函数发生冲突的概率是不可忽视的。EIP 1967 规范并没有说明如何处理这个问题 - 正确的避免这个问题的方法在透明可升级代理规范或 UUPS 规范中处理。EIP 1967 与函数选择器冲突无关。
ERC 1967 解决的问题是,implementation
和 admin
变量 非常可能 与实现合约中定义的存储变量发生冲突。具体来说,它们使用存储槽 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
)
}
这将通过将代理指向零地址来破坏代理!假设是开发者 不会 这样做。
以下是一个 Compound Finance 的代理合约 的例子。
通过查看合约在上述槽中是否有非零值,区块浏览器可以判断合约是否为代理合约。关于上面截图的一些观察:
如果你阅读原始的 EIP 1967,你会看到对信标槽的引用。信标在实践中很少使用,因此我们将其讨论推迟到本文末尾。
信标是另一个主题,根本上,它们是一种同时更新多个代理的机制。例如,我们可以将多个代理指向相同的实现合约。由于存储在每个代理中单独保持,代理将不相互干扰。
信标合约非常简单:它只需返回实现合约的地址:
interface IBeacon {
function implementation() external view returns (address);
}
每个代理在进行 delegatecall 之前会询问信标当前的实现合约地址。通过更改信标中 implementation()
函数的返回值,所有代理都可以一次性更新。
信标存储槽是 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50
,其派生自 bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)
。值 address(0) 可以存储在这里,以供不使用信标的代理(或者可以将槽留空)。
OpenZeppelin 的透明可升级代理 和 UUPS 合约均使用 ERC 1967 来定义本文讨论的变量存储位置。
高效的库 Solady 还提供了 UUPS 代理实现,该实现利用了 ERC 1967。
ERC 1967 是一个关于放置实现合约、管理员以及信标的存储变量的标准。它使区块浏览器能够轻松识别合约是否为代理,并消除了代理与实现之间存储冲突的可能性。
本文是我们高级 solidity 训练营 的一部分。请查看该计划以了解更多信息。
原文发布于 2023 年 12 月 20 日
- 原文链接: rareskills.io/post/erc19...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!