上一篇文章详细介绍了端到端实现:一个最小化代币合约、链下状态重建和 React 前端——从 `mint()` 到 MetaMask 的全过程。本文将继续探讨:如何对这样的项目进行质量保证测试?
我(还)不是区块链工程师,但 QA 模式在不同领域都通用,借鉴其他领域已有的有效方法是我学习最快的方式。
该合约只做三件事:`mint`、`transfer` 和 `burn`,但这已足够实践完整的 QA 工具链:静态分析、变异测试、gas 性能分析、形式化验证。
代码在 `egpivo/ethereum-account-state`。
区块链 QA 金字塔:从底层的静态分析到顶层的形式化验证在添加任何新内容之前,项目已经有:
所有测试都通过了。覆盖率看起来不错。那为什么还要做更多测试?
因为"所有测试通过"并不意味着"捕获了所有错误"。100% 的行覆盖率如果没有断言检查正确的内容,仍然可能遗漏真正的错误。
Slither(Trail of Bits) 捕获测试无法看到的问题:重入、未检查的返回值、接口不匹配。
./scripts/run-qa.sh slither
结果:1 个中等发现:`erc20-interface`:`transfer()` 没有返回 `bool`。
这是预期的。该合约故意不是完整的 ERC20:它是一个教学性的状态机。但这个发现并非学术性的:
如果有人后来将此代币导入期望 ERC20 的协议,接口不匹配会静默失败。Slither 现在标记它,以便做出有意识的决定。
./scripts/run-qa.sh coverage覆盖率结果。
一个未覆盖的函数:`BalanceLib.gt()`。我们稍后会回到这个问题。
forge coverage 输出:24 个测试通过,Token.sol 覆盖率表./scripts/run-qa.sh gas
三个操作的基准 gas 成本:
操作的 Gas 成本在后续运行中,`forge snapshot — diff` 与基准进行比较。`transfer()` 中 20% 的 gas 回退对每个用户都是实际成本——在合并前捕获它的成本很低。
这是事情变得有趣的地方。Gambit(Certora) 生成 变异体: `Token.sol` 的副本,带有小的故意错误(`+=` 改为 `-=`,`>=` 改为 `>`,条件取反)。管道对每个变异体运行完整的测试套件。如果变异体存活(所有测试仍然通过),这就是一个具体的测试缺口。
./scripts/run-qa.sh mutation
结果:97.0% 变异分数——33 个变异体中 32 个被杀死,1 个存活。
Gambit 的输出日志显示每个变异体及其更改。几个例子:
生成变异体 #7:BinaryOpMutation — Token.sol:168
totalSupply = totalSupply.add(amountBalance) → totalSupply = totalSupply.sub(amountBalance)
被 test_Mint_Success 杀死
生成变异体 #19:RelationalOpMutation — Token.sol:196
if (!fromBalance.gte(amountBalance)) → if (fromBalance.gte(amountBalance))
被 test_Transfer_Success 杀死
生成变异体 #28:SwapArgumentsMutation — Token.sol:81
return Balance.unwrap(a) > Balance.unwrap(b) → return Balance.unwrap(b) > Balance.unwrap(a)
存活 ← 没有测试捕获到这个Gambit 变异测试:32 个被杀死,1 个存活,变异分数 97.0%
存活的变异体在 `BalanceLib.gt()` 中将 `a > b` 交换为 `b > a`。没有测试捕获到它,因为 `gt()` 是死代码。它在 `Token.sol` 中从未被调用。
覆盖率标记了 91.67% 的函数,但无法解释差距。变异测试做到了:`gt()` 是死代码,没有任何东西调用它,如果它错了也没人会注意到。
智能合约中的死代码或未受保护的代码有真实的先例。
该函数不打算可调用,但没有人测试这个假设。相比之下,我们的 `gt()` 是无害的,但模式是相同的:存在但从未执行的代码是没有人监视的代码。
Halmos(a16z) 对所有可能的输入进行符号推理。模糊测试采样随机值并希望碰到边界情况,而 Halmos 则彻底证明属性。
./scripts/run-qa.sh halmos
结果:9/9 符号测试通过——所有输入的所有属性都得到证明。
已验证的属性:
已验证的属性一个实用说明:Halmos 0.3.3 不支持 `vm.expectRevert()`,所以我无法用正常的 Foundry 方式编写回滚测试。解决方法是 try/catch 模式——如果调用在应该回滚时成功,`assert(false)` 使证明失败:
function check_mint_reverts_on_zero_address(uint256 amount) public {
vm.assume(amount > 0);
try token.mint(address(0), amount) {
assert(false); // 不应该到达这里
} catch {
// 预期回滚 - Halmos 证明这条路径总是被采用
}
}
不是最漂亮的,但它有效——Halmos 仍然为所有输入证明属性。这是你只有通过实际运行工具才能发现的那种事情。
关于形式化验证为何重要的背景:
漏洞在代码中,任何人都可以审查,但在部署前没有工具或测试捕获到它。像 Halmos 这样的符号证明器的存在正是为了弥补这一差距——它们不采样;它们穷尽输入空间。
Halmos 输出:9 个测试通过,0 个失败,符号测试结果测试文件是 `contracts/test/Token.halmos.t.sol`。
第一篇文章的架构有一个 TypeScript 域层,镜像链上状态机。这个阶段测试两者是否真的一致。
我为 TypeScript 域层添加了 fast-check 属性测试,镜像 Foundry 的模糊器为 Solidity 所做的:
npm test - tests/unit/property.test.ts
结果:修复真实错误后9/9 属性测试通过。
测试的属性:
fast-check 在 `Token.ts` `transfer()` 中发现了真实的跨层一致性错误。缩减的反例立即清晰:
3 次测试后属性失败
缩减了 2 次
反例:transfer(from=0xaaa…, to=0xaaa…, amount=1n)
→ from == to (自我转账)
→ verifyInvariant() 返回 false
自我转账(`from == to`) 打破了 `sum(balances) == totalSupply` 不变量。`toBalance` 在 `fromBalance` 更新之前被读取,所以当 `from == to` 时,陈旧的值覆盖了扣除:
// 之前(有错误)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← from == to 时陈旧
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← 覆盖减法
修复:在写入 `fromBalance` 后读取 `toBalance`,匹配 Solidity 的存储语义:
// 之后(已修复)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← 现在读取更新后的值
this.accounts.set(to.getValue(), toBalance.add(amount));
Solidity 合约未受影响:它在每次写入后重新读取存储。但 TypeScript 镜像有一个微妙的顺序依赖性,现有的单元测试都没有覆盖。
更大规模的跨层不匹配曾是灾难性的。
我们的自我转账错误不会让任何人损失金钱,但失败模式在结构上是相同的:两个应该一致的层,不一致。
在现有项目上运行 QA 工具从来不只是"安装和运行"。在它们工作之前,有几件事情出了问题:
所有内容都通过两个脚本运行:
./scripts/run-qa.sh slither gas # 只进行静态分析 + gas
./scripts/run-qa.sh mutation # 只进行变异测试
./scripts/run-qa.sh all # 所有内容
不是每个检查都很快。Slither 和覆盖率在每次提交时运行。变异测试和 Halmos 较慢——更适合每周或预发布运行。
五个 QA 层,每个捕获不同类别的问题。
层说明Gambit 和 fast-check 在这一轮中给出了最可操作的结果。
QA 检查现在作为六阶段管道连接到 GitHub Actions:
CI 管道:构建和 Lint 扇出到测试、覆盖率、Gas、Slither 和审计阶段GitHub Actions 管道:构建和 Lint 控制所有下游阶段。
阶段说明Ethereum Account State: QA Pipeline for a Minimal Token 最初发布在 Medium 的 Coinmonks 上,人们通过突出显示和回应这个故事来继续对话。


