ERC-1967 详解:代理合约的标准存储插槽

理解 implementation/admin/beacon 三个标准插槽的 keccak256 推导与防冲突原理

5 分钟阅读
ERC-1967 详解:代理合约的标准存储插槽

ERC-1967 详解:代理合约的标准存储插槽

目录


一、ERC-1967 是什么

ERC-1967 是一个极简标准,它只规定两件事:

  1. 代理合约的关键变量(实现地址、管理员、beacon)应该存在哪几个固定插槽
  2. 这些变量改变时应该发出什么事件(log)

ERC-1967 只规定某些存储变量放哪、改变时发什么日志,仅此而已

不规定怎么升级、谁能升级——那些是 Transparent Proxy、UUPS 等模式的事。ERC-1967 只管”地址存哪”。

二、它解决什么问题

代理用 delegatecall 让实现合约操作代理的存储。如果代理把 implementation 地址存在插槽 0、admin 存在插槽 1(最朴素的做法),而实现合约也用插槽 0、1 存自己的业务变量——直接冲突,delegatecall 一执行就把 implementation 地址改坏了。

ERC-1967 的解法:把代理的元数据藏到几个由哈希算出来的、几乎不可能和正常变量撞车的”随机”插槽里

三、三个标准存储插槽

3.1 实现地址插槽

  • 推导bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
  • 十六进制0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
  • 存当前逻辑合约地址。

3.2 管理员插槽

  • 推导bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)
  • 十六进制0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
  • 存有权升级的管理员地址。

3.3 Beacon 插槽

  • 推导bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)
  • 十六进制0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50
  • 存 beacon 合约地址(用于 Beacon 代理模式,见后续文章)。

四、为什么这些插槽能防冲突

存储空间有 2²⁵⁶−1 个插槽。这几个插槽是 keccak256 哈希(再减 1)产生的”伪随机”大数,没有已知的原像(preimage)。

  • 正常状态变量从 0、1、2… 顺序分配——和这几个天文数字插槽撞车的概率,等同于哈希碰撞,天文数字级别地不可能
  • 动态类型(mapping/数组)也是 keccak256 算插槽,同样的随机性,照样不会撞。

所以无论实现合约怎么声明变量,都不会意外动到这几个插槽。

五、为什么要减 1

推导公式里那个 - 1 很关键:

keccak256('eip1967.proxy.implementation') 本身是某个字符串的哈希——它的原像是已知的(就是那个字符串)。Solidity 计算 mapping/数组插槽时也用 keccak256,理论上某个精心构造的 mapping 键可能哈希到这个值。

减 1 之后,得到的新数字不再是任何已知字符串的 keccak256 输出(找不到原像),进一步杜绝了被 Solidity 存储机制意外命中的可能。这是一个微小但严谨的安全细节。

六、读写这些插槽的代码

因为这些是裸插槽,必须用汇编读写:

bytes32 internal constant IMPLEMENTATION_SLOT =
    0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

function _getImplementation() internal view returns (address impl) {
    assembly { impl := sload(IMPLEMENTATION_SLOT) }
}

function _setImplementation(address newImpl) internal {
    assembly { sstore(IMPLEMENTATION_SLOT, newImpl) }
    emit Upgraded(newImpl); // ERC-1967 要求发的事件
}

⚠️ 标准的安全假设是:开发者不会故意用汇编往这些插槽写脏数据。如果你恶意 sstore(0x360894...382bbc, 0),会直接破坏代理功能。

七、区块浏览器如何识别代理

ERC-1967 标准化的最大好处之一:Etherscan 等浏览器可以可靠地识别代理合约

浏览器检查一个合约的这几个特定插槽是否非零——非零就判定为代理,然后:

  • 显示当前实现地址,甚至历史实现地址;
  • 提供”Read/Write as Proxy”界面,把代理和实现的 ABI 合并展示,方便用户交互。

OpenZeppelin 的 Transparent Proxy、UUPS,Solady 的 gas 优化版 UUPS,都遵循 ERC-1967。

八、总结

  • ERC-1967 只规定”代理元数据存哪个插槽 + 改变时发什么日志”;
  • 三个插槽:implementation / admin / beacon,都由 keccak256(标签) - 1 推导;
  • 选这些哈希插槽是为了和正常变量、动态类型插槽都不冲突
  • 减 1 是为了让插槽不再有已知原像,更安全;
  • 标准化让 Etherscan 能自动识别代理。

九、动手练习项目:ERC-1967 标准代理 Slot1967Proxy

项目目标

实现一个把 implementation 和 admin 都存在 ERC-1967 标准插槽的代理,亲手用汇编读写哈希插槽,验证它与实现合约的任意变量布局都不冲突。部署到 Sepolia 并让 Etherscan 自动识别为代理。

合约要求

1. Logic.sol(故意占用插槽 0、1 制造潜在冲突)

contract Logic {
    address public owner;   // 插槽 0
    uint256 public value;   // 插槽 1
    function setValue(uint256 v) external { value = v; }
    function setOwner(address o) external { owner = o; }
}

2. Slot1967Proxy.sol

  • 定义两个常量插槽(implementation、admin)用第三节的十六进制值;
  • 在合约里写一个 pure 函数复算插槽,断言等于硬编码值:bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
  • 构造函数:传入初始 implementation 和 admin,用汇编 sstore 写入两个插槽,发出 UpgradedAdminChanged 事件;
  • _getImplementation() / _getAdmin():汇编 sload;
  • upgradeTo(address newImpl):仅 admin(读 admin 插槽比对 msg.sender)可调,sstore + emit;
  • fallback():标准 assembly delegatecall 模板,转发到 _getImplementation(),冒泡返回值/revert。

测试要求(Foundry)

  1. test_SlotConstantsCorrect:pure 函数复算的插槽 == 硬编码十六进制;
  2. test_NoCollisionWithLogicVars:通过代理调 setValue(42)setOwner(addr),断言代理插槽 0/1 是 Logic 的 owner/value,而 implementation/admin 仍在哈希插槽完好无损;
  3. test_ReadImplementationSlot:用 vm.load(proxy, IMPLEMENTATION_SLOT) 读出 == 部署的 Logic 地址;
  4. test_Upgrade:upgradeTo 新 Logic,vm.load 验证插槽更新、事件正确;
  5. test_OnlyAdminUpgrade:非 admin upgradeTo 应 revert。

Sepolia 部署与验证步骤

  1. 部署 Logic、Slot1967Proxy(传 Logic 地址 + 你的地址作 admin);
  2. 在 Etherscan 打开代理地址——它应自动出现 “Read as Proxy / Write as Proxy” 标签(证明插槽被识别);
  3. cast storage <proxy> 0x360894...382bbc 读出 implementation 地址核对;
  4. 通过代理调 setValue,再读代理的插槽 1 确认数据写在代理而非 Logic。

进阶挑战(可选)

  • 加上 beacon 插槽和一个最简 IBeacon,让 fallback 可选地从 beacon 读 implementation(为 Beacon 篇预习);
  • 写注释回答:如果去掉公式里的 - 1,理论上什么样的 mapping 键可能命中 implementation 插槽?

💬 评论