# 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 :
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 :
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. 🛡️