摘要

委托代理合约被广泛用于可升级性和节省 Gas。这些代理合约依赖于一个逻辑合约(也称为实现合约或主拷贝),通过 delegatecall 进行调用。这使得代理可以保持持久状态(存储和余额),同时将代码执行委托给逻辑合约

为避免代理和逻辑合约之间的存储使用冲突,逻辑合约的地址通常保存在一个特定的存储槽中(例如,OpenZeppelin 合约中的 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc),该槽位保证永远不会被编译器分配。本 EIP 提议一组标准存储槽来保存代理信息。这使得区块浏览器等客户端能够正确提取并向最终用户展示此信息,同时也允许逻辑合约据此执行操作

动机

委托代理被广泛使用,作为支持升级和降低部署 Gas 成本的手段。这类代理的例子可见于 OpenZeppelin Contracts、Gnosis、AragonOS、Melonport、Limechain、WindingTree、Decentraland 等众多项目

一个典型的例子是区块浏览器。最终用户希望与底层逻辑合约交互,而不是代理本身。拥有从代理检索逻辑合约地址的通用方法,允许区块浏览器展示逻辑合约的 ABI 而非代理的 ABI。浏览器通过检查合约在特定存储槽的内容来判断它是否是一个代理,如果是,则同时展示代理和逻辑合约的信息。例如,Etherscan 上对地址 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 的展示就是如此

另一个例子是那些明确知晓自身正被代理的逻辑合约。这使得它们可以基于此事实,在其逻辑中触发代码更新。一个通用的存储槽使得这些用例可以独立于所使用的具体代理实现


规范

对代理的监控对许多应用的安全至关重要。因此,必须能够跟踪实现槽和管理员槽的变更。遗憾的是,跟踪存储槽的变更并不容易。因此,建议任何改变这些槽位的函数都应当 同时发出相应的事件。这包括从 0x0 到第一个非零值的初始化

以下是提议的用于存储代理特定信息的存储槽。后续可以根据需要通过其他 ERC 添加更多槽位

逻辑合约地址

存储槽0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc(通过 bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) 计算获得)

保存此代理委托调用的逻辑合约地址。如果使用信标,则此槽应当为空。此槽的变更应当通过以下事件通知:

event Upgraded(address indexed implementation);

信标合约地址

存储槽0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50(通过 bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1) 计算获得)

保存此代理所依赖的信标合约地址(作为后备)。如果直接使用逻辑地址,则此槽应当为空,并且仅在逻辑合约地址槽为空时才应考虑。此槽的变更应当通过以下事件通知:

event BeaconUpgraded(address indexed beacon);

信标用于在单一位置维护多个代理的逻辑地址,允许通过修改单个存储槽来升级多个代理。信标合约必须实现以下函数:

function implementation() returns (address)

基于信标的代理合约不使用逻辑合约地址槽。相反,它们使用信标合约地址槽来存储它们所连接的信标地址。为了了解信标代理所使用的逻辑合约,客户端应当

  1. 从信标逻辑存储槽读取信标地址
  2. 调用该信标合约的 implementation() 函数

信标合约上 implementation() 函数的结果不应依赖于调用者 (msg.sender)

管理员地址

存储槽0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103(通过 bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) 计算获得)

保存允许为此代理升级逻辑合约地址的地址(可选)。此槽的变更应当通过以下事件通知:

event AdminChanged(address previousAdmin, address newAdmin);

原理阐述

本 EIP 标准化了逻辑合约地址的存储槽,而不是在代理合约上提供公开方法。其原理在于,代理永远不应该向最终用户暴露任何可能与逻辑合约函数发生冲突的函数

需要注意的是,即使函数名称不同,冲突也可能发生,因为 ABI 仅依赖函数选择器的前四个字节。这可能导致意外的错误甚至漏洞,即对代理合约的调用返回了与预期不同的值,因为代理拦截了调用并用其自身的值进行响应

根据 Nomic Labs 的《以太坊代理中的恶意后门》所述:

代理合约中任何与实现合约中函数选择器匹配的函数都将被直接(优先)调用,跳过实现代码 由于函数选择器使用固定数量的字节,因此始终存在冲突的可能性。这对于日常开发来说不是问题,因为 Solidity 编译器会检测合约内的选择器冲突,但当选择器用于跨合约交互时,这就变得可被利用了。冲突可能被滥用来创建一个看似行为良好、实则隐藏后门的合约

代理公共函数可能被利用的事实,使得有必要以不同的方式标准化逻辑合约地址

所选存储槽的主要要求是,它们必须永远不会被编译器选中来存储任何合约状态变量。否则,逻辑合约在写入自己的变量时可能会无意中覆盖代理上的这些信息

Solidity 根据变量声明的顺序(在合约继承链被线性化之后)将变量映射到存储:第一个变量分配第一个槽,依此类推。动态数组和映射中的值是例外,它们存储在对键和存储槽的连接进行哈希计算后的位置。Solidity 开发团队已确认存储布局将在新版本中保持不变:

由于存储指针可以传递给库,状态变量在存储中的布局被认为是 Solidity 外部接口的一部分。这意味着对本节所述规则的任何更改都被视为语言的破坏性更改,并且由于其关键性质,在执行前应非常仔细地考虑。如果发生此类破坏性更改,我们希望发布一种兼容模式,在该模式下编译器将生成支持旧布局的字节码

Vyper 似乎遵循与 Solidity 相同的策略。请注意,用其他语言或直接用汇编编写的合约可能会发生冲突

这些槽位的选择方式保证了它们不会与编译器分配的状态变量冲突,因为它们依赖于一个不以存储索引开头的字符串的哈希值。此外,添加了 -1 偏移量使得哈希的原像无法被知晓,进一步降低了可能遭受攻击的机会

安全考量

本 ERC 依赖于所选存储槽不会被 Solidity 编译器分配这一事实。这保证了实现合约不会意外覆盖代理运行所需的任何信息。因此,选择了编号较高的槽位以避免与编译器分配的槽位冲突。同时,选择了没有已知原像的位置,以确保写入具有恶意构造键的映射时无法覆盖它

旨在修改代理特定信息的逻辑合约必须通过写入特定存储槽来故意这样做(如同 UUPS 模式的情况)