ERC-1967 详解:代理合约的标准存储插槽
目录
- 一、ERC-1967 是什么
- 二、它解决什么问题
- 三、三个标准存储插槽
- 四、为什么这些插槽能防冲突
- 五、为什么要减 1
- 六、读写这些插槽的代码
- 七、区块浏览器如何识别代理
- 八、总结
- 九、动手练习项目:ERC-1967 标准代理 Slot1967Proxy
一、ERC-1967 是什么
ERC-1967 是一个极简标准,它只规定两件事:
- 代理合约的关键变量(实现地址、管理员、beacon)应该存在哪几个固定插槽;
- 这些变量改变时应该发出什么事件(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 写入两个插槽,发出
Upgraded和AdminChanged事件; _getImplementation()/_getAdmin():汇编 sload;upgradeTo(address newImpl):仅 admin(读 admin 插槽比对 msg.sender)可调,sstore + emit;fallback():标准 assembly delegatecall 模板,转发到_getImplementation(),冒泡返回值/revert。
测试要求(Foundry)
test_SlotConstantsCorrect:pure 函数复算的插槽 == 硬编码十六进制;test_NoCollisionWithLogicVars:通过代理调setValue(42)和setOwner(addr),断言代理插槽 0/1 是 Logic 的 owner/value,而 implementation/admin 仍在哈希插槽完好无损;test_ReadImplementationSlot:用vm.load(proxy, IMPLEMENTATION_SLOT)读出 == 部署的 Logic 地址;test_Upgrade:upgradeTo 新 Logic,vm.load验证插槽更新、事件正确;test_OnlyAdminUpgrade:非 admin upgradeTo 应 revert。
Sepolia 部署与验证步骤
- 部署 Logic、Slot1967Proxy(传 Logic 地址 + 你的地址作 admin);
- 在 Etherscan 打开代理地址——它应自动出现 “Read as Proxy / Write as Proxy” 标签(证明插槽被识别);
- 用
cast storage <proxy> 0x360894...382bbc读出 implementation 地址核对; - 通过代理调 setValue,再读代理的插槽 1 确认数据写在代理而非 Logic。
进阶挑战(可选)
- 加上 beacon 插槽和一个最简 IBeacon,让 fallback 可选地从 beacon 读 implementation(为 Beacon 篇预习);
- 写注释回答:如果去掉公式里的
- 1,理论上什么样的 mapping 键可能命中 implementation 插槽?