EIP 1967 是一个标准,用于规定代理合约执行所需信息的存储位置。UUPS(通用可升级代理标准)和 Transparent Upgradeable Proxy Pattern(透明可升级代理模式)都使用了它。
请记住:**EIP 1967 仅说明某些存储变量的存放位置以及它们发生更改时应触发哪些日志,仅此而已。它并未说明这些变量该如何更新,或者谁有权管理它们。**它也没有定义任何需要实现的公开函数。更新这些变量的规范由 Transparent Upgradeable Proxy Pattern 或 UUPS 规范提供。
代理运行需要两个关键变量:implementation address(实现合约地址)和 admin(管理员)。实现合约地址是代理委托调用的目标地址。在升级过程中,implementation address 会被更改为升级后的合约地址;此时只有来自 admin 的调用才会被接受以执行更改。
前置要求
本文假设读者已经基本了解代理和 delegatecall 的工作原理、什么是存储槽、什么是 function selectors(函数选择器),以及在代理上下文中什么是函数选择器冲突。
代理存储槽的错误设计方式
以下是一个糟糕的代理设计示例:

首先,changeAdmin() 的函数选择器与实现合约中的函数发生冲突的概率不可忽视。EIP 1967 规范并未说明如何处理这一问题——防止该问题的正确方法在 Transparent Upgradeable Proxy 规范或 UUPS 规范中进行了处理。EIP 1967 与函数选择器冲突无关。
ERC 1967 解决的问题是 implementation 和 admin 变量极有可能与实现合约中定义的存储变量发生冲突。具体来说,它们使用了存储槽 0 和 1,而实现合约也很可能会使用这些存储槽。
防止冲突
由于 admin 和 implementation 地址可能会发生变化,因此它们需要存放在存储变量中,不能被设为不可变的(immutable)。但它们必须存放在不会与实现合约中的存储变量发生冲突的存储槽中。
这里的核心思路是:可能的存储槽空间极其庞大,为 2**256 - 1。
如果我们随机选择一个存储槽,实现合约选择相同存储槽的可能性基本为零。实现合约选中同一存储槽的概率大约与哈希函数发生碰撞的概率相当,因此这种风险基本不存在。
implementation 和 address 的存储槽
implementation 地址存储在以下槽中:
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
admin 地址存储在以下槽中:
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
这些槽是通过伪随机方式从以下公式推导出来的:
分别为 bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) 和
bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)。
转换为十进制后,implementation 和 admin 的存储槽分别为
24440054405305269366569402256811496959409073762505157381672968839269610695612
以及
81955473079516046949633743016697847541294818689821282749996681496272635257091
任何合约都不可能拥有如此多的变量,因此来自常规存储变量的冲突可以忽略不计。动态映射和数组会使用槽号与键值的哈希值,进而使用一个伪随机的存储槽。同理,伪随机数带来的冲突同样是可以忽略不计的。
推导存储槽
如果我们对一个字符串进行 keccak256 哈希计算,其输出本质上是一个伪随机数。通过将该结果减 1,我们会生成一个没有已知哈希原像的随机数,因此合约无法通过向 keccak256 输入任何内容来推导出与它们冲突的存储槽。
关于存储槽使用的假设
当然,编写实现合约的开发者也可以通过以下代码蓄意向这些存储槽中写入数据:
assembly {
// implementation slot
sstore(
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, 0x00
)
}
这会使代理指向零地址,从而导致代理崩溃!我们在此的假设是开发者不会这样做。
EIP 1967 使得 Etherscan 很容易识别其正在查看的是否为代理合约
以下是 Compound Finance’s proxy contract 的一个示例。

通过查看合约在上述存储槽中是否具有非零值,区块链浏览器可以判断该合约是否为代理合约。关于上图的几个观察结果如下:
- 在紫色圆圈中,我们看到 Etherscan 已经识别出该合约遵循 EIP-1967 模式。
- 在橙色圆圈中,我们看到了关于实现合约当前位置及其旧位置的提示。区块链浏览器只是读取了当前的 implementation 槽,同时记录了它的历史值。
- 在红色圆圈中,我们看到可以选择从代理或实现合约中进行读取和写入操作。通常情况下,我们需要对代理进行读写,因为它保存了合约的状态。
什么是 beacon 槽?
如果您阅读过原始的 EIP 1967 文档,您会看到其中提到了 beacon 槽。Beacons 在实际应用中极少被使用,因此我们将其讨论推迟到文章的末尾。
Beacons 将是另一篇文章的主题,但本质上,它们是一种同时更新多个代理的机制。例如,我们可以将多个代理指向同一个实现合约。由于存储数据分别保存在各自的代理中,因此这些代理之间不会相互干扰。
beacon 合约非常简单:它仅返回实现合约的地址:
interface IBeacon {
function implementation() external view returns (address);
}
在执行 delegatecall 之前,每个代理都会向 beacon 询问当前的实现合约地址是什么。通过更改 beacon 中 implementation() 函数的返回值,所有关联的代理便可以实现一次性更新。
beacon 存储槽为 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50
它是通过 bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1) 推导出来的。
对于不使用 beacons 的代理,可以在此处存储 address(0) 值(或者保留该槽为空)。
OpenZeppelin 和 Solady 的实现
OpenZeppelin’s Transparent Upgradeable Proxy 和 UUPS 合约都使用了 ERC 1967 来定义本文中所讨论变量的存储位置。
以节省 Gas 著称的库 Solady 也提供了一个利用 ERC 1967 的 UUPS proxy implementation。
结论
ERC 1967 是一项规定 implementation 合约、admin 和 beacon 的存储变量应放置于何处的标准。它使得区块链浏览器能够轻松识别某个合约是否为代理,并消除了代理合约与实现合约之间发生存储冲突的可能性。
通过 RareSkills 了解更多
本文是我们高级 Solidity Bootcamp 的一部分。请参阅该计划以了解更多信息。
首发于 2023 年 12 月 20 日