简体中文 | English
与 OpenZeppelin Upgrades Core 的差分对比
存储引擎会针对每一对 fixture 与成熟参考实现
@openzeppelin/upgrades-core
对比。两者拿到同样的产物并被要求给出结论;重点不是它们的措辞是否一致,而是对于某个变更是否阻断升级,两者是否一致,以及每一处分歧是否都被理解。
对比时把参考实现作用在编译器自己的 storageLayout 对象上(那正是它的比较器所消费的形态),并运行 CLI 的 storage 子命令。只有当引擎给出了结论(退出码 0 或 1)时,这一对才算"已比较";
退出码 2 表示产物被拒绝,那不是可比较的结论。
这是开发工具,不在 CI 中运行:它需要 Node 与一份安装好的参考实现,而测试套件保持离线。
复现方式
npm install --prefix /tmp/ozdiff @openzeppelin/upgrades-core@1.46.0
moon build --target native
OZ_UPGRADES_CORE=/tmp/ozdiff/node_modules/@openzeppelin/upgrades-core \
node scripts/oz-differential.mjs
针对 @openzeppelin/upgrades-core 1.46.0 运行,输出为:
reference: @openzeppelin/upgrades-core 1.46.0
engine: _build/native/debug/build/cmd/moonupgradeguard/moonupgradeguard.exe
- abi-event-indexed: engine exit 0 | reference pass | agree
- abi-fallback-kind: engine exit 0 | reference pass | agree
- abi-function-removed: engine exit 0 | reference pass | agree
- ambiguous-build-info: engine exit 2 | reference skip (no storageLayout) | n/a
- append: engine exit 0 | reference pass | agree
- compatible: engine exit 0 | reference pass | agree
- contract-selector: engine exit 2 | reference skip (no storageLayout) | n/a
- fractional-offset: engine exit 2 | reference pass | n/a
- invalid-json: engine exit 2 | reference skip (error: Expected ',' or '}' after property value in JSON at position 50 (line 4 column 1)) | n/a
- missing-file: no pair
- namespaced-storage: engine exit 2 | reference pass | n/a
- real-complex-compatible: engine exit 0 | reference pass | agree
- real-complex-incompatible: engine exit 1 | reference layoutchange, typechange | agree
- real-custom-layout-compatible: engine exit 0 | reference pass | agree
- real-custom-layout-moved: engine exit 1 | reference layoutchange | agree
- real-foundry-compatible: engine exit 0 | reference pass | agree
- real-foundry-incompatible: engine exit 1 | reference typechange | agree
- real-foundry-no-layout: engine exit 2 | reference skip (no storageLayout) | n/a
- real-hardhat-compatible: engine exit 0 | reference pass | agree
- real-hardhat-incompatible: engine exit 1 | reference typechange | agree
- real-hardhat-per-contract: engine exit 2 | reference skip (no storageLayout) | n/a
- real-oz-erc20-4.9.3-to-4.9.6: engine exit 0 | reference pass | agree
- real-oz-erc20-4.9.3-to-5.0.0: engine exit 1 | reference delete | agree
- real-oz-erc20-5.0.0-ast: engine exit 2 | reference pass | n/a
- real-solc-compatible: engine exit 0 | reference pass | agree
- real-solc-incompatible: engine exit 1 | reference typechange | agree
- real-solc-no-layout: engine exit 2 | reference skip (no storageLayout) | n/a
- schema-solc-0.5-to-0.8: engine exit 0 | reference pass | agree
- schema-solc-0.6-to-0.8: engine exit 0 | reference pass | agree
- schema-unsupported: engine exit 2 | reference rename | n/a
- storage-gap-dynamic: engine exit 1 | reference replace | agree
- storage-gap-finished: engine exit 0 | reference pass | agree
- storage-gap-shrink: engine exit 0 | reference pass | agree
- storage-gap-unsafe: engine exit 1 | reference layoutchange | agree
- storage-moved: engine exit 1 | reference delete, insert, layoutchange | agree
- storage-removed: engine exit 1 | reference delete | agree
- storage-renamed: engine exit 0 | reference rename | DIFFER
- struct-change: engine exit 1 | reference typechange | agree
- transient-append: engine exit 0 | reference skip (transient layout) | n/a
- transient-moved: engine exit 1 | reference skip (transient layout) | n/a
- transient-removed: engine exit 1 | reference skip (transient layout) | n/a
- unsupported-artifact: engine exit 2 | reference skip (no storageLayout) | n/a
compared 27 pairs, 1 divergences
storage-renamed: engine exit 0 vs reference rename
一致的部分
- 存储 gap。
storage-gap-shrink正是催生该规则的情形:基础合约把__gap的一个 slot 用于新变量,因此 gap 缩小,但结束位置不变。两者都通过。参考实现用endMatchesGap判定,而引擎用的是同一个判据,所以这里的一致并非偶然。 - 动态数组不是 gap。
storage-gap-dynamic用一个新变量替换名为__gap的动态数组:参考实现报告replace(同一位置上名字与类型都变了,而动态数组的 slot 不是它认得的 gap),两者都阻断。引擎把动态数组当作普通变量比较,得到同一结论的路径更直接:那个 slot 里是数组长度, 不是保留字节。 - 整体替换 gap。
storage-gap-finished把一个__gap整体替换为结束位置相同的 struct。 两者都接受,原因仍是同一条判据:gap 覆盖的是未使用字节,因此只有结束位置必须保持不变。 - 追加。
append追加了一个变量、一个函数和一个事件:两者都通过,因为没有移动任何东西的新增是安全的。 - 移动、删除与类型变化。
storage-moved、storage-removed、struct-change在两者中都阻断。特别是"struct 增加成员"对两者都是类型变化,说明引擎对递归类型变化的保守解读与参考实现一致,而不是更严格。 - 编译器版本。 由真实 solc 0.5.17、0.6.12、0.8.28 产出的
schema-solc-*各对,在两者中都通过。 - Foundry 真实产物。 追加变量与缩小变量宽度的两对平铺 artifact,在两者中分别通过和阻断。
- 真实开源版本。 OpenZeppelin Contracts Upgradeable 的 ERC20Upgradeable 4.9.3 与 4.9.6 使用相同编译器生成的布局有不同
astId,但继承变量、mapping 和 gap 的存储语义不变,两者均通过。 - 跨大版本升级。 ERC20Upgradeable 4.9.3 到 5.0.0 的旧版普通存储变量在新版布局中消失,两者均阻断;仅比较这两个
storageLayout不代表已验证新版 ERC-7201 命名空间。
唯一一处刻意差异
| 样例对 | 引擎 | 参考实现 |
|---|---|---|
storage-renamed |
警告,退出码 0 |
rename,阻断 |
在不移动位置的前提下重命名变量,会让每个字节都留在原处,因此存储布局仍然兼容。参考实现把重命名视为不安全(除非传入 unsafeAllowRenames),因为新名字可能承载新的含义。引擎则报告警告,并把这一点写进公开文档:布局兼容,含义需要人工确认。
改为更严格的策略是一次严重度变更加上快照更新,而不是重写实现;但它会改变现有用户的兼容性契约, 因此留作显式决策,而不是悄悄改变。
对比覆盖不到的情况
ambiguous-build-info与contract-selector包含多个合约,需要--contract;脚本不传选择器,引擎会报告歧义。- 有多个带布局合约的编译器外层产物仍需显式选择合约;当前脚本只自动提取唯一布局。
invalid-json与missing-file在任何布局出现之前就被拒绝。unsupported-artifact是 Hardhat 的单合约产物,本身不含存储布局。schema-unsupported使用了引擎不认识的encoding。引擎拒绝该产物(退出码2),因为编码决定了字节如何被读取;参考实现不校验该字段,并报告了一个无关的重命名。拒绝是刻意的选择:可能影响兼容性的未知形态,绝不能被报告为兼容。- 带有 transient storage 布局的样例对会被跳过:脚本把常规布局交给参考实现,所以它的结论与 transient 命名空间无关,而引擎会单独比较后者。
namespaced-storage被引擎拒绝(退出码2)。该 fixture 带有手写的合约结构体 AST 标注;脚本只把普通storageLayout交给参考比较器,因此它的pass不代表检查过命名空间。本工具识别该 AST 标注,但不分析命名空间成员。real-oz-erc20-5.0.0-ast的新端含真实编译器 AST 和 ERC-7201 标注,引擎因此拒绝(退出码2);脚本只把两侧普通storageLayout交给参考比较器,空布局的pass不代表参考实现检查了命名空间,故不计入可比较案例。
来源说明
参考实现用于理解公开行为;为对齐 gap 规则,阅读了它公开的源码(storage/gap.ts、
storage/compare.ts)。没有复制 Upgrades Core 的源码、测试或 fixture;真实 ERC20 案例只保存 Contracts Upgradeable MIT 授权版本的编译输出,来源见产物样例。脚本只对比行为。