上一篇我们介绍了 Foundry 的 Fuzz Testing。
Fuzz Testing 主要解决的是:
给函数不断生成随机参数,看看某一次调用会不会出现 Bug。
例如:
perl
function testFuzz_Deposit(uint256 amount) public {
vault.deposit(amount);
assertEq(vault.balanceOf(address(this)), amount);
}
但是现实中的 DeFi 合约往往不是调用一次函数这么简单。
用户可能连续执行:
arduino
deposit
→ transfer
→ deposit
→ withdraw
→ mint
→ burn
→ transfer
→ withdraw
...
真正的 Bug 很可能只有在一连串操作之后才会出现。
这就是 Foundry 的:
Invariant Testing(不变量测试)
一、什么是 Invariant Testing?
Invariant 的意思是:
无论系统经历什么合法的状态变化,某个条件始终应该成立。
例如 ERC20:
所有账户余额之和 == totalSupply
例如资金池:
池子中的资产 >= 用户应该获得的资产
例如 AMM:
reserve0 * reserve1 >= 某个约束
所以 Invariant Testing 关注的不是:
「这一次 deposit 对不对?」
而是:
「经过大量随机操作之后,系统是否仍然满足核心规则?」
Foundry 会随机生成函数调用序列,并在每次调用之后检查 invariant。官方文档也将 runs 和 depth 作为 invariant campaign 的两个核心维度:runs 表示测试多少组调用序列,depth 表示每组序列包含多少次调用。
可以简单理解成:
arduino
Run 1:
deposit → transfer → withdraw → mint → burn
↓
检查 invariant
Run 2:
transfer → deposit → deposit → burn → withdraw
↓
检查 invariant
Run 3:
mint → mint → transfer → withdraw → ...
↓
检查 invariant
二、最简单的 Invariant
假设我们有一个简单 Token:
ini
contract MyToken {
mapping(address => uint256) public balanceOf;
uint256 public totalSupply;
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
}
function burn(address from, uint256 amount) external {
balanceOf[from] -= amount;
totalSupply -= amount;
}
function transfer(
address from,
address to,
uint256 amount
) external {
balanceOf[from] -= amount;
balanceOf[to] += amount;
}
}
我们最关心的一个性质就是:
所有用户余额之和 == totalSupply
这就是一个典型 invariant。
三、创建 Invariant Test
Foundry 中可以使用:
scss
function invariant_TotalSupply() public {
assertEq(
token.balanceOf(alice) +
token.balanceOf(bob),
token.totalSupply()
);
}
测试函数以:
invariant_
开头。
Foundry 会把它识别成 invariant test。
但是这里有一个问题:
如果我们只测试:
alice
bob
覆盖范围其实很有限。
更有意思的地方是:
让 Foundry 自动生成随机的函数调用序列。
四、让 Foundry 自动调用函数
假设测试环境:
csharp
contract InvariantTest is Test {
MyToken token;
function setUp() public {
token = new MyToken();
}
function invariant_TotalSupply() public {
assertEq(
token.balanceOf(address(this)),
token.totalSupply()
);
}
}
如果 setUp() 中部署了 token,Foundry 默认可以把它作为 invariant testing 的 target contract。
然后 Foundry 就可以随机调用它的函数:
scss
mint()
burn()
transfer()
并不断检查:
scss
invariant_TotalSupply()
五、这和 Fuzz Testing 有什么区别?
这是理解 Invariant Testing 最重要的一点。
Fuzz Testing
通常是:
java
一个函数
↓
随机参数
↓
执行
↓
assert
例如:
kotlin
function testFuzz(uint256 amount) public {
token.mint(address(this), amount);
assertEq(
token.balanceOf(address(this)),
amount
);
}
Invariant Testing
则变成:
markdown
随机函数
↓
随机参数
↓
执行
↓
检查 invariant
↓
随机函数
↓
随机参数
↓
执行
↓
检查 invariant
↓
...
也就是:
markdown
Fuzz Testing
↓
测试单次调用
Invariant Testing
↓
测试连续状态变化
所以可以简单记:
Fuzz 测输入,Invariant 测状态。
六、真正厉害的是 Stateful Testing
假设我们的 Vault 有:
scss
deposit()
withdraw()
transfer()
mint()
burn()
普通 Fuzz 很可能测试:
scss
deposit(100)
然后结束。
但是 Invariant Testing 可以生成:
scss
deposit(100)
↓
transfer(50)
↓
deposit(300)
↓
withdraw(120)
↓
mint(500)
↓
burn(200)
↓
transfer(80)
↓
withdraw(50)
这时候合约已经进入了一个非常复杂的状态。
然后 Foundry 检查:
scss
function invariant_Solvency() public {
assertGe(
vault.totalAssets(),
vault.totalSupply()
);
}
如果某一个操作组合最终破坏了这个条件:
totalAssets < totalSupply
测试就失败。
七、为什么需要 Handler?
这里会遇到 Invariant Testing 的一个重要问题。
假设:
scss
function deposit(uint256 amount) external {
require(
token.balanceOf(msg.sender) >= amount
);
...
}
Foundry 随机生成:
scss
deposit(999999999999999999)
但是测试账户根本没有这么多 Token。
于是:
scss
deposit()
↓
revert
如果大量随机调用都因为前置条件不满足而 revert,那么测试看起来跑了很多次:
10000 calls
实际上可能没有真正改变多少协议状态。
Foundry 官方文档也特别强调了这一点:对于复杂协议,直接 fuzz 目标合约可能产生大量无效调用,因此可以通过 Handler 给调用增加必要的前置操作。
八、Handler 是什么?
可以把 Handler 理解成:
Fuzzer 和真实合约之间的一层适配器。
原来的结构:
Fuzzer
↓
Vault
变成:
Fuzzer
↓
Handler
↓
Vault
例如:
ini
contract Handler {
Vault public vault;
MockToken public token;
constructor(
Vault _vault,
MockToken _token
) {
vault = _vault;
token = _token;
}
function deposit(uint256 amount) external {
amount = bound(
amount,
1,
1000 ether
);
token.mint(address(this), amount);
token.approve(
address(vault),
amount
);
vault.deposit(amount);
}
}
现在 Foundry 随机调用:
scss
Handler.deposit()
Handler 会自动完成:
mint token
↓
approve
↓
deposit
这样测试就更容易产生有效状态变化。
九、Handler 不只是"包装函数"
Handler 最重要的价值其实是:
控制 Fuzzer 应该如何操作协议。
例如:
javascript
contract Handler {
function deposit(uint256 amount) external {
...
}
function withdraw(uint256 amount) external {
...
}
function transfer(uint256 amount) external {
...
}
}
然后告诉 Foundry:
scss
targetContract(address(handler));
那么随机调用的对象就变成:
scss
Handler.deposit()
Handler.withdraw()
Handler.transfer()
而不是直接:
scss
Vault.deposit()
Vault.withdraw()
Vault.transfer()
Foundry 提供了 targetContract、targetSelector、targetSender 等机制来进一步控制 invariant fuzzer 的目标范围。
十、targetContract
最简单的方式:
ini
function setUp() public {
token = new MockToken();
vault = new Vault(token);
handler = new Handler(
vault,
token
);
targetContract(address(handler));
}
意思是:
Fuzzer 主要随机调用 Handler 的函数。
例如:
scss
deposit()
withdraw()
transfer()
十一、targetSelector
如果 Handler 中还有一些我们不希望被随机调用的函数:
javascript
function deposit(...)
function withdraw(...)
function transfer(...)
function getBalance()
function debug()
可以进一步指定 selector。
例如:
ini
bytes4[] memory selectors = new bytes4[](3);
selectors[0] = Handler.deposit.selector;
selectors[1] = Handler.withdraw.selector;
selectors[2] = Handler.transfer.selector;
targetSelector(
FuzzSelector({
addr: address(handler),
selectors: selectors
})
);
这样 Fuzzer 就只会选择:
arduino
deposit
withdraw
transfer
Foundry 官方文档也建议使用 target selectors 对 fuzz 范围进行更精细的控制。
十二、Ghost Variable:记录"测试之外"的状态
这是 Invariant Testing 里非常重要的一个概念。
假设:
Vault
内部没有直接提供:
totalDeposited
但是我们的测试非常想知道:
历史上一共 deposit 了多少?
可以在 Handler 中自己维护一个变量:
arduino
uint256 public totalDeposited;
每次:
markdown
function deposit(uint256 amount) external {
...
vault.deposit(amount);
totalDeposited += amount;
}
这就是:
Ghost Variable
它不是生产合约的一部分,而是测试环境自己维护的状态。
官方 Foundry 文档也使用了这种方式,例如在 Handler 中累计用户获得的 shares,然后和协议的 totalSupply 进行比较。
十三、Ghost Variable 的价值
例如我们希望验证:
markdown
Vault.totalSupply
==
所有用户 shares 之和
Handler:
ini
uint256 public ghostShares;
function deposit(uint256 amount) external {
...
uint256 shares =
vault.deposit(amount);
ghostShares += shares;
}
Invariant:
perl
function invariant_TotalShares() public {
assertEq(
vault.totalSupply(),
handler.ghostShares()
);
}
这样就形成了:
markdown
真实合约状态
↓
vault.totalSupply()
VS
测试环境记录
↓
handler.ghostShares
如果两者出现差异,就可能存在 accounting bug。
十四、一个完整的结构
一个比较典型的项目结构:
bash
test/
├── Unit/
│ └── Vault.t.sol
│
├── Fuzz/
│ └── VaultFuzz.t.sol
│
└── Invariant/
├── Handler.sol
└── VaultInvariant.t.sol
Handler:
ini
contract Handler {
Vault public vault;
uint256 public ghostDeposited;
uint256 public ghostWithdrawn;
constructor(Vault _vault) {
vault = _vault;
}
function deposit(uint256 amount) external {
amount = bound(
amount,
1,
1000 ether
);
uint256 shares =
vault.deposit(amount);
ghostDeposited += amount;
}
function withdraw(uint256 amount) external {
amount = bound(
amount,
1,
1000 ether
);
vault.withdraw(amount);
ghostWithdrawn += amount;
}
}
Invariant:
scss
contract VaultInvariantTest is Test {
Vault vault;
Handler handler;
function setUp() public {
vault = new Vault();
handler = new Handler(vault);
targetContract(
address(handler)
);
}
function invariant_Solvency() public {
assertGe(
vault.totalAssets(),
vault.totalSupply()
);
}
}
现在测试模型就非常清晰:
scss
┌─────────────┐
│ Fuzzer │
└──────┬──────┘
↓
┌───────────────────┐
│ Handler │
│ │
│ deposit() │
│ withdraw() │
│ transfer() │
│ mint() │
│ burn() │
└─────────┬─────────┘
↓
┌───────────────────┐
│ Vault │
└─────────┬─────────┘
↓
检查 Invariant
十五、runs 和 depth
Invariant Testing 中经常会看到:
ini
[invariant]
runs = 100
depth = 50
可以理解为:
runs
↓
测试多少组随机操作序列
depth
↓
每组序列执行多少次操作
例如:
ini
runs = 100
depth = 50
大致就是:
arduino
第 1 组:
deposit → transfer → withdraw → ... 50 次
第 2 组:
mint → burn → deposit → ... 50 次
...
第 100 组:
随机操作 50 次
而且 invariant 会在调用过程中持续检查,而不是只在整组操作结束后检查。
十六、为什么 depth 很重要?
假设一个 Bug 必须经过:
arduino
deposit
→ transfer
→ withdraw
→ deposit
→ withdraw
→ burn
6 次操作之后才触发。
如果:
ini
depth = 3
很可能根本发现不了。
提高到:
ini
depth = 50
Fuzzer 就有机会进入更复杂的状态。
所以:
ini
runs
= 广度
depth
= 状态变化的深度
十七、Invariant 真正厉害的地方
假设我们手工测试:
scss
deposit(100)
withdraw(50)
很容易。
但是 Invariant Testing 可能生成:
scss
deposit(100)
transfer(70)
deposit(300)
withdraw(50)
mint(1000)
transfer(200)
burn(300)
withdraw(120)
deposit(500)
transfer(400)
...
甚至不同用户参与:
arduino
Alice deposit
Bob deposit
Alice transfer
Bob withdraw
Charlie mint
Alice burn
...
最终系统可能进入一个我们根本没有想到过的状态。
这就是 Invariant Testing 的核心价值:
不是告诉测试人员"应该测试哪些场景",而是让 Fuzzer 探索大量状态空间。
十八、如何设计好的 Invariant?
Invariant 并不是越复杂越好。
一个好的 invariant 通常是一个非常明确的业务规则。
例如:
1. Supply 守恒
ini
totalSupply == sum(balanceOf)
2. Solvency
assets >= liabilities
3. 不允许负余额
balance >= 0
4. Ownership 不变量
css
owner != address(0)
5. Exchange Rate
shares / assets
必须满足某种约束。
6. AMM
reserve0 * reserve1 >= k
核心思路都是:
把业务规则写成永远不能被破坏的条件。
十九、一个容易踩的坑
不要为了让测试通过,写:
perl
function invariant_Test() public {
if (somethingWrong) {
return;
}
assertEq(...);
}
这实际上可能把 Bug 隐藏掉。
因为:
ini
somethingWrong == true
↓
直接 return
↓
没有 assert
↓
测试 PASS
Invariant 的核心原则应该是:
只要进入这个状态,就必须能够验证对应规则。
对于不同协议状态,更推荐拆分不同的 invariant test,或者通过 Handler / target 配置让 Fuzzer 进入你真正想测试的状态空间。
二十、从 Fuzz 到 Invariant
到这里,可以把 Foundry 的测试体系串起来:
markdown
Unit Test
↓
验证具体函数
Fuzz Test
↓
验证随机输入
Invariant Test
↓
验证随机状态变化
Handler
↓
控制复杂状态变化
Ghost Variable
↓
记录测试环境中的额外状态
所以学习路线可以理解成:
scss
test_XXX()
↓
testFuzz_XXX()
↓
invariant_XXX()
↓
Handler
↓
Ghost Variables
↓
复杂 DeFi 协议 Invariant
二十一、最后总结
Foundry Invariant Testing 最重要的不是记住几个 API:
scss
targetContract()
targetSelector()
invariant_XXX()
而是理解一种测试思想:
不去预测 Bug 会出现在哪个具体场景,而是定义"系统无论怎么变化都不能违反的规则",然后让 Fuzzer 自动探索状态空间。
Fuzz Testing:
markdown
随机输入
↓
测试一次调用
Invariant Testing:
markdown
随机函数
↓
随机参数
↓
随机调用序列
↓
不断改变系统状态
↓
每一步检查 invariant
而 Handler 又进一步解决:
markdown
随机调用
↓
大量无效 revert
的问题:
Fuzzer
↓
Handler
↓
准备有效条件
↓
调用真实协议
↓
检查 invariant
这时候 Foundry 才真正从:
"随机测试函数"
进化成:
"自动探索智能合约状态空间"。
对于 DeFi、AMM、Lending、Vault、ERC-4626 等具有复杂状态转换的协议,Invariant Testing 的价值尤其明显。