简体中文 | English
升级 fixture
每个目录存放一对编译产物 old.json 与 new.json,端到端地演示一条兼容性规则。多数手写样例刻意做成 Foundry 写出的那种小型平铺 artifact:一个 abi 数组加一个 storageLayout 对象。
scripts/e2e.sh 中的 CLI 端到端检查会跑遍每一对,实战教程使用其中几对演示完整走查。
每个目录都是自包含的:当某一侧未变时,它是复制而不是共享文件,因此单独看一个目录也能读懂并运行。
来源说明:除下面单独标注的 OpenZeppelin 样例外,这里的源码和手写样例均属于本仓库;其余 real-* 是由本仓库源码生成的真实工具输出。手写样例的字段形态遵循 Solidity 编译器的 storageLayout 文档与 ABI 规范。
| 目录 | 变化 | check 退出码 |
|---|---|---|
compatible/ |
同一个合约重新编译:只有编译器生成的 astId 与类型表顺序不同 |
0 |
append/ |
追加一个存储变量、一个函数和一个事件 | 0 |
storage-removed/ |
owner 变量被删除 |
1 |
storage-renamed/ |
owner 在同一位置、类型不变的前提下重命名为 admin |
0 |
storage-moved/ |
totalSupply 与 owner 交换 slot |
1 |
struct-change/ |
Config struct 增加一个成员 |
1 |
struct-members-missing/ |
struct 类型缺少 members 字段,输入被拒绝 |
2 |
schema-missing-fields/ |
mapping 的 key/value 以及数组的 base 缺失,输入被拒绝 |
2 |
storage-gap-shrink/ |
基础合约把 __gap 的一个 slot 用于新变量,gap 缩小但结束位置不变 |
0 |
transient-moved/ |
transient 变量在 slot 之间移动 | 1 |
transient-append/ |
新版本新增一个 transient 变量 | 0 |
transient-removed/ |
新产物没有报告 transient 布局,因此旧的 transient 变量消失 | 1 |
storage-gap-finished/ |
__gap 被一个结束位置相同的 struct 整体替换 |
0 |
storage-gap-unsafe/ |
加入同一个变量但 gap 保持原大小,导致 gap 与其后的变量移动 | 1 |
storage-gap-dynamic/ |
__gap 是动态数组,slot 里是长度而非保留字节,被新变量替换时不当作 gap |
1 |
abi-function-removed/ |
transfer(address,uint256) 被删除,而存储未变 |
1 |
abi-fallback-kind/ |
fallback 处理器被同签名的具名 function fallback() 取代 |
1 |
abi-event-indexed/ |
Transfer 的 value 参数变为 indexed |
1 |
abi-handler-mutability/ |
新 ABI 的 receive 声明为 nonpayable,属于无效输入 |
2 |
abi-noncanonical-width/ |
两侧 ABI 都使用非规范的 uint0256,不能因为相同就判定兼容 |
2 |
abi-noncanonical-array/ |
两侧 ABI 都使用非规范维度 [01],不能因为相同就判定兼容 |
2 |
abi-components-nontuple/ |
两侧 ABI 都给 uint256 携带了仅 tuple 可用的 components |
2 |
invalid-json/ |
old.json 被截断 |
2 |
unsupported-artifact/ |
old.json 是 Hardhat 单合约产物,不含存储布局 |
2 |
contract-selector/ |
一份包含两个合约的 build info:Alpha 兼容,而 Beta 移动了变量,因此由 --contract 决定结论 |
不传选择器为 2,Alpha 为 0,Beta 为 1 |
namespaced-storage/ |
old.json 带有 ERC-7201 @custom:storage-location 注释,其命名空间成员不在编译器布局中 |
2 |
schema-solc-0.5-to-0.8/ |
同一份合约分别由 solc 0.5.17 与 solc 0.8.28 编译,包含 0.5 写出的旧式 ABI 字段 | 0 |
schema-solc-0.6-to-0.8/ |
同一份合约分别由 solc 0.6.12 与 solc 0.8.28 编译 | 0 |
real-solc-compatible/ |
solc Standard JSON:追加变量 | 0 |
real-solc-incompatible/ |
solc Standard JSON:变量宽度从 256 位缩为 128 位 | 1 |
real-solc-no-layout/ |
solc Standard JSON:编译时未请求存储布局 | 2 |
real-foundry-compatible/ |
Foundry 平铺 artifact:追加变量 | 0 |
real-foundry-incompatible/ |
Foundry 平铺 artifact:变量宽度缩小 | 1 |
real-foundry-no-layout/ |
Foundry 平铺 artifact:编译时未请求存储布局 | 2 |
real-hardhat-compatible/ |
Hardhat build info:追加变量 | 0 |
real-hardhat-incompatible/ |
Hardhat build info:变量宽度缩小 | 1 |
real-hardhat-per-contract/ |
Hardhat 单合约 artifact:不含存储布局 | 2 |
real-complex-compatible/ |
solc Standard JSON:打包字段、mapping、struct 与 transient 变量保持原位,同时缩减 __gap 并新增变量 |
0 |
real-complex-incompatible/ |
solc Standard JSON:打包偏移、mapping 值类型、struct 成员类型与 transient slot 发生变化 | 1 |
real-custom-layout-compatible/ |
solc 0.8.29 layout at 42:继承变量与派生变量共用 slot,追加变量不移动旧布局 |
0 |
real-custom-layout-moved/ |
solc 0.8.29 把基址从 42 改为 43:继承变量、打包变量和 mapping 的 slot 都移动 | 1 |
real-oz-erc20-4.9.3-to-4.9.6/ |
OpenZeppelin ERC20Upgradeable 两个正式版本:astId 变化,但继承布局、mapping 与 gap 保持不变 |
0 |
real-oz-erc20-4.9.3-to-5.0.0/ |
OpenZeppelin ERC20Upgradeable 跨大版本:旧版普通存储变量从新版布局消失,部分 ABI 条目也被移除 | 1 |
real-oz-erc20-5.0.0-ast/ |
同一份 OpenZeppelin 5.0.0 合约:仅新端附带真实编译器 AST,其中标注了 ERC-7201 命名空间 | 2 |
schema-unsupported/ |
类型表使用了本版本不认识的 encoding |
2 |
fractional-offset/ |
两边的 offset 都是四舍五入后为整数的小数,被当作不可用 schema 数据拒绝 |
2 |
ambiguous-build-info/ |
old.json 是包含两个带存储布局合约的 Hardhat build info |
2 |
missing-file/ |
故意没有 new.json,用于覆盖不可读路径 |
2 |
真实产物矩阵
ABI 结构矩阵还检查非 tuple 参数携带 components 的错误,包括函数输入/输出、事件和自定义错误;三种外层格式与 old/new 损坏侧都必须定位到 components 并返回 2,tuple 控制组保持兼容。
同一 ABI 类型矩阵也覆盖前导零数组维度(含多维数组与 tuple 数组):uint256[01]、bytes32[][002]、int8[2][00] 和 tuple[01][]。它们在各参数位置都必须返回输入错误 2,对应规范类型的控制组返回 0;库测试还保留动态数组、[0] 及大于机器整数范围的维度。
ABI 位宽矩阵还检查 uint0256、int08、bytes01 在函数输入/输出、嵌套 tuple、事件和自定义错误中的拒绝行为。三种外层格式、old/new 单侧及双侧损坏都必须返回 2,JSON 诊断精确定位参数的 type;对应规范类型的控制组必须返回 0。这些 ABI 样例均为本仓库手写测试数据(Apache-2.0)。
输入完整性矩阵还检查 ABI 特殊函数:receive 仅接受 payable,fallback 仅接受 payable 或 nonpayable。三种外层格式在 old/new 单侧及双侧损坏时,check 与 abi 的 text/JSON 输出都必须稳定返回输入错误 2,并准确指向 stateMutability;合法组合的控制组返回 0。
scripts/e2e.sh 还会调用 scripts/check_input_integrity.py,从本仓库 Apache-2.0 的 real-complex-compatible/old.json 派生损坏输入。矩阵覆盖变量与 struct 成员的必需字段、mapping/数组引用、struct 成员表、未知 encoding 和错误字段类型;逐一检查平铺、Standard JSON、Hardhat 外层格式,以及常规和 transient 布局在 old/new 单侧及双侧损坏时的退出码 2、诊断路径、输入来源和重复运行一致性。完整布局的控制组必须返回 0。这些变体仅在临时目录生成,不需要 Solidity 编译器或网络。
real-* 使用本仓库自行编写的 Counter 源码 及其兼容、不兼容版本(Apache-2.0)。三份源码分别复制成工具项目中的同一个源文件名,再编译成 old.json 或 new.json。没有改写编译器输出中的字段;solcjs 输出开头的非 JSON 提示行在保存前被移除。
| 工具 | 固定版本 | 生成命令及保存位置 |
|---|---|---|
| solcjs | 0.8.28 | solcjs --standard-json < fixtures/real-artifacts/inputs/old.json > output.txt;其余三份输入文件同法编译。输入的源文件键为 src/Counter.sol,outputSelection 为 abi 与 storageLayout(without-layout.json 只请求 abi)。检查 errors 中没有 severity: "error",移除 stdout 的提示行后保存完整 JSON。 |
| Forge | 1.7.1,solc 0.8.28 | 将源码复制到临时项目的 src/Counter.sol,执行 forge build --force --use /path/to/solc --extra-output storageLayout,复制 out/Counter.sol/Counter.json;无布局用例省略 --extra-output storageLayout。 |
| Hardhat | 2.27.2,solc 0.8.28 | 将源码复制到临时项目的 contracts/Counter.sol,使用配置执行 hardhat compile --force --config hardhat.config.cjs;复制 artifacts/build-info/*.json,以及无布局用例的 artifacts/contracts/Counter.sol/Counter.json。 |
real-solc-*、real-foundry-* 和 real-hardhat-* 的旧端均由 old.sol 编译。兼容和不兼容用例的新端分别由对应源码编译;无布局用例的新端使用正常的旧版编译输出。上述命令只用于生成已提交的样例;运行检查器和测试无需安装这些工具或访问网络。
real-complex-* 使用本仓库自行编写的 ComplexLayout 源码及其兼容、不兼容版本(Apache-2.0)。三份 Standard JSON 输入将源码放在相同的 src/ComplexLayout.sol 键下,并请求 abi、storageLayout 与 transientStorageLayout。分别执行 npx --yes solc@0.8.28 --standard-json < fixtures/real-artifacts/inputs/complex-old.json(另两份输入同法),检查没有编译错误,移除 solcjs 的非 JSON 提示行,即得到两对样例中的完整编译器输出;两对的 old.json 相同。端到端测试同时核对这两对产物的具体诊断及退出码。生成时使用的是 solcjs 0.8.28,测试时无需安装它。
real-custom-layout-* 的三份 Standard JSON 输入包含本仓库自行编写的 Base 与 Shifted 源码(Apache-2.0),分别固定 layout at 42、同基址追加变量、改为 layout at 43。对每份输入执行 npx --yes solc@0.8.29 --standard-json < fixtures/real-artifacts/inputs/custom-layout-old.json(替换输入文件名),确认没有编译错误,去掉 solcjs 的非 JSON 提示行,保存完整输出。两对的 old.json 相同;运行测试无需安装 solc 或联网。布局预期依据 Solidity 0.8.29 存储布局规范。
real-oz-erc20-4.9.3-to-4.9.6/ 来自 OpenZeppelin Contracts Upgradeable 的 v4.9.3 和 v4.9.6 token/ERC20/ERC20Upgradeable.sol 及其依赖,源项目以 MIT 授权。仓库没有复制 Solidity 源码;两份 fixture 是用 solcjs 0.8.20 从对应 npm 包生成的完整 Standard JSON 输出,只请求 ERC20Upgradeable 的 abi 与 storageLayout,保存时仅去掉 solcjs 的非 JSON 提示行。复现时分别安装 @openzeppelin/contracts-upgradeable@4.9.3 与 @openzeppelin/contracts-upgradeable@4.9.6 到独立目录;将各版本源码以 @openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol 为键放入 Standard JSON sources,用对应目录的 node_modules 作为 solcjs --base-path 和 --include-path。两版均使用 solcjs 0.8.20。生成产物沿用上游 MIT 来源;本仓库新增的测试代码仍为 Apache-2.0,运行测试不需要 npm 或网络。
real-oz-erc20-4.9.3-to-5.0.0/ 的旧端是上述 v4.9.3 产物的独立副本;新端来自 OpenZeppelin Contracts Upgradeable v5.0.0 的同名合约及其依赖,同样遵循上游 MIT 许可。复现新端时,在独立目录安装 @openzeppelin/contracts-upgradeable@5.0.0、@openzeppelin/contracts@5.0.0 和 solc@0.8.20,使用相同的源码键与 abi、storageLayout 输出选择,并用该目录的 node_modules 作为 solcjs --base-path 和 --include-path;仅去掉 stdout 的非 JSON 提示行,未改写编译器输出字段。没有复制 Solidity 源码。新布局的普通 storage 为空,旧变量删除及 ABI 移除使 check 以 1 退出。由于这两份产物没有 AST,测试不能验证 v5 的 ERC-7201 命名空间成员;它只验证这次跨大版本升级被阻断。运行测试不需要 npm 或网络。
real-oz-erc20-5.0.0-ast/ 复用上述 v5.0.0 同名合约及 MIT 来源。旧端独立复制不带 AST 的 v5.0.0 产物;新端用同一源码和依赖、同一 solcjs 0.8.20 重新编译,保留 abi、storageLayout,并为 @openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol 额外请求源级 ast。其 ERC20Storage 结构体的 AST 文档含有 @custom:storage-location erc7201:openzeppelin.storage.ERC20。保存完整 Standard JSON 输出时只移除了 solcjs 的非 JSON 提示行,未改写字段,也未复制 Solidity 源码。CLI 对新端报 artifact.namespaced-storage.unsupported 并以 2 退出;这证明真实 AST 上的拒绝行为,不表示已能比较命名空间成员。测试离线运行。
编译器 schema 矩阵
schema-* 各对用于检查不同编译器写出的字段形态:
- solc 0.5.17 会在 ABI 条目中把
constant与payable写在stateMutability旁边。这些多余字段会被忽略,因此 0.5 的产物与其它版本一样可以解码。 - solc 0.6.12 与 0.8.28 只写
stateMutability。 - 在
stateMutability出现之前的 ABI(0.4 时代)无法精确解码,因为constant区分不了pure与view。这类条目会被显式报错拒绝,而不是被猜测。 - 类型表中不认识的
encoding会被拒绝,因为它可能改变字节的读取方式。
生成的 fixture 来自下面这份合约,由上述三个版本以 abi 与 storageLayout 作为
outputSelection 编译得到:
pragma solidity ^VERSION;
contract Token {
uint256 public totalSupply;
address public owner;
mapping(address => uint256) public balanceOf;
uint8 private flags;
uint8 private nextFlags;
event Transfer(address indexed from, address indexed to, uint256 value);
function transfer(address to, uint256 value) public returns (bool) {
balanceOf[msg.sender] -= value;
balanceOf[to] += value;
emit Transfer(msg.sender, to, value);
return true;
}
}
三个版本为它生成的布局完全相同,因此这两对跨版本样例被期望是兼容的。源码属于本仓库;fixture 是它的编译产物。
transient storage 遵循与常规存储相同的规则,位于它自己的命名空间:诊断项使用
transientStorage[...] 作为位置,因此报告能区分这两个地址空间。没有报告 transient 布局的产物会被视为没有 transient 变量——Foundry 写出空布局时就是这个样子;把它与带有该布局的产物相比,
就会读成"删除",因此用未开启 transientStorageLayout 输出选择的版本编译时会阻断,而不是悄悄通过。
重命名变量是本工具唯一一处刻意比 OpenZeppelin Upgrades Core 更宽松的地方:参考实现把重命名视为不安全(除非设置 unsafeAllowRenames),而这里字节留在原处,所以 storage-renamed/ 报告警告并以 0 退出。详见差分对比记录。
包含多个合约的外层结构需要 --contract NAME 或 --contract SOURCE:NAME;不提供时会被报告为歧义,而不是被猜测。
ERC-7201 命名空间存储会被拒绝,而不是被假定兼容。命名空间通过其注释推导出的 slot 访问,而编译器在 storageLayout 中只列出状态变量,因此命名空间的成员不在输入里,其兼容性未知。
存储 gap 使用 __gap 定长数组约定:它覆盖的字节是未使用的,因此后续版本可以取用,前提是 gap 仍然结束在它原先结束的位置——这正是让声明在它之后的变量保持在原位的原因。三对 storage-gap-* 分别是该变化的安全版本、不安全版本,以及动态数组命名 __gap 时不适用的版本:动态数组的 slot 存放的是长度,不是保留字节。
三个子命令彼此独立,其中两对样例直接演示了这一点:
moon run src/cmd/moonupgradeguard -- abi fixtures/storage-removed/old.json fixtures/storage-removed/new.json
moon run src/cmd/moonupgradeguard -- storage fixtures/abi-function-removed/old.json fixtures/abi-function-removed/new.json
两者都以 0 退出,尽管对这两对执行 check 都会以 1 退出。
手动运行一对样例
# 兼容:不输出任何内容并以 0 退出
moon run src/cmd/moonupgradeguard -- check fixtures/compatible/old.json fixtures/compatible/new.json
# 不兼容:输出移动并以 1 退出
moon run src/cmd/moonupgradeguard -- check fixtures/storage-moved/old.json fixtures/storage-moved/new.json
# 机器可读:诊断数组写在 stdout
moon run src/cmd/moonupgradeguard -- check fixtures/abi-function-removed/old.json fixtures/abi-function-removed/new.json --format json
scripts/e2e.sh 会把每一对样例交给构建出的 native 可执行文件运行,并核对退出码、应出现的诊断码、
JSON 合法性,以及同一输入两次运行是否产生完全一致的字节。