skip to content
kindkang
返回
目录

事情发生在一个我以为自己很熟的代理合约上。

我们维护的一个 logic 合约,做了一次看起来无害的升级 —— 在原有的 address admin 字段下面,加了一个 bool paused。新合约编译通过、测试通过、覆盖率没退步。我合并、走完 timelock、push proxy 升级。半小时后监控告警 admin 字段读出来不是部署时设的地址,而是一个看起来像 keccak 哈希前缀的奇怪值。

下面是当晚我边骂边写的复盘。我把它放出来,是因为这种 bug 在 Solidity 圈是反复出现的”老 bug”,但它每次出现都让我重新意识到:EVM 的存储模型对人类太不友好了,而我经常忘记这件事。

问题第一次出现的样子

升级前 logic 合约的状态变量声明长这样:

contract LogicV1 {
address public admin; // slot 0
uint256 public totalShares; // slot 1
mapping(address => uint256) public balances; // slot 2
}

升级后我以为这样写没问题:

contract LogicV2 {
address public admin; // slot 0
bool public paused; // slot 0 也是这里 ← 我以为
uint256 public totalShares; // slot 1
mapping(address => uint256) public balances; // slot 2
}

我的心智模型是:“address 占 20 字节,bool 占 1 字节,加起来 21 字节,还放得下一个 slot”。这句话本身没错,但它和 EVM 实际的 packing 规则没关系。

实际发生了什么

EVM 存储槽是 32 字节一格。变量按声明顺序从 slot 0 开始放,只有当下一个变量加进来仍然能放进当前 slot 时,编译器才会做 packing。

address admin 占了 slot 0 的低 20 字节。这没问题。

接下来 bool paused —— 理论上它能塞进 slot 0 的剩下 12 字节里。但要把它塞进去,编译器需要在生成 SLOAD/SSTORE 时给两个变量都加上 mask 和 shift 操作。这部分编译器是会做的。所以从 V1 到 V2,理论上:

  • slot 0 高位(offset 20):新 paused
  • slot 0 低位(offset 0):原 admin
  • slot 1:仍是 totalShares

测试通过 —— 因为测试是从一个干净状态部署 V2 来跑的。在这种环境下,adminpaused 共存于 slot 0 没有任何问题。

升级不是干净部署。升级是在 V1 已经写过的存储上跑 V2 的代码。V1 写过的 slot 0 里只有低 20 字节有 admin,高 12 字节是 0。V2 读 admin 的时候做了一次 mask(保留低 20 字节),完美。但 V2 在某个路径里写了一次 paused = true,这次写入只 mask 高位、保留低位 —— 而保留逻辑是先 SLOAD 当前 slot 0,再 OR 进新值,再 SSTORE 回去。

问题来了。从原合约转过来的存储,slot 0 的高 12 字节虽然是 0,但 V2 在某次重构时改了变量声明顺序 —— 那次重构和这次升级一起合的。我没注意。新的顺序里 pausedadmin 前面:

contract LogicV2 {
bool public paused; // slot 0 offset 0
address public admin; // slot 0 offset 1 ← 偏了 1 字节
...
}

于是 V2 读 admin 时,从 slot 0 的 byte 1 开始读 20 字节。原 V1 写入的 admin 地址低 1 字节被丢掉了,前面拼进了 19 字节的旧 admin + 1 字节的相邻数据 —— 这就是那个看起来像 keccak 前缀的值的来源。

我是怎么定位到的

最有用的工具是 cast storage。任何时候你怀疑 storage 出了问题:

Terminal window
cast storage <PROXY_ADDRESS> 0 --rpc-url $RPC
cast storage <PROXY_ADDRESS> 1 --rpc-url $RPC
cast storage <PROXY_ADDRESS> 2 --rpc-url $RPC

把这三行的输出打印出来,跟你脑子里以为的布局对一下。我就是这么在五分钟内确认 slot 0 里 admin 的字节边界错了的。

更系统的工具是 forge inspect,它能直接给你新旧合约的存储布局:

Terminal window
forge inspect LogicV1 storage-layout
forge inspect LogicV2 storage-layout

把两份输出 diff 一下。如果 diff 里有任何一行的 slotoffset 不一样,就不是安全的升级。这个 diff 是机器能做的,不需要靠人脑。

这件事让我把”升级安全”重新做了一遍

之前我们的升级流程里,“是否会破坏存储布局”是 reviewer 拍脑袋判断的。这个 bug 之后我把它变成了 CI 的一道 check:

  1. 部署前,用 forge inspect 抓 V1 的 storage-layout,存为 storage/v1.json
  2. PR 里包含新合约时,CI 跑 forge inspect 抓 V2 的 layout
  3. 跑一个 ~30 行的 diff 脚本,要求 V1 已有字段的 slot + offset + type 全部一致,否则 fail

这是一道几小时就能写完的 check,但因为它机器化,它不会忘、不会累、不会被 deadline 压力压掉。

我也开始更频繁用 OpenZeppelin 的 @openzeppelin/upgrades-core 工具,它能验证布局兼容性。但即使你用了它,理解底下机制仍然重要 —— 工具能告诉你”不兼容”,但不能告诉你”为什么不兼容”,而后者才是你下次怎么改的依据。

一些事后想法

EVM 的存储模型是个非常 raw 的接口。Solidity 在它上面加了一层”假装这是 C struct”的 syntactic sugar,但糖只在干净环境下工作 —— 一旦涉及 delegatecall 跨合约共享存储,sugar 就漏了,你要直接面对 256-bit 大小的 slot 和按字节计算的 offset。

这并不是 EVM 的”设计缺陷” —— 它就是这样设计的,简单、确定、可计算。是我们写代码的人在期待一个它从来没承诺给我们的抽象。

下一次我再看到代理合约 + 升级 + 改字段,我会很慢地读一遍 storage layout,再慢慢动手。慢,但是不会再有人在凌晨叫醒我。

顺手推荐一篇我读过最清楚的 storage layout 文档:Solidity Layout in Storage。看一遍,然后每半年再看一遍。这种东西不复习就会忘。