Tutoriel·10 min de lecture·Par Solingo

Invariant Testing avec Foundry — De Zéro à la Production

Le fuzzing trouve des bugs. Les invariants prouvent la sécurité. Voici comment les écrire correctement.

# Invariant Testing avec Foundry — De Zéro à la Production

Les unit tests vérifient des cas spécifiques. Le fuzzing explore des inputs aléatoires. Mais les invariant tests prouvent que votre contrat ne peut jamais entrer dans un état invalide.

C'est la forme de test la plus puissante. Et Foundry la rend accessible.

Unit vs Fuzz vs Invariant

Unit Test :

→ "transfer(alice, 100) should work"

→ Vérifie 1 scénario

Fuzz Test :

→ "transfer(random_user, random_amount) should work"

→ Vérifie 1000 scénarios aléatoires

Invariant Test :

→ "sum(all balances) == totalSupply ALWAYS"

→ Vérifie que la propriété tient après N'IMPORTE QUELLE séquence d'actions

Exemple Simple : ERC20

// Invariants d'un ERC20 :

// 1. sum(balances) == totalSupply

// 2. balances[user] >= 0 (impossible de devenir négatif)

// 3. allowance ne change que via approve

contract Token {

mapping(address => uint256) public balanceOf;

mapping(address => mapping(address => uint256)) public allowance;

uint256 public totalSupply;

function mint(address to, uint256 amount) external {

balanceOf[to] += amount;

totalSupply += amount;

}

function transfer(address to, uint256 amount) external {

balanceOf[msg.sender] -= amount;

balanceOf[to] += amount;

}

}

Test d'invariant :

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

import "forge-std/Test.sol";

import "../src/Token.sol";

contract TokenInvariantTest is Test {

Token token;

address[] users;

function setUp() public {

token = new Token();

// Créer des users pour le fuzzing

users.push(address(0x1));

users.push(address(0x2));

users.push(address(0x3));

}

/// forge-config: default.invariant.runs = 100

/// forge-config: default.invariant.depth = 50

function invariant_totalSupplyEqualsSumBalances() public view {

uint256 sum;

for (uint256 i = 0; i < users.length; i++) {

sum += token.balanceOf(users[i]);

}

assertEq(sum, token.totalSupply(), "Sum of balances != totalSupply");

}

}

Comment ça marche :

  • Foundry appelle aléatoirement les fonctions publiques du contrat (mint, transfer)
  • Après chaque séquence d'appels, vérifie l'invariant
  • Si l'invariant échoue, shrink la séquence pour trouver le cas minimal
  • Handlers — Contrôler le Fuzzing

    Par défaut, Foundry appelle toutes les fonctions publiques avec des inputs aléatoires. Mais vous voulez souvent contraindre les actions.

    // Handler = contrat qui wrappe le contrat testé
    

    contract TokenHandler is Test {

    Token token;

    address[] users;

    uint256 public ghost_mintedTotal;

    uint256 public ghost_transferredTotal;

    constructor(Token _token) {

    token = _token;

    users = [address(0x1), address(0x2), address(0x3)];

    }

    // Action : mint à un user aléatoire

    function mint(uint256 userSeed, uint256 amount) external {

    address user = users[userSeed % users.length];

    amount = bound(amount, 0, 1e24); // Limite à 1M tokens

    token.mint(user, amount);

    ghost_mintedTotal += amount;

    }

    // Action : transfer entre deux users

    function transfer(uint256 fromSeed, uint256 toSeed, uint256 amount) external {

    address from = users[fromSeed % users.length];

    address to = users[toSeed % users.length];

    uint256 balance = token.balanceOf(from);

    amount = bound(amount, 0, balance); // Ne transfer que ce qui existe

    vm.prank(from);

    token.transfer(to, amount);

    ghost_transferredTotal += amount;

    }

    }

    Test d'invariant avec handler :

    contract TokenInvariantTest is Test {
    

    Token token;

    TokenHandler handler;

    function setUp() public {

    token = new Token();

    handler = new TokenHandler(token);

    // Foundry fuzzera seulement le handler (pas le token directement)

    targetContract(address(handler));

    }

    function invariant_totalSupplyEqualsMintedTotal() public view {

    assertEq(token.totalSupply(), handler.ghost_mintedTotal());

    }

    function invariant_sumBalancesEqualsTotalSupply() public view {

    uint256 sum;

    for (uint256 i = 0; i < 3; i++) {

    sum += token.balanceOf(address(uint160(i + 1)));

    }

    assertEq(sum, token.totalSupply());

    }

    }

    Avantages du handler :

    • Contrôle sur les actions (bounds, users valides)
    • Ghost variables (tracking de métriques)
    • Meilleure couverture (actions réalistes)

    Invariants Courants

    1. Lois de Conservation

    // ERC20 : sum(balances) == totalSupply
    

    invariant_conservation() {

    assertEq(sumAllBalances(), totalSupply);

    }

    // Vault : sum(user shares) × price == total assets

    invariant_vaultConservation() {

    uint256 totalShares = vault.totalSupply();

    uint256 totalAssets = vault.totalAssets();

    assertEq(totalShares * vault.pricePerShare(), totalAssets);

    }

    2. Solvency

    // Lending : sum(deposits) >= sum(borrows)
    

    invariant_solvency() {

    assertGe(pool.totalDeposits(), pool.totalBorrows());

    }

    // AMM : reserves × price >= total LP value

    invariant_ammSolvency() {

    uint256 reserve0 = amm.reserve0();

    uint256 reserve1 = amm.reserve1();

    uint256 k = reserve0 * reserve1;

    assertGe(k, amm.totalSupply() ** 2); // Constant product

    }

    3. Monotonicity

    // Staking : rewards ne peuvent que croître
    

    uint256 lastRewards;

    invariant_rewardsMonotonic() {

    uint256 currentRewards = staking.totalRewards();

    assertGe(currentRewards, lastRewards);

    lastRewards = currentRewards;

    }

    4. Boundaries

    // Oracle : prix toujours dans une range raisonnable
    

    invariant_priceInRange() {

    uint256 price = oracle.getPrice();

    assertGe(price, 100e8); // Min 100 USD

    assertLe(price, 10000e8); // Max 10,000 USD

    }

    Exemple Complet : ERC4626 Vault

    // Vault ERC4626 simplifié
    

    contract Vault is ERC4626 {

    constructor(IERC20 _asset) ERC4626(_asset) ERC20("Vault", "vTKN") {}

    function totalAssets() public view override returns (uint256) {

    return asset.balanceOf(address(this));

    }

    }

    // Handler

    contract VaultHandler is Test {

    Vault vault;

    IERC20 asset;

    address[] users;

    uint256 public ghost_depositSum;

    uint256 public ghost_withdrawSum;

    constructor(Vault _vault, IERC20 _asset) {

    vault = _vault;

    asset = _asset;

    users = [address(0x1), address(0x2), address(0x3)];

    }

    function deposit(uint256 userSeed, uint256 assets) public {

    address user = users[userSeed % users.length];

    assets = bound(assets, 0, 1e24);

    // Give user some assets

    deal(address(asset), user, assets);

    vm.startPrank(user);

    asset.approve(address(vault), assets);

    vault.deposit(assets, user);

    vm.stopPrank();

    ghost_depositSum += assets;

    }

    function withdraw(uint256 userSeed, uint256 assets) public {

    address user = users[userSeed % users.length];

    uint256 maxAssets = vault.maxWithdraw(user);

    assets = bound(assets, 0, maxAssets);

    if (assets == 0) return;

    vm.prank(user);

    vault.withdraw(assets, user, user);

    ghost_withdrawSum += assets;

    }

    function redeem(uint256 userSeed, uint256 shares) public {

    address user = users[userSeed % users.length];

    uint256 maxShares = vault.maxRedeem(user);

    shares = bound(shares, 0, maxShares);

    if (shares == 0) return;

    vm.prank(user);

    uint256 assets = vault.redeem(shares, user, user);

    ghost_withdrawSum += assets;

    }

    }

    // Tests d'invariants

    contract VaultInvariantTest is Test {

    Vault vault;

    IERC20 asset;

    VaultHandler handler;

    function setUp() public {

    asset = new ERC20Mock();

    vault = new Vault(asset);

    handler = new VaultHandler(vault, asset);

    targetContract(address(handler));

    }

    // Invariant 1 : totalAssets == asset.balanceOf(vault)

    function invariant_totalAssets() public view {

    assertEq(vault.totalAssets(), asset.balanceOf(address(vault)));

    }

    // Invariant 2 : sum(shares) == totalSupply

    function invariant_totalSupply() public view {

    uint256 sum;

    for (uint256 i = 0; i < 3; i++) {

    address user = address(uint160(i + 1));

    sum += vault.balanceOf(user);

    }

    assertEq(sum, vault.totalSupply());

    }

    // Invariant 3 : totalAssets × totalSupply conservation

    function invariant_sharePrice() public view {

    if (vault.totalSupply() == 0) return;

    uint256 expectedAssets = vault.convertToAssets(vault.totalSupply());

    assertApproxEqRel(expectedAssets, vault.totalAssets(), 0.01e18); // 1% tolerance

    }

    // Invariant 4 : deposits - withdraws == totalAssets

    function invariant_netFlow() public view {

    uint256 netDeposits = handler.ghost_depositSum() - handler.ghost_withdrawSum();

    assertEq(netDeposits, vault.totalAssets());

    }

    // Invariant 5 : solvency (peut toujours withdraw)

    function invariant_solvency() public view {

    for (uint256 i = 0; i < 3; i++) {

    address user = address(uint160(i + 1));

    uint256 maxWithdraw = vault.maxWithdraw(user);

    assertLe(maxWithdraw, vault.totalAssets());

    }

    }

    }

    Runs et Depth :

    # foundry.toml
    

    [invariant]

    runs = 256 # Nombre de séquences testées

    depth = 100 # Longueur max de chaque séquence

    fail_on_revert = false # Continue même si certains appels revert

    Shrinking — Réduire les Failures

    Quand un invariant échoue, Foundry shrink automatiquement la séquence.

    Séquence originale (50 appels) :
    

    deposit(100) → mint(50) → transfer(20) → ... → withdraw(1000) ❌

    Shrinking...

    deposit(100) → withdraw(1000) ❌ // Trouvé la cause minimale

    Debug : utilisez ``forge test -vvvv`` pour voir les séquences.

    Ghost Variables — Tracking Avancé

    Les ghost variables trackent des métriques qui n'existent pas dans le contrat.

    contract Handler {
    

    uint256 public ghost_sumDeposits;

    uint256 public ghost_sumWithdraws;

    uint256 public ghost_zeroTransfers; // Compter les transfers de 0

    function transfer(uint256 amount) public {

    if (amount == 0) {

    ghost_zeroTransfers++;

    }

    // ...

    }

    }

    // Invariant : pas plus de 10% de zero transfers

    invariant_noSpamZeroTransfers() {

    uint256 totalCalls = handler.ghost_sumDeposits() + handler.ghost_sumWithdraws();

    assertLe(handler.ghost_zeroTransfers(), totalCalls / 10);

    }

    Conclusion

    Les invariant tests sont la forme de test la plus puissante pour les smart contracts.

    Workflow :

  • Identifier les invariants (conservation, solvency, monotonicity)
  • Écrire des handlers (contraindre les actions)
  • Ajouter des ghost variables (tracking de métriques)
  • Run avec high depth (100-1000 appels par séquence)
  • Debug les failures (shrinking automatique)
  • Invariants courants :

    • Conservation : sum(balances) == totalSupply
    • Solvency : assets >= liabilities
    • Monotonicity : rewards toujours croissants
    • Boundaries : prix/amounts dans des ranges valides

    Les invariants ne remplacent pas les unit tests. Mais ils prouvent que votre contrat est robuste face à des séquences d'actions arbitraires.

    Testez les invariants. Dormez tranquille. 🛡️

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement