Foundry Invariant Testing 实战:ERC-4626 + Handler + Ghost Variable

上一篇我们介绍了 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 安全测试了。

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