QA 仪表板监控智能合约 状态 上一篇文章详细介绍了端到端的实现:一个最小化的代币合约、链下状态重构QA 仪表板监控智能合约 状态 上一篇文章详细介绍了端到端的实现:一个最小化的代币合约、链下状态重构

Ethereum账户状态:最小代币的质量保证流程

2026/04/09 13:48
阅读时长 14 分钟
如需对本内容提供反馈或相关疑问,请通过邮箱 [email protected] 联系我们。
QA 仪表板监控智能合约状态

上一篇文章详细介绍了端到端实现:一个最小化代币合约、链下状态重建和 React 前端——从 `mint()` 到 MetaMask 的全过程。本文将继续探讨:如何对这样的项目进行质量保证测试?

我(还)不是区块链工程师,但 QA 模式在不同领域都通用,借鉴其他领域已有的有效方法是我学习最快的方式。

该合约只做三件事:`mint`、`transfer` 和 `burn`,但这已足够实践完整的 QA 工具链:静态分析、变异测试、gas 性能分析、形式化验证。

代码在 `egpivo/ethereum-account-state`。

区块链 QA 金字塔:从底层的静态分析到顶层的形式化验证

我们的起点

在添加任何新内容之前,项目已经有:

  • 21 个 Foundry 单元测试 覆盖每个状态转换(成功、非法输入回滚、事件发出)
  • 3 个不变量测试 通过 `TokenHandler` 在 10 个参与者上运行随机的 `mint`/`transfer`/`burn` 序列(每个 128k 次调用)
  • 模糊测试 检查随机金额的 `sum(balances) == totalSupply`
  • TypeScript 域测试 (Vitest) 镜像链上状态机
  • CI:编译、测试、lint(Prettier + solhint)

所有测试都通过了。覆盖率看起来不错。那为什么还要做更多测试?

因为"所有测试通过"并不意味着"捕获了所有错误"。100% 的行覆盖率如果没有断言检查正确的内容,仍然可能遗漏真正的错误。

第一阶段:智能合约静态分析和覆盖率

Slither

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 覆盖率表

Gas 快照

./scripts/run-qa.sh gas

三个操作的基准 gas 成本:

操作的 Gas 成本

在后续运行中,`forge snapshot — diff` 与基准进行比较。`transfer()` 中 20% 的 gas 回退对每个用户都是实际成本——在合并前捕获它的成本很低。

第二阶段:变异测试和形式化验证

变异测试(Gambit)

这是事情变得有趣的地方。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)

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 域层,镜像链上状态机。这个阶段测试两者是否真的一致。

使用 fast-check 进行基于属性的测试

我为 TypeScript 域层添加了 fast-check 属性测试,镜像 Foundry 的模糊器为 Solidity 所做的:

npm test - tests/unit/property.test.ts

结果:修复真实错误后9/9 属性测试通过

测试的属性:

  • `Balance`:交换律、结合律、恒等元、逆元、比较一致性
  • `Token`:随机操作序列下的不变量 `sum(balances) == totalSupply`(200 次运行,每次 50 个操作)
  • `Token`:随机序列后 `totalSupply` 非负
  • `mint` 对有效输入总是成功
  • `transfer` 保持 `totalSupply`

fast-check 发现的错误

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 工具从来不只是"安装和运行"。在它们工作之前,有几件事情出了问题:

  • 0% 覆盖率,因为 `foundry.toml` 没有测试路径:第一次 `forge coverage` 运行在所有方面都返回 0%。原来 `foundry.toml` 没有指定 `test = "contracts/test"` 或 `script = "contracts/script"`,所以 Forge 没有发现任何测试。覆盖率命令静默成功——它只是没有什么要覆盖的。这是最具误导性的失败:绿色运行但没有有用的输出。
  • `InvariantTest` 导入在 forge-std v1.14.0 中消失:`Invariant.t.sol` 从 `forge-std` 导入 `InvariantTest`,这在最近的版本中被删除。编译失败,出现不透明的"找不到符号"错误。修复是删除导入——现在 `Test` 本身就足够用于 Foundry 的不变量测试。
  • `uint256(token.totalSupply())` vs `Balance.unwrap()`:测试使用显式转换从用户定义的 `Balance` 类型中提取底层的 `uint256`。它可以编译,但这是错误的习惯用法——`Balance.unwrap(token.totalSupply())` 才是 UDVT 系统设计的用法。应用于 `Token.t.sol`、`Invariant.t.sol` 和 `DeploySepolia.s.sol`。

管道设计

所有内容都通过两个脚本运行:

  • scripts/setup-qa-tools.sh`:安装 Slither、Halmos、Gambit(幂等)
  • `scripts/run-qa.sh`:运行检查,将带时间戳的结果保存到 `qa-results/`

./scripts/run-qa.sh slither gas # 只进行静态分析 + gas
./scripts/run-qa.sh mutation # 只进行变异测试
./scripts/run-qa.sh all # 所有内容

不是每个检查都很快。Slither 和覆盖率在每次提交时运行。变异测试和 Halmos 较慢——更适合每周或预发布运行。

总结

区块链 QA 工具链:每层捕获什么——从静态分析到跨层属性测试

五个 QA 层,每个捕获不同类别的问题。

层说明

Gambit 和 fast-check 在这一轮中给出了最可操作的结果。

CI 管道

QA 检查现在作为六阶段管道连接到 GitHub Actions:

CI 管道:构建和 Lint 扇出到测试、覆盖率、Gas、Slither 和审计阶段

GitHub Actions 管道:构建和 Lint 控制所有下游阶段。

阶段说明

参考文献

  • Ethereum Account State 源代码:[github.com/egpivo/ethereum-account-state](https://github.com/egpivo/ethereum-account-state)
  • 上一篇文章:Ethereum Account State
  • Slither:github.com/crytic/slither
  • Gambit:github.com/Certora/gambit
  • Halmos:github.com/a16z/halmos
  • fast-check:github.com/dubzzz/fast-check
  • Foundry:getfoundry.sh

注释

  • 这篇文章改编自我的原始博客文章。

Ethereum Account State: QA Pipeline for a Minimal Token 最初发布在 Medium 的 Coinmonks 上,人们通过突出显示和回应这个故事来继续对话。

免责声明: 本网站转载的文章均来源于公开平台,仅供参考。这些文章不代表 MEXC 的观点或意见。所有版权归原作者所有。如果您认为任何转载文章侵犯了第三方权利,请联系 [email protected] 以便将其删除。MEXC 不对转载文章的及时性、准确性或完整性作出任何陈述或保证,并且不对基于此类内容所采取的任何行动或决定承担责任。转载材料仅供参考,不构成任何商业、金融、法律和/或税务决策的建议、认可或依据。

$30,000 等值 PRL + 15,000 USDT

$30,000 等值 PRL + 15,000 USDT$30,000 等值 PRL + 15,000 USDT

充值并交易 PRL,即可提升您的奖励!