仅供教育参考,不构成投资建议;投资可能产生损失。
直接答案
当实现代码以不同于现有状态创建时布局所赋予的含义,读取或写入代理的存储槽,就会发生代理存储冲突。升级可能因此把余额当作地址、清空所有者、破坏映射的根槽,或覆写升级控制数据。
使用 delegatecall 时,实现字节码在代理的上下文中执行:存储、余额和 address(this) 都属于代理。变量名不会存储在链上,因此 EVM 只按新代码计算出的槽位和字节偏移操作。
所以,升级必须保持已部署布局兼容,而不只是成功编译或公开相同函数。授权升级之前,应将编译器生成的布局与确切的已部署版本比较。
工作原理
Solidity 通常在继承关系经过 C3 线性化后,按声明顺序从槽 0 起放置状态变量。小于 32 字节的值可能共用一个槽;结构体和数组还有额外的放置规则,而映射和动态数组会根据基础槽推导数据位置。基础槽一旦移动,其派生数据的位置也会改变。
需要审计以下四类不同的冲突边界:
- 代理与实现之间: 实现地址、管理员等代理自有字段不得占用应用状态所用的槽。ERC-1967 为实现、信标和管理员数据规定了编译器常规分配不会触及的标准槽。
- 旧实现与新实现之间: 现有应用变量必须保持兼容的槽、偏移和类型。在末尾追加变量可能安全,但插入、重排、删除或改变变量类型会重新解释已有存储字。
- 继承关系: 向基类添加状态或改变继承顺序,可能移动派生合约声明的存储,即使派生合约源码没有变化。
- 预留或命名空间存储: 正确使用存储间隙可以为基类预留空间,ERC-7201 风格的命名空间可以隔离布局。但两者都不允许任意修改既有布局内部。
示例
假设版本 1 的布局如下:
uint256 totalAssets; // slot 0
address owner; // slot 1版本 2 错误地在开头插入一个变量:
bool paused; // slot 0, offset 0
uint256 totalAssets; // slot 1
address owner; // slot 2升级后,paused 会读取旧 totalAssets 的低位字节,新 totalAssets 会把旧 owner 存储字当作整数读取,而 owner 会读取槽 2 中原有的内容,通常为零。原始存储字仍然存在,但新代码赋予了它们不同含义。交易可能成功执行,却以错误解释后的状态进行授权或记账。
风险与升级检查
- 为两个实现生成存储布局输出,并以实际部署的参考合约为准,比较槽、偏移、类型和继承信息。
- 对传统线性布局,只在末尾追加新变量。除非已证明兼容,否则不要重排现有字段、改变类型、删除后复用,也不要修改基类。
- 使用存储间隙时,应按实际占用的预留槽数精确缩减间隙。使用命名空间存储时,须确保命名空间标识唯一,并验证每个现有命名空间内的变更。
- 不要把 ERC-1967 当作完整保护。它将代理元数据与编译器分配的应用槽分开,但不能保证两个实现布局彼此兼容。
- 在分叉网络或状态快照上测试升级及任何重新初始化函数。执行前后都要核对所有者、角色、余额、授权额度、映射条目、暂停状态、实现槽以及回滚或紧急控制。
如果升级已疑似造成冲突,在治理权限允许时,应暂停后续升级和状态变更调用。保留升级前区块号与实现字节码,比较受影响槽位的原始存储,并由合格的合约工程师设计迁移方案、接受独立审查。没有经验证的存储映射就反复升级,可能破坏更多原本可恢复的状态。
常见误区
- “变量名没变,布局就是安全的。” 名称不决定存储位置,类型、顺序、打包、继承和命名空间规则才决定。
- “删除变量就能释放槽位供复用。” 代理存储会持续存在。除非经过严格审查的迁移将旧值清除或转换,否则复用槽位只是给旧存储字赋予新含义。
- “一笔测试交易成功就证明升级兼容。” 测试可能只触及少量槽位。布局验证和状态差异测试必须覆盖特权字段、打包值、映射、数组和继承存储。
相关主题
来源
- 智能合约简介 - Solidity Documentation(访问日期:2026-08-21)
- 存储和瞬态存储中状态变量的布局 - Solidity Documentation(访问日期:2026-08-21)
- ERC-1967:代理存储槽 - Ethereum Improvement Proposals(访问日期:2026-08-21)
- 编写可升级合约 - OpenZeppelin Documentation(访问日期:2026-08-21)