上一篇我们介绍了 Foundry Invariant Testing。
核心思想是:
不再只测试某一次函数调用,而是让 Foundry 随机执行一连串操作,然后检查合约在整个过程中是否始终满足某些不变量。
这一篇直接进入实战。
我们选择一个非常典型的 DeFi 场景:
ERC-4626 Vault
并使用:
diff
ERC-4626
+
Handler
+
Ghost Variable
+
Invariant
构建一套完整的状态测试。
一、先理解我们要测试什么
ERC-4626 可以简单理解成:
用户
↓
存入 Token
↓
Vault
↓
获得 Shares
例如:
ini
Alice 存入 100 USDC
Vault:
assets = 100
Alice:
shares = 100
之后:
Alice withdraw 50 USDC
变成:
ini
Vault:
assets = 50
Alice:
shares = 50
如果 Vault 中存在收益:
ini
assets = 200
shares = 100
那么每个 share 就值:
ini
200 / 100 = 2 assets
所以 ERC-4626 最核心的问题之一就是:
assets 和 shares 之间的 accounting 是否始终正确?
这非常适合 Invariant Testing。
二、项目结构
我们先建立一个简单项目:
bash
foundry.toml
src/
├── MockERC20.sol
└── SimpleVault.sol
test/
└── invariant/
├── Handler.t.sol
└── VaultInvariant.t.sol
为了让文章重点集中在 Foundry 测试,我们实现一个简化版 Vault。
三、MockERC20
首先创建一个非常简单的 Token:
ini
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MockERC20 {
string public name = "Mock Token";
string public symbol = "MOCK";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256))
public allowance;
function mint(
address to,
uint256 amount
) external {
balanceOf[to] += amount;
totalSupply += amount;
}
function approve(
address spender,
uint256 amount
) external returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transfer(
address to,
uint256 amount
) external returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function transferFrom(
address from,
address to,
uint256 amount
) external returns (bool) {
uint256 allowed =
allowance[from][msg.sender];
if (allowed != type(uint256).max) {
allowance[from][msg.sender]
= allowed - amount;
}
balanceOf[from] -= amount;
balanceOf[to] += amount;
return true;
}
}
这里只是为了测试演示,并不是生产级 ERC20。
四、实现一个简单 Vault
接下来:
ini
contract SimpleVault {
MockERC20 public immutable asset;
string public name = "Simple Vault";
string public symbol = "svMOCK";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256)
public balanceOf;
constructor(MockERC20 _asset) {
asset = _asset;
}
这里:
asset
代表底层资产。
而:
shares
代表用户在 Vault 中的份额。
五、deposit
最简单的 deposit:
ini
function deposit(
uint256 assets,
address receiver
)
external
returns (uint256 shares)
{
shares = previewDeposit(assets);
asset.transferFrom(
msg.sender,
address(this),
assets
);
balanceOf[receiver] += shares;
totalSupply += shares;
}
这里最关键的是:
ini
shares = previewDeposit(assets);
我们需要定义 assets → shares 的兑换关系。
六、shares 怎么计算?
一个简化公式:
ini
shares = assets * totalSupply / totalAssets
但是第一次存款的时候:
ini
totalSupply = 0
totalAssets = 0
所以需要特殊处理:
ini
function previewDeposit(
uint256 assets
)
public
view
returns (uint256)
{
uint256 supply = totalSupply;
uint256 assetsInVault =
totalAssets();
if (
supply == 0 ||
assetsInVault == 0
) {
return assets;
}
return
assets * supply
/ assetsInVault;
}
这样第一次:
100 assets
↓
100 shares
七、totalAssets
Vault 当前拥有多少底层资产?
csharp
function totalAssets()
public
view
returns (uint256)
{
return asset.balanceOf(
address(this)
);
}
所以:
ini
totalAssets
=
Vault 地址上的 Token balance
八、withdraw
再实现 withdraw:
ini
function withdraw(
uint256 assets,
address receiver,
address owner
)
external
returns (uint256 shares)
{
shares = previewWithdraw(assets);
balanceOf[owner] -= shares;
totalSupply -= shares;
asset.transfer(
receiver,
assets
);
}
计算:
ini
function previewWithdraw(
uint256 assets
)
public
view
returns (uint256)
{
uint256 supply = totalSupply;
uint256 assetsInVault =
totalAssets();
if (
supply == 0 ||
assetsInVault == 0
) {
return assets;
}
return
assets * supply
/ assetsInVault;
}
为了简单,我们暂时忽略:
allowance
权限
fee
rounding
share inflation attack
preview 函数标准细节
这些可以放到后续 ERC-4626 安全实战中讨论。
九、第一条 Invariant
现在我们已经有了一个 Vault。
最简单的问题:
Vault 的 shares 是否应该和它的 accounting 保持一致?
例如:
ini
Vault:
totalSupply = 100 shares
totalAssets = 100 assets
如果:
ini
totalAssets = 0
但:
ini
totalSupply = 100
这显然是一个危险状态。
所以可以定义:
perl
function invariant_IfNoAssetsThenNoShares()
public
{
if (vault.totalAssets() == 0) {
assertEq(
vault.totalSupply(),
0
);
}
}
这个 invariant 表达的就是:
ini
assets == 0
↓
shares == 0
十、但是这还不够
现在测试:
javascript
function invariant_IfNoAssetsThenNoShares()
虽然成立。
但我们真正想测试的是:
erlang
deposit
withdraw
deposit
deposit
withdraw
withdraw
...
大量随机操作之后:
Vault accounting
是不是仍然正确。
所以需要:
Handler
十一、创建 Handler
Handler 是整个实战的核心。
创建:
ini
contract Handler is Test {
SimpleVault public vault;
MockERC20 public asset;
address public alice =
address(0x1);
address public bob =
address(0x2);
address public charlie =
address(0x3);
构造函数:
ini
constructor(
SimpleVault _vault,
MockERC20 _asset
) {
vault = _vault;
asset = _asset;
}
十二、第一个 Handler:deposit
我们希望 Foundry 随机调用:
scss
deposit()
但是直接把随机数传给 Vault:
ini
vault.deposit(amount);
很容易产生:
ini
amount = 999999999999999999999999
而用户根本没有这么多 Token。
因此 Handler 负责:
diff
限制随机值
+
准备 Token
+
approve
+
deposit
代码:
ini
function deposit(
uint256 amount,
uint8 userSeed
)
external
{
amount = bound(
amount,
1,
1000 ether
);
address user =
_user(userSeed);
asset.mint(
user,
amount
);
vm.startPrank(user);
asset.approve(
address(vault),
amount
);
vault.deposit(
amount,
user
);
vm.stopPrank();
}
注意这里出现了上一篇文章介绍过的:
scss
vm.startPrank()
它可以让后面的调用看起来像是:
Alice
或者:
Bob
发起的。
十三、随机选择用户
Handler 中增加:
ini
function _user(
uint8 seed
)
internal
view
returns (address)
{
uint256 index =
uint256(seed) % 3;
if (index == 0) {
return alice;
}
if (index == 1) {
return bob;
}
return charlie;
}
这样 Fuzzer 可以随机选择:
Alice
Bob
Charlie
于是测试状态就开始变复杂:
erlang
Alice deposit
Bob deposit
Alice withdraw
Charlie deposit
Bob withdraw
...
十四、withdraw Handler
withdraw 更麻烦。
因为:
scss
withdraw(amount)
必须保证:
用户拥有足够 shares
所以我们不能直接:
ini
amount = bound(
amount,
1,
1000 ether
);
否则大量调用会:
revert
我们需要根据用户当前状态限制 amount。
ini
function withdraw(
uint256 amount,
uint8 userSeed
)
external
{
address user =
_user(userSeed);
uint256 assets =
vault.totalAssets();
if (assets == 0) {
return;
}
uint256 userShares =
vault.balanceOf(user);
if (userShares == 0) {
return;
}
uint256 maxAssets =
vault.previewRedeem(
userShares
);
amount = bound(
amount,
1,
maxAssets
);
vm.prank(user);
vault.withdraw(
amount,
user,
user
);
}
这里的核心思想非常重要:
Handler 不只是"转发函数",而是负责把随机输入转换成更有意义的协议操作。
十五、Ghost Variable 登场
现在我们已经可以随机:
deposit
withdraw
但还有一个问题:
我们如何知道 Vault 的 accounting 是否正确?
这时候就可以使用:
Ghost Variable
Ghost Variable:
只存在于测试代码中的变量,用来记录我们期望的状态。
例如:
arduino
uint256 public ghostDeposited;
uint256 public ghostWithdrawn;
deposit:
ini
ghostDeposited += amount;
withdraw:
ini
ghostWithdrawn += amount;
于是:
markdown
ghostDeposited
↓
历史上所有 deposit
ghostWithdrawn
↓
历史上所有 withdraw
十六、第一版 Accounting Invariant
那么:
Vault 当前资产
理论上应该满足:
diff
当前资产
=
总存入
-
总提取
所以:
scss
function invariant_Accounting()
public
{
uint256 expected =
handler.ghostDeposited()
- handler.ghostWithdrawn();
assertEq(
vault.totalAssets(),
expected
);
}
这就是一个非常经典的:
Ghost Variable + Invariant
组合。
十七、但是这里有一个重要问题
上面的公式只有在:
ini
1 deposit = 1 asset
1 withdraw = 1 asset
并且:
没有收益
没有手续费
没有 donation
时成立。
如果 Vault 收到了收益:
100 assets
↓
Vault earning
↓
150 assets
那么:
ini
ghostDeposited = 100
ghostWithdrawn = 0
Vault.totalAssets() = 150
这时候:
yaml
150 != 100
测试就失败。
但这不一定是 Bug。
所以:
Invariant 必须描述真正应该永远成立的业务规则。
不能把"当前预期状态"误认为"不变量"。
十八、更加可靠的 Invariant
对于这个简单 Vault,我们可以测试:
Vault 的 assets 不应该小于 0
当然 Solidity 的:
uint256
本身就不会出现负数。
这个 invariant 没什么价值。
更好的方式是测试:
shares 存在
↓
Vault 必须存在对应资产
例如:
scss
function invariant_Solvency()
public
{
if (vault.totalSupply() > 0) {
assertGt(
vault.totalAssets(),
0
);
}
}
这就更接近协议层面的约束。
十九、再增加一个 Ghost Variable
我们还可以记录:
arduino
uint256 public ghostMintedShares;
uint256 public ghostBurnedShares;
deposit:
ini
uint256 shares =
vault.deposit(
amount,
user
);
ghostMintedShares += shares;
withdraw:
ini
uint256 shares =
vault.withdraw(
amount,
user,
user
);
ghostBurnedShares += shares;
理论上:
ini
Vault.totalSupply
=
所有 mint shares
-
所有 burn shares
于是:
scss
function invariant_ShareAccounting()
public
{
uint256 expected =
handler.ghostMintedShares()
-
handler.ghostBurnedShares();
assertEq(
vault.totalSupply(),
expected
);
}
这就是一个非常典型的 Ghost Variable。
二十、为什么 Ghost Variable 很有用?
因为生产合约通常不会告诉你:
"从测试开始到现在,一共 mint 了多少?"
但是 Handler 可以记录。
于是测试系统就多了一份:
独立的 accounting
形成:
markdown
真实合约
↓
vault.totalSupply()
VS
测试模型
↓
ghostMinted - ghostBurned
这其实已经开始接近:
Model-based Testing
了。
二十一、配置 Target Contract
现在 Handler 有:
scss
deposit()
withdraw()
需要告诉 Foundry:
ini
function setUp() public {
asset =
new MockERC20();
vault =
new SimpleVault(asset);
handler =
new Handler(
vault,
asset
);
targetContract(
address(handler)
);
}
这样:
Fuzzer
↓
Handler
↓
deposit / withdraw
↓
Vault
二十二、完整 Invariant Test
最终:
scss
contract VaultInvariantTest
is Test
{
MockERC20 asset;
SimpleVault vault;
Handler handler;
function setUp() public {
asset =
new MockERC20();
vault =
new SimpleVault(
asset
);
handler =
new Handler(
vault,
asset
);
targetContract(
address(handler)
);
}
function invariant_Solvency()
public
{
if (
vault.totalSupply() > 0
) {
assertGt(
vault.totalAssets(),
0
);
}
}
function invariant_ShareAccounting()
public
{
uint256 expected =
handler.ghostMintedShares()
-
handler.ghostBurnedShares();
assertEq(
vault.totalSupply(),
expected
);
}
}
这时候:
bash
forge test
Foundry 就开始自动探索。
二十三、Foundry 到底在干什么?
可以想象 Foundry 在不断做:
scss
Run 1
deposit(100, Alice)
withdraw(20, Alice)
deposit(500, Bob)
withdraw(100, Bob)
deposit(80, Charlie)
↓
检查 invariant
然后:
scss
Run 2
deposit(800, Bob)
deposit(30, Alice)
withdraw(10, Bob)
withdraw(20, Alice)
...
↓
检查 invariant
再:
scss
Run 3
deposit(...)
deposit(...)
withdraw(...)
deposit(...)
withdraw(...)
...
↓
检查 invariant
最终形成:
markdown
Fuzzer
│
↓
Handler
↙ ↘
deposit withdraw
↘ ↙
Vault
│
↓
State Change
│
↓
Invariant Check
二十四、这时候 Bug 会怎么出现?
假设我们故意修改 Vault:
ini
function withdraw(
uint256 assets,
address receiver,
address owner
)
external
returns (uint256 shares)
{
shares =
previewWithdraw(assets);
// 故意制造 Bug
balanceOf[owner] -= shares;
// 忘记减少 totalSupply
// totalSupply -= shares;
asset.transfer(
receiver,
assets
);
}
那么:
sql
deposit
↓
mint shares
withdraw
↓
burn user shares
↓
但是 totalSupply 没减少
于是:
用户余额:
100 → 50
totalSupply:
100 → 100
Invariant:
perl
assertEq(
vault.totalSupply(),
ghostExpected
);
最终失败。
二十五、最有意思的是:你不需要自己写场景
我们没有写:
scss
test_DepositWithdraw()
test_DepositTwiceWithdraw()
test_TwoUsers()
test_ThreeUsers()
test_DepositWithdrawDeposit()
test_TransferWithdraw()
...
而是告诉 Foundry:
这里有几个可以执行的操作:
deposit
withdraw
以及:
这里有几个永远应该成立的规则:
Solvency
Share Accounting
剩下的:
操作顺序
操作次数
操作参数
用户
状态组合
交给 Fuzzer。
这才是 Invariant Testing 的魅力。
二十六、runs 和 depth
可以在:
foundry.toml
配置:
ini
[invariant]
runs = 256
depth = 100
意味着测试会探索大量:
随机操作序列
例如:
256 组测试
每组最多 100 次调用
所以理论上会探索:
erlang
deposit
→ withdraw
→ deposit
→ deposit
→ withdraw
→ ...
大量不同组合。
实际项目中应该根据测试速度逐步增加,而不是一开始就把参数调得非常大。
二十七、Handler 的三个核心职责
到这里可以总结 Handler 的作用。
1. 控制随机参数
ini
amount = bound(
amount,
1,
1000 ether
);
避免大量没有意义的极端输入。
2. 准备前置状态
例如:
mint token
↓
approve
↓
deposit
3. 记录测试模型
例如:
ghostMintedShares
ghostBurnedShares
让测试拥有一份独立 accounting。
所以:
Handler = Fuzzer 的协议操作适配层 + 测试状态记录器。
二十八、Ghost Variable 的核心思想
Ghost Variable 最重要的不是:
ini
uint256 ghostXXX;
而是:
记录那些生产合约不会主动记录、但测试需要知道的历史信息。
例如:
ghostDeposited
ghostWithdrawn
ghostMinted
ghostBurned
ghostTransferCount
ghostTotalFees
ghostLastPrice
这些变量:
不属于生产代码
只存在:
bash
test/
里面。
二十九、ERC-4626 最值得测试哪些 Invariant?
如果继续把这个实验升级成真正的 ERC-4626 测试,可以重点考虑:
1. Share Accounting
ini
totalSupply
=
所有用户 shares 总和
2. Asset Accounting
ini
Vault assets
=
协议应该持有的资产
3. Deposit
deposit assets
→
应该获得合理数量 shares
4. Withdraw
withdraw assets
→
应该消耗合理数量 shares
5. Round-trip
例如:
deposit assets
↓
shares
↓
withdraw shares
↓
assets
需要满足某种误差范围。
6. Exchange Rate
assets / shares
不能因为正常操作出现不合理跳变。
7. Solvency
shell
Vault assets
>=
用户 shares 对应的资产价值
三十、真正的 DeFi Invariant Testing
到了这里,测试思路已经从:
测试函数
升级成:
测试协议状态
再进一步:
测试协议数学关系
例如一个 Lending Protocol:
shell
totalDeposits
>=
totalWithdrawals
AMM:
shell
reserve0 * reserve1
>=
k
Vault:
totalAssets
↔
totalShares
Staking:
ini
totalStaked
=
所有用户 stake 之和
这才是 Invariant Testing 真正适合的场景。
三十一、整个知识体系串起来
现在可以把 Foundry 的测试体系完整串起来:
kotlin
Unit Testing
│
│ 测试具体功能
↓
Fuzz Testing
│
│ 随机输入
↓
Invariant Testing
│
│ 随机状态变化
↓
Handler
│
│ 控制复杂操作
↓
Ghost Variable
│
│ 建立独立测试模型
↓
Protocol Invariant
其中:
Fuzz
解决:
输入空间太大。
Invariant
解决:
状态空间太大。
Handler
解决:
随机操作很容易变成无效操作。
Ghost Variable
解决:
很多业务状态无法直接从合约中验证。
三十二、最后总结
这一篇真正值得记住的只有四个东西:
ERC-4626
Assets
↕
Shares
Handler
Fuzzer
↓
Handler
↓
Protocol
负责把随机输入变成有效的协议操作。
Ghost Variable
markdown
真实合约状态
VS
测试模型状态
用于建立独立 accounting。
Invariant
markdown
无论执行什么操作
↓
核心业务规则始终成立
最终:
markdown
deposit
→ transfer
→ withdraw
→ mint
→ burn
→ deposit
→ withdraw
→ ...
↓
Foundry 自动探索
↓
Handler 管理状态
↓
Ghost Variable 记录模型
↓
Invariant 持续检查
↓
自动发现 Bug
这时候我们已经不再是在写:
"这个函数应该返回什么?"
而是在验证:
"这个协议无论经历怎样复杂的状态变化,都不能违反什么规则?"
这正是 Foundry Invariant Testing 最有价值的地方。
下一步继续深入,就可以开始测试 ERC-4626 中真正棘手的问题:
markdown
Share Price
↓
Rounding
↓
Inflation Attack
↓
Donation
↓
First Depositor
↓
Invariant
↓
Foundry 自动找攻击序列
这时候就从"学会 Invariant Testing",真正进入 DeFi 安全测试了。