第5章 不变量测试
不变量测试
不变量测试验证那些无论执行序列如何变化都必须始终成立的属性。Forge 会随机运行函数调用序列,并在每次调用后检查不变量。
基础不变量测试
```solidity // test/Vault.invariant.t.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol"; import {Vault} from "../src/Vault.sol";
contract VaultInvariantTest is Test { Vault vault;
function setUp() public { vault = new Vault(); targetContract(address(vault)); }
function invariant_SolvencyCheck() public view { assertGe( address(vault).balance, vault.totalDeposits() ); } } ```
运行不变量测试:
```bash $ forge test --match-contract VaultInvariantTest ```
afterInvariant 钩子
当你需要在每次不变量运行结束后检查一次属性,而不是在运行过程中的每次调用后都检查时,定义 afterInvariant():
```solidity // test/Vault.invariant.t.sol contract VaultInvariantTest is Test { Vault vault; VaultHandler handler;
function setUp() public { vault = new Vault(); handler = new VaultHandler(vault); targetContract(address(handler)); }
function invariant_SolvencyCheck() public view { assertGe(address(vault).balance, vault.totalDeposits()); }
function afterInvariant() public view { assertEq( address(vault).balance, handler.ghost_depositSum() - handler.ghost_withdrawSum() ); } } ```
当不变量检查通过后,Forge 会在运行结束时调用该钩子,检查最终状态。如果钩子发生回滚或断言失败,不变量测试即告失败,并报告会报告该运行的调用序列。钩子必须具有确切的签名 afterInvariant(),且一个测试合约只能定义一个。钩子中产生的状态变更不会持久化。
对于需要在调用序列的整个过程中始终成立的属性,请使用 invariant_* 函数。对于聚合类或运行结束时的检查,尤其是当每次调用都评估该属性成本过高时,请使用 afterInvariant()。
处理器模式
处理器(Handler)封装目标合约,以约束输入并跟踪状态:
```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol"; import {Vault} from "../../src/Vault.sol";
contract VaultHandler is Test { Vault public vault; uint256 public ghost_depositSum; uint256 public ghost_withdrawSum; address[] public actors; address internal currentActor; modifier useActor(uint256 actorSeed) { currentActor = actors[bound(actorSeed, 0, actors.length - 1)]; vm.startPrank(currentActor); _; vm.stopPrank(); }
constructor(Vault _vault) { vault = _vault; for (uint256 i = 0; i < 10; i++) { actors.push(makeAddr(string(abi.encodePacked("actor", i)))); vm.deal(actors[i], 100 ether); } }
function deposit(uint256 amount, uint256 actorSeed) external useActor(actorSeed) { amount = bound(amount, 0.01 ether, 10 ether); vault.deposit{value: amount}(); ghost_depositSum += amount; }
function withdraw(uint256 amount, uint256 actorSeed) external useActor(actorSeed) { uint256 balance = vault.balanceOf(currentActor); if (balance == 0) return; amount = bound(amount, 1, balance); vault.withdraw(amount); ghost_withdrawSum += amount; } } ```
```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol"; import {Vault} from "../src/Vault.sol"; import {VaultHandler} from "./handlers/VaultHandler.sol";
contract VaultInvariantTest is Test { Vault vault; VaultHandler handler;
function setUp() public { vault = new Vault(); handler = new VaultHandler(vault); // 仅以处理器为目标,不直接以金库为目标 targetContract(address(handler)); }
function invariant_ConservationOfDeposits() public view { assertEq( address(vault).balance, handler.ghost_depositSum() - handler.ghost_withdrawSum() ); }
function invariant_SolvencyCheck() public view { assertGe( address(vault).balance, vault.totalDeposits() ); } } ```
幽灵变量
幽灵变量(Ghost variables)用于跟踪未存储于链上的累计状态:
```solidity // test/handlers/TokenHandler.sol contract TokenHandler is Test { Token public token; // 跟踪所有铸造和销毁 uint256 public ghost_mintedSum; uint256 public ghost_burnedSum; // 跟踪每个地址的增量 mapping(address => int256) public ghost_balanceDeltas;
function mint(address to, uint256 amount) external { amount = bound(amount, 1, 1000 ether); token.mint(to, amount); ghost_mintedSum += amount; ghost_balanceDeltas[to] += int256(amount); }
function burn(address from, uint256 amount) external { uint256 balance = token.balanceOf(from); if (balance == 0) return; amount = bound(amount, 1, balance); vm.prank(from); token.burn(amount); ghost_burnedSum += amount; ghost_balanceDeltas[from] -= int256(amount); } } ```
```solidity contract TokenInvariantTest is Test { function invariant_TotalSupplyMatchesGhosts() public view { assertEq( token.totalSupply(), handler.ghost_mintedSum() - handler.ghost_burnedSum() ); } } ```
配置不变量运行
```toml # foundry.toml [invariant] runs = 256 # 测试运行次数 depth = 100 # 每次运行的调用深度 fail_on_revert = false # 处理器回滚时不失败 shrink_run_limit = 5000 # 收缩失败序列的尝试次数 ```
高级不变量战役
Foundry v1.7.0 增加了多个选项,使深度不变量战役既更快又更具表现力。
check_interval
在深度战役中,使用 check_interval 在精度和速度之间进行权衡:
```toml # foundry.toml [invariant] depth = 1000 check_interval = 10 ```
这会改变 Foundry 评估不变量的时机:
* 0:仅检查每次运行中的最后一次调用 * 1:检查每次调用 * N:每 N 次调用检查一次,并始终检查最后一次调用
当不变量评估成本较高时,此功能很有用,但它可能会遗漏那些在两次检查之间破坏后又恢复不变量的 Bug。
基于时间的战役
当 Bug 依赖于调用之间经过的时间或区块移动时,使用 max_time_delay 和 max_block_delay:
```toml # foundry.toml [invariant] max_time_delay = 86400 # 1 天,单位:秒 max_block_delay = 1000 ```
Foundry 将对调用序列以及调用之间的时间或区块距离进行模糊测试。这对于 vesting(分期归属)、拍卖、TWAP、冷却期以及任何依赖 block.timestamp 或 block.number 的逻辑特别有用。
优化模式
如果不变量函数返回 int256 而不是进行断言,Foundry 会从“查找失败”模式切换到“最大化该值”模式:
```solidity function invariant_optimize_maxUtilization() public view returns (int256) { return int256(vault.totalBorrows()) - int256(vault.totalIdle()); } ```
这适用于最坏情况滑点、最大不平衡、最大舍入误差,或者你希望模糊测试器推高的任何其他指标。
对于优化模式战役,如果你希望跨运行获得可复现的结果,请使用固定种子。
针对特定函数
```solidity function setUp() public { vault = new Vault(); handler = new VaultHandler(vault); targetContract(address(handler)); // 仅调用这些函数 bytes4[] memory selectors = new bytes4[](2); selectors[0] = VaultHandler.deposit.selector; selectors[1] = VaultHandler.withdraw.selector; targetSelector(FuzzSelector({ addr: address(handler), selectors: selectors })); } ```
排除函数
```solidity function setUp() public { targetContract(address(handler)); // 排除特定函数 excludeSelector(FuzzSelector({ addr: address(handler), selectors: toSelectors(VaultHandler.debugFunction.selector) })); }
function toSelectors(bytes4 selector) internal pure returns (bytes4[] memory) { bytes4[] memory selectors = new bytes4[](1); selectors[0] = selector; return selectors; } ```
调用摘要
添加摘要函数以了解测试覆盖率:
```solidity contract VaultHandler is Test { // 调用计数器 mapping(bytes4 => uint256) public calls;
function deposit(uint256 amount) external { calls[this.deposit.selector]++; // ... }
function withdraw(uint256 amount) external { calls[this.withdraw.selector]++; // ... }
function callSummary() external view { console.log("deposit calls:", calls[this.deposit.selector]); console.log("withdraw calls:", calls[this.withdraw.selector]); } }
contract VaultInvariantTest is Test { function invariant_CallSummary() public view { handler.callSummary(); } } ```
多合约不变量
跨多个合约测试不变量:
```solidity contract SystemHandler is Test { Vault vault; Token token; Oracle oracle;
function depositAndStake(uint256 amount, uint256 actorSeed) external useActor(actorSeed) { amount = bound(amount, 1 ether, 100 ether); token.approve(address(vault), amount); vault.depositAndStake(amount); ghost_stakedSum += amount; }
function updatePrice(uint256 newPrice) external { newPrice = bound(newPrice, 0.1 ether, 100 ether); oracle.setPrice(newPrice); } }
contract SystemInvariantTest is Test { // 跨合约不变量:金库始终能覆盖提取 function invariant_VaultSolvency() public view { uint256 vaultValue = vault.totalStaked() * oracle.price() / 1e18; assertGe(vaultValue, vault.totalLiabilities()); } } ```
常见的不变量测试
* 守恒律:输入总和等于输出总和 * 偿付能力:合约能够覆盖所有负债 * 单调性:值仅增加或仅减少 * 边界:值保持在预期范围内 * 访问控制:只有授权用户才能调用函数 * 状态一致性:相关状态变量保持同步
调试失败的不变量
当不变量失败时,Forge 会显示调用序列:
```bash $ forge test --match-test invariant_Solvency -vvvv ```
输出显示导致失败的每个调用,有助于你复现和修复 Bug。
最佳实践
* 使用处理器:将输入约束在有效范围内 * 使用幽灵变量:验证累计状态与链上状态是否匹配 * 边界输入:使用 bound() 代替 vm.assume() * 多参与者:使用多种用户进行测试,而不仅仅是一个 * 从简单开始:从基本不变量开始,逐步增加复杂度 * 记录调用次数:验证所有函数都得到充分练习