Foundry Invariant Testing:让测试自动寻找复杂状态下的 Solidity Bug

上一篇我们介绍了 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。官方文档也将 runsdepth 作为 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 提供了 targetContracttargetSelectortargetSender 等机制来进一步控制 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 的价值尤其明显。

相关推荐
明月_清风2 小时前
Foundry Invariant Testing 实战:ERC-4626 + Handler + Ghost Variable
后端·web3·solidity
java porter2 小时前
我开源了一个 Spring AI Agent 项目
后端
136096757232 小时前
AgentScope 2.0 学习笔记:RAG 进阶——Top-K 调优与幻觉测试(让 Agent 敢说「不知道」)
后端
yume_sibai2 小时前
07-Rust 异步编程完全指南(async/await + Tokio + Future + 并发原语 + 异步流)
开发语言·后端·rust
2601_953988072 小时前
Ricon组态 - 让数据可视化如此简单
运维·后端·物联网·数学建模·前端框架·c4前端
jsl_jsl_jsl2 小时前
操作系统速记
后端
geovindu3 小时前
java: Strategy Pattern
java·开发语言·后端·设计模式·策略模式·行为模式
garmin Chen3 小时前
MySQL精简面试题
数据库·后端·mysql·面试
中趴菜3 小时前
robots.txt 规则误拦排查实战指南
后端