Solidity 单元测试工程化:Foundry 测试框架的 Fuzzing 与不变式验证

Solidity 单元测试工程化:Foundry 测试框架的 Fuzzing 与不变式验证
Solidity 单元测试工程化Foundry 测试框架的 Fuzzing 与不变式验证一、当 Happy Path 测试覆盖不了闪电贷攻击大多数 Solidity 测试的现状是这样的为每个函数写两三个手写用例 —— Alice 转账给 Bob、Bob approve 给合约、正常赎回成功 —— 跑通即止。这种 Happy Path 测试在 2023 年以前或许够用但目前 DeFi 攻击已进化到高度组合化的阶段攻击者在一个交易里嵌套 5-8 次跨协议调用利用瞬时价格快照、MEV 捆绑和重入包装来构造攻击序列。手写用例几乎不可能穷举这种组合空间。Foundry 的 fuzzing 测试是应对这种复杂度的实用方案。它的核心思路很简单你不用手动枚举边界条件而是定义输入空间和预期行为让框架用随机输入去砸。搭配不变式测试Invariant Testing你还可以让框架在整个合约生命周期中持续验证某些无论如何都不能违背的规则。flowchart LR A[定义测试合约] -- B{测试类型} B --|Fuzz Test| C[随机输入生成器] B --|Invariant Test| D[状态序列生成器] C -- E[执行函数调用] D -- E E -- F{断言检查} F --|失败| G[记录最小复现用例] F --|通过| H[继续下一轮] G -- I[输出 calldata]在 Foundry 的生态中fuzzing 已经不再是一个可选的高级技巧。它是构建复杂 DeFi 协议时的工程底线 —— 你可以少写一些单测但关键的状态转换和处理外部输入的路径fuzzing 应该成为默认选项。二、Fuzzing 与不变式测试的原理差异很多开发者把 Fuzzing 和 Invariant Testing 混为一谈但它们在 Foundry 中的实现机制有本质区别。理解这个区别直接关系到你能发现什么类型的 bug。Fuzz Test模糊测试Foundry 随机生成函数参数对同一个函数反复调用每次调用之间是独立的。它的搜索空间是函数输入的类型空间。对于function deposit(uint256 amount)来说Foundry 会生成各种uint256值 —— 0、1、max、以及各种中间值。如果某个值导致了 revert 或 assert 失败Foundry 会尝试缩小范围找到最小复现值。Invariant Test不变式验证不变式测试的视角完全不同。它关注合约状态在函数调用序列下的守恒。Framework 随机生成函数调用序列可能是任意公开函数的任意组合顺序并在每次调用后检查预定义的不变量。如果某个序列打破了不变量Foundry 会反向裁剪这个序列找到触发问题的最短调用链。sequenceDiagram participant Fuzz as Fuzzer 引擎 participant Target as 被测试合约 participant Invariant as 不变式检查器 loop 256 runs Fuzz-Target: 生成随机参数 f(args) Target--Fuzz: 返回结果 Fuzz-Fuzz: assert 单次调用结果 end Note over Fuzz,Invariant: 不变式测试更进一步 loop 任意函数序列 Fuzz-Target: 随机选择函数 参数 Target--Fuzz: 状态变更 Fuzz-Invariant: 检查所有不变量 Invariant--Fuzz: 通过 / 失败 alt 不变量被打破 Fuzz-Fuzz: 裁剪调用序列 end end两者的关系是互补的Fuzzing 覆盖单函数边界行为Invariant Testing 覆盖跨函数的状态一致性。一个典型的例子是代币合约Fuzzing 确保transfer在各种参数下不报错Invariant Testing 确保所有transfermintburn操作序列结束后totalSupply依然等于所有地址余额之和。三、生产级代码实践3.1 ERC20 代币的完整 Fuzzing 测试// test/Token.fuzz.t.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; import {Test, console} from forge-std/Test.sol; import {MyToken} from ../src/MyToken.sol; /** * title TokenFuzzTest * notice 对 ERC20 代币进行 Fuzzing 测试 * 设计考量 * - 使用 forge-std 的 bound() 限制输入范围避免 fuzzer 生成极端值导致无意义的 revert * - 每个 fuzz 函数独立确保状态隔离 —— Foundry 会在每次 fuzz 调用前自动 fork 状态 * - 为 fuzzer 提供结构化的断言集而非单个 assert提高 bug 发现密度 */ contract TokenFuzzTest is Test { MyToken public token; address public owner; address public alice; address public bob; // Fuzzer 每次执行的 runs 数量默认 256 // 设置为更高的值可以在 CI 中以 --fuzz-runs 参数覆盖 function setUp() public { owner makeAddr(owner); alice makeAddr(alice); bob makeAddr(bob); vm.startPrank(owner); token new MyToken(TestToken, TST); token.mint(alice, 1_000_000 ether); token.mint(bob, 1_000_000 ether); vm.stopPrank(); } /** * Fuzz 测试transfer 不应在合法输入下 revert * bound(amount, 1, max) 避免 amount0 或溢出 */ function testFuzz_TransferShouldNotRevert(uint256 amount) public { amount bound(amount, 1, token.balanceOf(alice)); vm.prank(alice); token.transfer(bob, amount); // 静默通过即表示无 revert } /** * Fuzz 测试转账后余额守恒 * 这是核心业务不变量的 fuzz 化验证 */ function testFuzz_TransferPreservesTotalSupply(uint256 amount) public { amount bound(amount, 1, token.balanceOf(alice)); uint256 aliceBefore token.balanceOf(alice); uint256 bobBefore token.balanceOf(bob); vm.prank(alice); token.transfer(bob, amount); assertEq(token.balanceOf(alice), aliceBefore - amount); assertEq(token.balanceOf(bob), bobBefore amount); } /** * Fuzz 测试approve 后 allowance 必须精确等于设定值 * 很多 ERC20 实现会因为 approve 非零值再改为零值时存在 race condition */ function testFuzz_ApproveSetsCorrectAllowance(uint256 amount) public { vm.prank(alice); token.approve(bob, amount); assertEq(token.allowance(alice, bob), amount); } /** * Fuzz 测试transferFrom 的权限边界 * 如果 allowance 不足必须 revert */ function testFuzz_TransferFromRevertsWhenAllowanceTooLow( uint256 approveAmount, uint256 transferAmount ) public { approveAmount bound(approveAmount, 0, type(uint256).max - 1); // 确保 transferAmount approveAmount transferAmount bound(transferAmount, approveAmount 1, type(uint256).max); vm.prank(alice); token.approve(bob, approveAmount); vm.prank(bob); vm.expectRevert(); token.transferFrom(alice, bob, transferAmount); } }3.2 不变式测试代币总供给守恒// test/Token.invariant.t.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; import {Test, console} from forge-std/Test.sol; import {StdInvariant} from forge-std/StdInvariant.sol; import {MyToken} from ../src/MyToken.sol; /** * title TokenInvariantTest * notice 验证 ERC20 代币在任意操作序列下的不变式 * 设计考量 * - 继承 StdInvariant 获取 targetContract / targetSelector 等辅助方法 * - 使用 excludeArtifacts 排除不需要的合约路径 * - ghost 变量用于跨调用追踪状态避免 EVM storage slot 混淆 */ contract TokenInvariantTest is StdInvariant, Test { MyToken public token; address public alice; address public bob; function setUp() public { alice makeAddr(alice); bob makeAddr(bob); token new MyToken(TestToken, TST); token.mint(alice, 1_000_000 ether); token.mint(bob, 1_000_000 ether); // 将合约注册为 fuzzer 目标 targetContract(address(token)); // 排除 view 函数和非公开函数避免无意义的 fuzzer 调用 bytes4[] memory excludedSelectors new bytes4[](3); excludedSelectors[0] bytes4(keccak256(name())); excludedSelectors[1] bytes4(keccak256(symbol())); excludedSelectors[2] bytes4(keccak256(decimals())); targetSelector(FuzzSelector({ addr: address(token), selectors: excludedSelectors }).exclude(); } /** * 不变式 1总供给在所有操作后等于所有持币地址余额之和 * 这是 ERC20 最根本的不变式 */ function invariant_TotalSupplyEqualsSumOfBalances() public { assertEq( token.totalSupply(), token.balanceOf(alice) token.balanceOf(bob) ); } /** * 不变式 2任意地址的余额不能超过总供给 */ function invariant_BalanceNeverExceedsTotalSupply() public { assertLe(token.balanceOf(alice), token.totalSupply()); assertLe(token.balanceOf(bob), token.totalSupply()); } /** * 不变式 3allowance 不影响余额仅影响 transferFrom 的授权额度 */ function invariant_AllowanceDoesNotAffectBalance() public { // 无需显式断言 —— 如果 allowance 实现有 bug 影响了余额 // 前两个 invariants 必然会捕获 } }3.3 不变式 Handler控制 fuzzer 的行为边界对于更复杂的协议直接 targetContract 会导致 fuzzer 生成不真实的操作序列。比如一个借贷协议fuzzer 可能反复调用deposit而不调用borrow这样的序列找不到真实 bug。通过 Handler 合约可以引导 fuzzer 的行为模式// test/handlers/LendingHandler.sol contract LendingHandler is Test { LendingPool public pool; MockERC20 public token; constructor(LendingPool _pool, MockERC20 _token) { pool _pool; token _token; } /** * Handler 方法deposit * 每次被 fuzzer 随机选中时使用 ghost 变量记录 deposit 总量 * 这种设计让 fuzzer 的调用更接近真实用户行为 */ function deposit(uint256 amount) public { amount bound(amount, 1, token.balanceOf(address(this))); token.approve(address(pool), amount); pool.deposit(amount); } function borrow(uint256 amount) public { // 限制借款量不超过可用流动性的 80%避免 fuzzer 反复触发无意义的 revert uint256 available pool.getAvailableLiquidity(); amount bound(amount, 1, (available * 80) / 100); pool.borrow(amount); } function repay(uint256 amount) public { uint256 debt pool.getDebt(address(this)); amount bound(amount, 1, debt); token.approve(address(pool), amount); pool.repay(amount); } }3.4 Foundry 配置调优# foundry.toml [profile.default] src src out out libs [lib] # Fuzzing 配置 fuzz { runs 5000, max_test_rejects 500_000 } # runs: 每个 fuzz 测试执行的随机输入组数5000 是复杂 DeFi 协议的合理起点 # max_test_rejects: 允许 fuzzer 抛弃的无效输入数默认 65536 # 提高这个值是必要的如果 bound() 频繁排除了大量输入范围fuzzer 需要更多重试才能找到有效输入 invariant { runs 256, depth 15, fail_on_revert false } # runs: 不变式测试的跑数 # depth: 函数调用序列的最大长度15 表示 fuzzer 会生成 1-15 个随机函数调用组成的序列 # fail_on_revert false: 不变式测试中允许函数调用 revert正常业务逻辑 # 设置为 true 会让 fuzzer 把所有 revert 视为失败适用于任何操作都不应该 revert的严格场景 [profile.ci] fuzz { runs 10000, max_test_rejects 1_000_000 } invariant { runs 512, depth 20 } # CI 环境下提高运行次数利用 CI Runner 的高性能 CPU 做深度搜索四、边界分析Fuzzing 的覆盖率不等于合约安全性。Foundry fuzzer 基于随机输入生成它不能保证覆盖所有代码路径。一个只在block.timestamp 某个特定值时触发的 bugfuzzer 几乎不可能命中。覆盖率报告应该作为最低标准而不是完美目标。不变式设计的质量决定了测试价值。如果你定义的不变式本身就有漏洞比如总借款量不超过存款量忽略利息累积那么测试无论跑多少轮都是自我欺骗。不变式的推导需要与合约的数学规范对齐。Gas 消耗不可忽视。在 CI 中运行 10000 次 fuzz 测试对于包含多个复杂操作的合约单次运行可能消耗大量 RPC 调用和 CPU 时间。建议将高 runs 值的配置放在 nightly CI 中日常 PR 使用较低的 runs 值做快速门禁。不适用场景合约依赖外部 oracle 价格fuzzer 无法模拟链下价格波动的分布需要跨区块状态如时间锁Foundry fuzzer 默认在单区块内运行依赖复杂 MEV 场景需要搭配forge-std的vm.roll()和vm.warp()手动构造五、总结维度要点Fuzz Test随机生成函数参数覆盖单函数边界行为通过bound()控制有效输入范围Invariant Test随机函数调用序列 不变式断言通过 Handler 合约引导 fuzzer 行为模式配置调优日常 CI 低 runs 做快速门禁nightly CI 高 runs 大 depth 做深度搜索Handler 模式控制 fuzzer 的操作序列使其符合真实用户行为提高 bug 发现效率局限认知无法覆盖时间/区块依赖的 bug不变式质量决定测试价值工程边界单区块测试假设不覆盖跨区块状态与 MEV 场景