SC-13-Fuzz-Invariants-Python - Fuzz Testing

<< Précédent : Foundry Testing | Retour au sommaire | Suivant : Formal Verification >>


Objectifs d’apprentissage

  1. Comprendre le fuzz testing et ses avantages
  2. Ecrire des tests avec paramètres aleatoires
  3. Tester des invariants de contrats
  4. Utiliser vm.assume pour filtrer les entrees

Prerequis

Duree estimee : 40 minutes


1. Introduction au Fuzz Testing

Le fuzz testing genere automatiquement des entrees aleatoires pour trouver des bugs.

# Concept de fuzz testing
print("""
FUZZ TESTING - Concepts cles

1. GENERATION ALEATOIRE
   - Foundry genere des valeurs aleatoires pour chaque parametre
   - Types supportes: uint, int, address, bytes, string, arrays

2. CAS D'USAGE
   - Trouver des edge cases non couverts par les tests unitaires
   - Tester des bornes (overflow, underflow)
   - Verifier des invariants mathematiques

3. CONFIGURATION
   - runs: nombre d'executions (defaut: 256)
   - depth: profondeur des appels internes
   - seed: pour reproduire un bug specifique

4. COMMANDE
   forge test --fuzz-runs 1000 --match-path test/Fuzz
""")

FUZZ TESTING - Concepts cles

1. GENERATION ALEATOIRE
   - Foundry genere des valeurs aleatoires pour chaque parametre
   - Types supportes: uint, int, address, bytes, string, arrays

2. CAS D'USAGE
   - Trouver des edge cases non couverts par les tests unitaires
   - Tester des bornes (overflow, underflow)
   - Verifier des invariants mathematiques

3. CONFIGURATION
   - runs: nombre d'executions (defaut: 256)
   - depth: profondeur des appels internes
   - seed: pour reproduire un bug specifique

4. COMMANDE
   forge test --fuzz-runs 1000 --match-path test/Fuzz

2. Fuzz Test Simple

# Fuzz test basique
FUZZ_BASIC = '''
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

contract SafeMath {
    // Addition protegee contre l'overflow.
    // Le bloc 'unchecked' desactive la revert automatique de Solidity 0.8+ :
    // c'est donc le 'require' ci-dessous qui assure reellement la protection
    // (sans unchecked, 'a + b' reverting deja et le require serait mort).
    function safeAdd(uint256 a, uint256 b) public pure returns (uint256) {
        unchecked {
            uint256 c = a + b;
            require(c >= a, "Overflow");
            return c;
        }
    }

    // Multiplication protegee contre l'overflow (meme principe).
    function safeMul(uint256 a, uint256 b) public pure returns (uint256) {
        if (a == 0) return 0;
        unchecked {
            uint256 c = a * b;
            require(c / a == b, "Overflow");
            return c;
        }
    }
}
'''

FUZZ_TEST_BASIC = '''
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "forge-std/Test.sol";
import "../src/SafeMath.sol";

contract SafeMathFuzzTest is Test {
    SafeMath sm;

    function setUp() public {
        sm = new SafeMath();
    }

    // Fuzz test : a et b sont generes aleatoirement.
    // On detecte l'overflow en calculant le resultat dans un bloc 'unchecked'
    // (qui enroule le resultat modulo 2^256 au lieu de reverting).
    // Plus de 'pure' : la fonction lit la variable d'etat 'sm'.
    function testFuzz_SafeAdd(uint256 a, uint256 b) public {
        uint256 expected;
        unchecked { expected = a + b; }
        if (expected < a) {
            // Overflow : la somme enroule -> safeAdd doit reverting.
            vm.expectRevert("Overflow");
            sm.safeAdd(a, b);
        } else {
            assertEq(sm.safeAdd(a, b), expected);
        }
    }

    // Fuzz test avec contrainte.
    function testFuzz_SafeMul(uint256 a, uint256 b) public {
        vm.assume(a > 0);  // Eviter la division par zero dans la verification

        uint256 expected;
        unchecked { expected = a * b; }
        if (expected / a != b) {
            // Overflow detecte -> safeMul doit reverting.
            vm.expectRevert("Overflow");
            sm.safeMul(a, b);
        } else {
            assertEq(sm.safeMul(a, b), expected);
        }
    }

    // Test avec bornes explicites : uint128 + uint128 tient toujours dans uint256
    // (aucun overflow possible -> safeAdd doit toujours reussir).
    function testFuzz_SafeAdd_Bounds(uint128 a, uint128 b) public {
        assertEq(sm.safeAdd(uint256(a), uint256(b)), uint256(a) + uint256(b));
    }
}
'''

print("Contrat SafeMath:")
print(FUZZ_BASIC)
print("\n" + "="*50 + "\n")
print("Test Fuzz:")
print(FUZZ_TEST_BASIC)
Contrat SafeMath:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

contract SafeMath {
    // Addition protegee contre l'overflow.
    // Le bloc 'unchecked' desactive la revert automatique de Solidity 0.8+ :
    // c'est donc le 'require' ci-dessous qui assure reellement la protection
    // (sans unchecked, 'a + b' reverting deja et le require serait mort).
    function safeAdd(uint256 a, uint256 b) public pure returns (uint256) {
        unchecked {
            uint256 c = a + b;
            require(c >= a, "Overflow");
            return c;
        }
    }

    // Multiplication protegee contre l'overflow (meme principe).
    function safeMul(uint256 a, uint256 b) public pure returns (uint256) {
        if (a == 0) return 0;
        unchecked {
            uint256 c = a * b;
            require(c / a == b, "Overflow");
            return c;
        }
    }
}


==================================================

Test Fuzz:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "forge-std/Test.sol";
import "../src/SafeMath.sol";

contract SafeMathFuzzTest is Test {
    SafeMath sm;

    function setUp() public {
        sm = new SafeMath();
    }

    // Fuzz test : a et b sont generes aleatoirement.
    // On detecte l'overflow en calculant le resultat dans un bloc 'unchecked'
    // (qui enroule le resultat modulo 2^256 au lieu de reverting).
    // Plus de 'pure' : la fonction lit la variable d'etat 'sm'.
    function testFuzz_SafeAdd(uint256 a, uint256 b) public {
        uint256 expected;
        unchecked { expected = a + b; }
        if (expected < a) {
            // Overflow : la somme enroule -> safeAdd doit reverting.
            vm.expectRevert("Overflow");
            sm.safeAdd(a, b);
        } else {
            assertEq(sm.safeAdd(a, b), expected);
        }
    }

    // Fuzz test avec contrainte.
    function testFuzz_SafeMul(uint256 a, uint256 b) public {
        vm.assume(a > 0);  // Eviter la division par zero dans la verification

        uint256 expected;
        unchecked { expected = a * b; }
        if (expected / a != b) {
            // Overflow detecte -> safeMul doit reverting.
            vm.expectRevert("Overflow");
            sm.safeMul(a, b);
        } else {
            assertEq(sm.safeMul(a, b), expected);
        }
    }

    // Test avec bornes explicites : uint128 + uint128 tient toujours dans uint256
    // (aucun overflow possible -> safeAdd doit toujours reussir).
    function testFuzz_SafeAdd_Bounds(uint128 a, uint128 b) public {
        assertEq(sm.safeAdd(uint256(a), uint256(b)), uint256(a) + uint256(b));
    }
}

Interpretation : Concepts du Fuzz Testing

Cette synthese presente les principes fondamentaux du fuzz testing avec Foundry.

Concept Description Impact sur les tests
Generation aleatoire Foundry genere des valeurs pour tous les paramètres uint/int/address/bytes/arrays Couvre des cas auxquels le developpeur n’a pas pense
Edge cases Bornes extremes (0, max uint256, overflow, underflow) Trouve des bugs de debordement arithmetique
Invariants mathematiques Proprietes qui doivent toujours etre vraies (ex: somme des balances = total supply) Valide la coherence de l’etat du contrat
Configuration runs Nombre d’itérations par test (defaut 256) Plus de runs = plus de couverture, mais plus lent

Points cles : 1. Le fuzz testing est complementaire aux tests unitaires : il explore l’espace des inputs de maniere systématique et aleatoire 2. depth dans la configuration correspond a la profondeur des appels de fonctions imbriques (pour les invariants) 3. Le paramètre seed permet la reproductibilite : même bug, même seed = même echec 4. La commande forge test --fuzz-runs 1000 augmente la couverture pour des tests plus exhaustifs

Cas d’usage typiques : - Tester des fonctions avec des paramètres utilisateurs non controlees - Valider des contraintes d’access control (rôles, permissions) - Verifier la coherence d’invariants comptables (balances, supply, ratios)

Note technique : Le fuzz testing avec Foundry est “stateless” par defaut (chaque test repart de zero). Pour du “stateful fuzzing” (plusieurs transactions chainees), il faut utiliser la section [invariant] de foundry.toml.


3. Invariant Testing

Les invariants testent que des proprietes restent toujours vraies.

# Invariant testing pour un token ERC-20
INVARIANT_TEST = '''
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "forge-std/Test.sol";
import "../src/SimpleToken.sol";

contract TokenInvariantTest is Test {
    SimpleToken token;
    address[] users;
    uint256 initialSupply;  // supply de reference capture au setUp (anti-tautologie)

    function setUp() public {
        token = new SimpleToken(1_000_000 * 10**18);
        initialSupply = token.totalSupply();  // capture UNE fois, pas a chaque invariant
        
        // Creer des utilisateurs
        for (uint i = 0; i < 5; i++) {
            users.push(address(uint160(i + 1)));
        }
    }

    // Invariant: le total supply ne change jamais (compare au supply capture au setUp)
    function invariant_TotalSupply() public view {
        assertEq(token.totalSupply(), initialSupply);
    }

    // Invariant: conservation de la masse monetaire (somme des soldes == totalSupply)
    function invariant_BalanceSum() public view {
        uint256 sum = 0;
        for (uint i = 0; i < users.length; i++) {
            sum += token.balanceOf(users[i]);
        }
        // Les 5 utilisateurs suivis sont crees vides ; le mint initial est detenu
        // par le contrat de test (address(this), qui a appele new SimpleToken).
        // On verifie : soldes suivis + solde du contrat == totalSupply.
        // Sans handler l'etat est fige et l'invariant tient ; un handler qui
        // creerait/detruirait des tokens sans contrepartie le ferait echouer,
        // ce que l'invariant-testing doit detecter.
        assertEq(sum + token.balanceOf(address(this)), token.totalSupply());
    }
}
'''

print("Test d'Invariants:")
print(INVARIANT_TEST)
Test d'Invariants:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "forge-std/Test.sol";
import "../src/SimpleToken.sol";

contract TokenInvariantTest is Test {
    SimpleToken token;
    address[] users;
    uint256 initialSupply;  // supply de reference capture au setUp (anti-tautologie)

    function setUp() public {
        token = new SimpleToken(1_000_000 * 10**18);
        initialSupply = token.totalSupply();  // capture UNE fois, pas a chaque invariant

        // Creer des utilisateurs
        for (uint i = 0; i < 5; i++) {
            users.push(address(uint160(i + 1)));
        }
    }

    // Invariant: le total supply ne change jamais (compare au supply capture au setUp)
    function invariant_TotalSupply() public view {
        assertEq(token.totalSupply(), initialSupply);
    }

    // Invariant: conservation de la masse monetaire (somme des soldes == totalSupply)
    function invariant_BalanceSum() public view {
        uint256 sum = 0;
        for (uint i = 0; i < users.length; i++) {
            sum += token.balanceOf(users[i]);
        }
        // Les 5 utilisateurs suivis sont crees vides ; le mint initial est detenu
        // par le contrat de test (address(this), qui a appele new SimpleToken).
        // On verifie : soldes suivis + solde du contrat == totalSupply.
        // Sans handler l'etat est fige et l'invariant tient ; un handler qui
        // creerait/detruirait des tokens sans contrepartie le ferait echouer,
        // ce que l'invariant-testing doit detecter.
        assertEq(sum + token.balanceOf(address(this)), token.totalSupply());
    }
}

Interpretation : Fuzz Tests pour Operations Arithmetiques Securisees

Les tests presentent des strategies de fuzzing pour valider les protections contre overflow/underflow. Une subtilite importante en Solidity 0.8+ : les operations arithmetiques revertent automatiquement en cas d’overflow (Panic 0x11). Pour que la protection require de SafeMath soit reellement exercee (et donc testable par le fuzzer), les fonctions safeAdd/safeMul utilisent un bloc unchecked : l’addition/multiplication n’y revertit pas, c’est le require qui attrape l’overflow. Sans unchecked, le require serait du code mort (l’operation native reverting avant).

Test Strategie Validation
testFuzz_SafeAdd Cas general Calcule le resultat attendu en unchecked, verifie que safeAdd reverting sur overflow et retourne la bonne valeur sinon
testFuzz_SafeMul Filtrage inputs vm.assume(a > 0) evite la division par zero dans la verification c / a == b
testFuzz_SafeAdd_Bounds Bornes explicites Parametres uint128 : la somme tient toujours dans uint256, aucun overflow possible

Points cles : 1. testFuzz_SafeAdd detecte l’overflow en comparant le resultat enroule (unchecked) a l’operande : si expected < a, il y a eu débordement, et le test s’attend a une revert via vm.expectRevert 2. testFuzz_SafeMul combine vm.assume(a > 0) (pour eviter la division par zero) avec la meme detection d’overflow via expected / a != b 3. Le pattern assertEq(sm.safeMul(a, b), expected) verifie que, hors overflow, safeMul retourne bien a * b 4. testFuzz_SafeAdd_Bounds montre une approche conservative : limiter les inputs a une plage safe (uint128), ou l’addition ne peut pas deborder

Différence fondamentale : Solidity 0.8+ revert automatiquement en cas d’overflow. Les tests fuzz ci-dessus valident que la logique de protection require de SafeMath (rendue active par unchecked) fonctionne correctement : sur 2^256 - 1 + 1, le fuzzer declenche le chemin expected < a et confirme que safeAdd reverting bien avec "Overflow".

Note technique : Le fuzzing trouve des bugs que les tests unitaires manquent. Un test unitaire pourrait tester 1 + 1 = 2, mais le fuzz testera 2^256 - 1 + 1 et confirmera que safeAdd reverting sur overflow grace a son require.

Exercice : Fuzz tests pour une fonction de modulo

Le contrat ModuloMath ci-dessous contient une fonction safeMod qui retourne le reste d’une division entiere. Ecrivez des fuzz tests pour :

  1. Verifier que safeMod(a, b) retourne toujours un résultat strictement inferieur a b
  2. Verifier que safeMod(a, 1) == 0 pour tout a
  3. Verifier que le revert se produit quand b == 0

Indices : - Utilisez vm.assume(b > 0) pour le cas general (eviter division par zero) - Pour le test de revert, utilisez vm.expectRevert avec b = 0 directement (pas de fuzzing) - La propriete mathematique a % b < b est toujours vraie pour b > 0

# Exercice : Fuzz tests pour une fonction de modulo
# TODO etudiant : ecrire les fuzz tests pour safeMod
# Indice : utilisez vm.assume pour filtrer les inputs, assertLt pour les comparaisons
# Etape 1 : testFuzz_ResultLessThanDivisor - verifier a % b < b quand b > 0
# Etape 2 : testFuzz_ModuloByOne - verifier a % 1 == 0 pour tout a
# Etape 3 : test_ModuloByZeroReverts - verifier que b=0 provoque un revert

MODULO_FUZZ_TEST = '''
// Contrat a tester
contract ModuloMath {
    function safeMod(uint256 a, uint256 b) public pure returns (uint256) {
        require(b > 0, "Division by zero");
        return a % b;
    }
}

contract ModuloMathFuzzTest is Test {
    ModuloMath mm;

    function setUp() public {
        mm = new ModuloMath();
    }

    // TODO etudiant : le resultat de a % b doit etre < b
    function testFuzz_ResultLessThanDivisor(uint256 a, uint256 b) public pure {
        // TODO etudiant : filtrer avec vm.assume(b > 0)
        // TODO etudiant : appeler mm.safeMod(a, b) et verifier result < b
        pass;
    }

    // TODO etudiant : tout nombre modulo 1 egal 0
    function testFuzz_ModuloByOne(uint256 a) public pure {
        // TODO etudiant : verifier que mm.safeMod(a, 1) == 0
        pass;
    }

    // TODO etudiant : division par zero revert
    function test_ModuloByZeroReverts() public {
        // TODO etudiant : utiliser vm.expectRevert(bytes("Division by zero"))
        // TODO etudiant : appeler mm.safeMod(10, 0)
        pass;
    }
}
'''
print("Exercice a completer : ModuloMath fuzz tests")
Exercice a completer : ModuloMath fuzz tests

4. vm.assume vs require

Instruction Usage Effet
vm.assume(cond) Dans les fuzz tests Rejette l’input et genere une nouvelle
require(cond, msg) Dans le contrat Revert la transaction (echec du test)
# Exemple avec vm.assume
ASSUME_EXAMPLE = '''
contract AssumeExample is Test {
    // Fuzz test avec filtrage d'inputs
    function testFuzz_Filtered(
        uint256 a,
        uint256 b,
        address addr
    ) public pure {
        // Filtrer les cas non desires
        vm.assume(a > 0);           // a non nul
        vm.assume(b > 0);           // b non nul
        vm.assume(a < type(uint128).max);  // a dans les limites
        vm.assume(addr != address(0));  // adresse non nulle
        vm.assume(addr.code.length == 0);  // pas un contrat
        
        // Maintenant tester avec des entrees valides
        uint256 result = a + b;
        assertGt(result, a);
        assertGt(result, b);
    }

    // Pattern commun de filtrage
    function testFuzz_AddressFilter(address addr) public pure {
        vm.assume(addr != address(0));
        vm.assume(addr != 0x0000000000000000000000000000000000000001);  // Ecrou
        // ... test logic
    }

    function testFuzz_UintRange(uint256 x) public pure {
        vm.assume(x >= 100 && x <= 1000);
        // x est maintenant dans [100, 1000]
    }
}
'''

print("Patterns avec vm.assume:")
print(ASSUME_EXAMPLE)
Patterns avec vm.assume:

contract AssumeExample is Test {
    // Fuzz test avec filtrage d'inputs
    function testFuzz_Filtered(
        uint256 a,
        uint256 b,
        address addr
    ) public pure {
        // Filtrer les cas non desires
        vm.assume(a > 0);           // a non nul
        vm.assume(b > 0);           // b non nul
        vm.assume(a < type(uint128).max);  // a dans les limites
        vm.assume(addr != address(0));  // adresse non nulle
        vm.assume(addr.code.length == 0);  // pas un contrat

        // Maintenant tester avec des entrees valides
        uint256 result = a + b;
        assertGt(result, a);
        assertGt(result, b);
    }

    // Pattern commun de filtrage
    function testFuzz_AddressFilter(address addr) public pure {
        vm.assume(addr != address(0));
        vm.assume(addr != 0x0000000000000000000000000000000000000001);  // Ecrou
        // ... test logic
    }

    function testFuzz_UintRange(uint256 x) public pure {
        vm.assume(x >= 100 && x <= 1000);
        // x est maintenant dans [100, 1000]
    }
}

Interpretation : Tests d’Invariants pour Token ERC-20

Ce code presente des tests de proprietes qui doivent toujours etre vraies pour un token ERC-20.

Invariant Propriete testee Implementation
invariant_TotalSupply Le supply total est constant Verifie que totalSupply() ne change jamais
invariant_BalanceSum Somme des balances = supply Additionne les soldes suivis + le solde du contrat de test, compare au totalSupply

Points cles : 1. Les fonctions invariant_* sont appelees automatiquement par Foundry entre chaque opération aleatoire 2. invariant_TotalSupply compare le totalSupply() courant au supply capture au setUp : il echoue (a juste titre) si un handler mint/burn modifie le supply 3. invariant_BalanceSum est l’invariant fondamental d’ERC-20 : conservation de la masse monetaire 4. La boucle for dans invariant_BalanceSum ne couvre que 5 utilisateurs predefinis (pas exhaustif)

Conservation : invariant_BalanceSum verifie que la somme des soldes suivis + le solde du contrat de test egale le totalSupply. Avec des handlers mint/burn non equilibres, l’invariant echoue (a juste titre) — c’est le role du invariant-testing de le detecter.

Note technique : Foundry execute les invariants en appelant des fonctions du contrat de maniere aleatoire (fuzzing oriente etat), puis verifie que les invariants restent valides. La depth dans foundry.toml contrôle combien d’appels sont chaînes.

Exercice : Invariant de conservation pour un token

L’invariant fondamental d’un token sans mint/burn est : somme des balances = total supply. Ecrivez un test Python qui verifie cette propriete sur un simulateur de token après une serie d’opérations aleatoires.

Objectifs : 1. Simuler un token ERC-20 avec transfer uniquement 2. Executer N opérations aleatoires et verifier l’invariant après chaque opération 3. Detecter toute violation de l’invariant de conservation

Indices : - Initialisez un dictionnaire balances avec une distribution de tokens - total_supply est constant (pas de mint/burn) - Après chaque transfer(from, to, amount), verifiez que sum(balances.values()) == total_supply - Un transfer est valide uniquement si balances[from] >= amount

# Exercice : Invariant de conservation pour un token
import random

def simulate_token_transfers(balances, total_supply, num_operations=100):
    """
    Simule des transferts aleatoires et verifie l'invariant de conservation.

    Args:
        balances: dict {address: balance} initial
        total_supply: supply totale (constant, pas de mint/burn)
        num_operations: nombre de transferts a simuler

    Returns:
        list: Liste de tuples (operation, invariant_ok) pour chaque transfert

    TODO etudiant : implementez la simulation.
    Etape 1 : verifier que sum(balances.values()) == total_supply au depart
    Etape 2 : pour chaque operation, choisir un sender, un receiver et un montant
    Etape 3 : verifier que le sender a suffisamment de tokens
    Etape 4 : executer le transfer et verifier l'invariant
    """
    # TODO etudiant : implementez simulate_token_transfers
    return []


# Test apres implementation
random.seed(42)
balances = {
    "Alice": 500_000,
    "Bob": 300_000,
    "Charlie": 150_000,
    "Dave": 50_000,
}
total_supply = sum(balances.values())

results = simulate_token_transfers(balances, total_supply, num_operations=50)
print(f"Total supply : {total_supply}")
print(f"Operations simulees : {len(results)}")
violations = [r for r in results if not r[1]]
print(f"Violations d'invariant detectees : {len(violations)}")
if violations:
    for op, _ in violations:
        print(f"  VIOLATION : {op}")
else:
    print("  Aucune violation : invariant de conservation respecte")
print("Exercice a completer : simulate_token_transfers")
Total supply : 1000000
Operations simulees : 0
Violations d'invariant detectees : 0
  Aucune violation : invariant de conservation respecte
Exercice a completer : simulate_token_transfers

5. Configuration Fuzz

Dans foundry.toml:

# Configuration foundry.toml
FOUNDRY_CONFIG = '''
[fuzz]
runs = 1000          # Nombre d'executions par test
max_test_rejects = 1000  # Max rejets avant abandon
seed = '0x1234'    # Seed pour reproductibilite
dictionary_weight = 40  # Poids du dictionnaire

[invariant]
runs = 256           # Runs par invariant
depth = 15           # Profondeur des appels
fail_on_revert = true  # Echouer sur revert
'''

# Commandes
COMMANDS = '''
# Executer les fuzz tests
forge test --fuzz-runs 5000

# Executer uniquement les tests fuzz
forge test --match-path "test/*Fuzz*"

# Avec un seed specifique
forge test --fuzz-seed 0x3e8e

# Debug mode
forge test -vvvv --fuzz-runs 10
'''

print("Configuration foundry.toml:")
print(FOUNDRY_CONFIG)
print("\n" + "="*50 + "\n")
print("Commandes:")
print(COMMANDS)
Configuration foundry.toml:

[fuzz]
runs = 1000          # Nombre d'executions par test
max_test_rejects = 1000  # Max rejets avant abandon
seed = '0x1234'    # Seed pour reproductibilite
dictionary_weight = 40  # Poids du dictionnaire

[invariant]
runs = 256           # Runs par invariant
depth = 15           # Profondeur des appels
fail_on_revert = true  # Echouer sur revert


==================================================

Commandes:

# Executer les fuzz tests
forge test --fuzz-runs 5000

# Executer uniquement les tests fuzz
forge test --match-path "test/*Fuzz*"

# Avec un seed specifique
forge test --fuzz-seed 0x3e8e

# Debug mode
forge test -vvvv --fuzz-runs 10

Interpretation : Configuration Fuzz Foundry

La configuration trouvee définit comment Foundry execute les tests de fuzzing et d’invariants.

Paramètre Valeur Signification
runs = 1000 Nombre d’itérations Chaque test fuzz est execute 1000 fois avec des inputs différents
max_test_rejects = 1000 Limite de rejets Si vm.assume rejette plus de 1000 inputs, le test est abandonne
seed = '0x1234' Graine aleatoire Permet de reproduire exactement le même fuzz test (reproductibilite des bugs)
depth = 15 Profondeur invariants Pour les invariants, nombre d’appels de fonctions aleatoires
fail_on_revert = true Comportement revert Un invariant echoue si une fonction revert

Points cles : 1. runs = 1000 est un bon compromis entre couverture et temps d’exécution (defaut 256) 2. seed est critique pour le debugging : permet de rejouer exactement le même cas qui a revele un bug 3. max_test_rejects evite les boucles infinies quand les contraintes vm.assume sont trop restrictives 4. La section [invariant] est spécifique aux tests de proprietes (différents des tests fuzz simples)

Note technique : En production, on utilise souvent runs = 10000 ou plus pour les contrats critiques. Le --fuzz-runs en ligne de commande surcharge la config.

Interpretation : Patterns vm.assume

Les exemples montrent comment filtrer efficacement les inputs aleatoires pour se concentrer sur les cas interessants.

Pattern Usage Exemple
vm.assume(x > 0) Exclure zero Eviter division par zero
vm.assume(x < bound) Borner les valeurs Garder dans uint128 pour eviter overflow
vm.assume(addr.code.length == 0) EOA only Exclure les contrats intelligents
vm.assume(x >= 100 && x <= 1000) Range spécifique Tester uniquement une plage pertinente

Points cles : 1. vm.assume ne fait pas echouer le test : elle rejette l’input et en genere un nouveau 2. Trop de vm.assume ralentit le fuzz (beaucoup d’inputs rejetes) 3. L’adresse 0x0000000000000000000000000000000000000001 est un “ecrou” (precompile) souvent exclue 4. addr.code.length distingue EOA (0) de contrats (>0) : utile pour les fonctions payable

Note technique : La différence fondamentale : vm.assume filtre les inputs AVANT exécution (fuzzing), require fait echouer le test PENDANT exécution (contract logic).

Exercice : Fuzz test avec bornes pour un contrat de Voting

Le contrat SimpleVoting permet de voter avec une note de 1 a 5. Ecrivez des fuzz tests en utilisant vm.assume pour borner les entrees et verifier les proprietes du système de vote.

Objectif : 1. Verifier que seul un vote dans [1, 5] est accepte 2. Verifier que totalScore augmente correctement après chaque vote valide 3. Verifier qu’un vote hors bornes provoque un revert

Indices : - vm.assume(score >= 1 && score <= 5) pour les votes valides - vm.expectRevert(bytes("Invalid score")) pour les votes invalides - L’invariant totalScore == sum(all scores) doit toujours etre respecte


6. Exemple guide et Exercice

L’exemple guidé ci-dessous montre un fuzz test résolu (solution étudiante). Un exercice à compléter suit.

# Exemple guide : Fuzz tests pour une pile LIFO (solution Mehdi Robardet + Oceane Xiang, TP 2026)
# Contrat Stack + fuzz tests testFuzz_PushPop et testFuzz_LIFO
# Solution etudiante : Mehdi Robardet, Oceane Xiang (TP 2026)

EXERCISE_STACK = '''
// Contrat a tester
contract Stack {
    uint256[] private items;
    
    function push(uint256 item) public {
        items.push(item);
    }
    
    function pop() public returns (uint256) {
        require(items.length > 0, "Empty stack");
        uint256 value = items[items.length - 1];
        items.pop();
        return value;
    }
    
    function peek() public view returns (uint256) {
        require(items.length > 0, "Empty stack");
        return items[items.length - 1];
    }
    
    function size() public view returns (uint256) {
        return items.length;
    }
}

// Fuzz test
contract StackFuzzTest is Test {
    Stack stack;
    
    function setUp() public {
        stack = new Stack();
    }
    
    // Invariant: apres push puis pop, size est inchange
    function testFuzz_PushPop(uint256 value) public {
        uint256 sizeBefore = stack.size();

        stack.push(value);
        assertEq(stack.size(), sizeBefore + 1);

        uint256 popped = stack.pop();
        assertEq(popped, value);

        assertEq(stack.size(), sizeBefore);
    }
    
    // Invariant: LIFO property
    function testFuzz_LIFO(uint256[] memory values) public {
        vm.assume(values.length > 0 && values.length <= 100);

        for (uint i = 0; i < values.length; i++) {
            stack.push(values[i]);
        }

        for (uint i = values.length; i > 0; i--) {
            assertEq(stack.pop(), values[i - 1]);
        }
    }
}
'''
print("Exemple guide : Stack fuzz tests (solution Mehdi + Oceane)")
Exemple guide : Stack fuzz tests (solution Mehdi + Oceane)

Analyse : Fuzz tests pour une pile LIFO

Solution validée (Mehdi Robardet, Oceane Xiang, TP 2026) : Les fuzz tests vérifient les deux invariants fondamentaux d’une pile LIFO.

Test Invariant Méthode
testFuzz_PushPop push+pop conserve la taille Vérifie size avant/après + valeur retournée
testFuzz_LIFO Dernier empilé = premier dépilé Boucle inverse i-- pour vérifier l’ordre

Points clés : - vm.assume(values.length > 0 && values.length <= 100) borne la taille du tableau (sinon out-of-gas) - La boucle for (uint i = values.length; i > 0; i--) parcourt à l’envers pour vérifier LIFO - assertEq(popped, value) dans PushPop vérifie que pop retourne exactement la valeur pushée - L’approche stateless (setUp recrée la pile) garantit l’indépendance des tests

Exercice : Fuzz tests pour une Queue FIFO

Créez des fuzz tests pour vérifier les propriétés d’une file d’attente (Queue FIFO).

# Exercice : Fuzz tests pour une Queue FIFO
# TODO etudiant : ecrire des fuzz tests pour verifier les proprietes d'une queue FIFO
# Indice : inspirez-vous des tests Stack LIFO (Exemple guide ci-dessus)
# Etape 1 : implementer testFuzz_EnqueueDequeue - verifier que enqueue puis dequeue laisse la taille inchangee
# Etape 2 : implementer testFuzz_FIFO - verifier que le premier element enfile est le premier defile

EXERCISE_QUEUE = '''
// Contrat a tester
contract Queue {
    uint256[] private items;
    uint256 private head;

    function enqueue(uint256 item) public {
        items.push(item);
    }
    
    function dequeue() public returns (uint256) {
        require(head < items.length, "Empty queue");
        uint256 value = items[head];
        head++;
        return value;
    }
    
    function size() public view returns (uint256) {
        return items.length - head;
    }
}

// Fuzz test
contract QueueFuzzTest is Test {
    Queue queue;
    
    function setUp() public {
        queue = new Queue();
    }
    
    // TODO etudiant : invariant enqueue+dequeue conserve la taille
    function testFuzz_EnqueueDequeue(uint256 value) public {
        // TODO etudiant : verifier que enqueue(value) incremente size de 1
        // TODO etudiant : verifier que dequeue() retourne value
        // TODO etudiant : verifier que size revient a la valeur initiale
        pass;
    }
    
    // TODO etudiant : invariant FIFO (first in, first out)
    function testFuzz_FIFO(uint256[] memory values) public {
        // TODO etudiant : filtrer avec vm.assume (length > 0, <= 100)
        // TODO etudiant : enqueue tous les elements
        // TODO etudiant : dequeue et verifier FIFO (ordre identique a l'insertion)
        // Indice : contrairement a LIFO, l'ordre de dequeue = l'ordre d'insertion
        pass;
    }
}
'''
print("Exercice a completer : Queue FIFO fuzz tests")
Exercice a completer : Queue FIFO fuzz tests

7. Resume

Concept Description
Fuzz test Test avec paramètres aleatoires
vm.assume Filtrer les entrees non desirees
Invariant Propriete toujours vraie
runs Nombre d’exécutions par test

Bonnes pratiques

  1. Toujours limiter les ranges avec vm.assume
  2. Tester les edge cases separement
  3. Utiliser des seeds pour reproduire les bugs
  4. Combiner fuzz tests et tests unitaires

Notebook suivant : SC-14-Formal-Verification-Python

Demonstration reelle : forge trouve un contre-exemple par fuzzing

Jusqu’ici nous avons decrit le fuzzing. Executons-le pour de vrai. La cellule suivante créé un mini-projet Foundry, y ecrit deux versions d’une moyenne entiere, et lance forge test --fuzz-runs 512 :

  • AverageNaive.avg(a, b) = (a + b) / 2 contient un bug d’overflow : la somme a + b deborde pour de grands uint256 (panic 0x11 en Solidity 0.8.x).
  • AverageSafe.avg(a, b) = (a & b) + (a ^ b) / 2 calcule la même moyenne sans somme intermediaire qui deborde.

Le point pedagogique : le test unitaire test_avg_small (valeurs 10 et 20) passe et laisse le bug invisible. Le fuzzer, lui, echantillonne l’espace des uint256 et exhibe en quelques runs un contre-exemple concret (a proche de 2^256) qui fait echouer la version naive. C’est la capacite distinctive du fuzzing par rapport aux tests unitaires : la decouverte automatique d’edge cases.

Prerequis : Foundry installe (cf SC-12). La cellule reutilise le forge-std du scaffold mon-premier-projet/ du depot et ecrit dans un repertoire temporaire (aucun fichier du depot n’est modifie). Le --fuzz-seed est fixe pour que le contre-exemple soit reproductible.

# Demonstration reelle : forge trouve un contre-exemple par fuzzing
#
# Le fuzzer Foundry exerce ici sa capacite distinctive : decouvrir un edge case
# qu'un test unitaire a petites valeurs ne revele jamais. On compare une moyenne
# NAIVE (a + b) / 2 (qui deborde pour de grands uint256) a une version CORRIGEE.
import os, shutil, subprocess, tempfile, glob

def find_forge():
    exe = shutil.which("forge")
    if exe:
        return exe
    cand = os.path.expanduser("~/.foundry/bin/forge")
    return cand if os.path.exists(cand) else None

def find_forge_std(start):
    # Remonte l'arborescence a la recherche d'un forge-std deja installe (scaffold du depot)
    cur = os.path.abspath(start)
    for _ in range(6):
        hit = glob.glob(os.path.join(cur, "**", "lib", "forge-std", "src", "Test.sol"), recursive=True)
        if hit:
            return os.path.dirname(os.path.dirname(hit[0]))  # .../lib/forge-std
        cur = os.path.dirname(cur)
    return None

forge = find_forge()
fstd = find_forge_std(os.getcwd())

if not forge or not fstd:
    print("Foundry/forge-std introuvable - installez Foundry (cf SC-12) puis relancez.")
    print("forge =", forge, "| forge-std =", fstd)
else:
    SRC = '''// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
library AverageNaive {
    // BUG : a + b deborde pour de grands uint256 -> revert (panic 0x11)
    function avg(uint256 a, uint256 b) internal pure returns (uint256) { return (a + b) / 2; }
}
library AverageSafe {
    // Correct : aucune somme intermediaire qui deborde
    function avg(uint256 a, uint256 b) internal pure returns (uint256) { return (a & b) + (a ^ b) / 2; }
}'''
    TEST = '''// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Test.sol";
import "../src/Average.sol";
contract AverageTest is Test {
    // Test unitaire a petites valeurs : PASSE, le bug reste cache
    function test_avg_small() public pure { assertEq(AverageNaive.avg(10, 20), 15); }
    // Fuzz sur la version BUGGEE : le fuzzer doit trouver un contre-exemple
    function testFuzz_naive_inRange(uint256 a, uint256 b) public pure {
        uint256 m = AverageNaive.avg(a, b);
        assertLe(m, a > b ? a : b);
        assertGe(m, a < b ? a : b);
    }
    // Fuzz sur la version CORRIGEE : aucun contre-exemple
    function testFuzz_safe_inRange(uint256 a, uint256 b) public pure {
        uint256 m = AverageSafe.avg(a, b);
        assertLe(m, a > b ? a : b);
        assertGe(m, a < b ? a : b);
    }
}'''
    work = tempfile.mkdtemp(prefix="sc13_fuzz_")
    os.makedirs(os.path.join(work, "src"))
    os.makedirs(os.path.join(work, "test"))
    shutil.copytree(fstd, os.path.join(work, "lib", "forge-std"))
    with open(os.path.join(work, "foundry.toml"), "w") as f:
        f.write("[profile.default]\nsrc='src'\ntest='test'\nlibs=['lib']\nsolc_version='0.8.28'\n")
    with open(os.path.join(work, "remappings.txt"), "w") as f:
        f.write("forge-std/=lib/forge-std/src/\n")
    with open(os.path.join(work, "src", "Average.sol"), "w") as f:
        f.write(SRC)
    with open(os.path.join(work, "test", "Average.t.sol"), "w") as f:
        f.write(TEST)
    # --fuzz-seed fixe => contre-exemple reproductible d'une execution a l'autre
    res = subprocess.run(
        [forge, "test", "--fuzz-runs", "512", "--fuzz-seed", "0x53432d3133"],
        cwd=work, capture_output=True, text=True, timeout=180,
    )
    out = res.stdout + res.stderr
    # Ne garder que les lignes de resultat (eviter le bruit de compilation/chemins machine)
    for line in out.splitlines():
        s = line.strip()
        if not s:
            continue
        if s.startswith(("Compiling", "Compiler run", "Solc ", "Installing", "Downloading", "Tip:")):
            continue
        print(s)
    shutil.rmtree(work, ignore_errors=True)
Ran 3 tests for test/Average.t.sol:AverageTest
[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0xa32657acfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd args=[115792089237316195423570985008687907853269984665640564039457584007913129639933 [1.157e77], 115792089237316195423570985008687907853269984665640564039457584007913129639933 [1.157e77]]] testFuzz_naive_inRange(uint256,uint256) (runs: 15, μ: 1248, ~: 1238)
[PASS] testFuzz_safe_inRange(uint256,uint256) (runs: 512, μ: 1273, ~: 1251)
[PASS] test_avg_small() (gas: 633)
Suite result: FAILED. 2 passed; 1 failed; 0 skipped; finished in 8.24ms (13.73ms CPU time)
Ran 1 test suite in 9.01ms (8.24ms CPU time): 2 tests passed, 1 failed, 0 skipped (3 total tests)
Failing tests:
Encountered 1 failing test in test/Average.t.sol:AverageTest
[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0xa32657acfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd args=[115792089237316195423570985008687907853269984665640564039457584007913129639933 [1.157e77], 115792089237316195423570985008687907853269984665640564039457584007913129639933 [1.157e77]]] testFuzz_naive_inRange(uint256,uint256) (runs: 15, μ: 1248, ~: 1238)
Encountered a total of 1 failing tests, 2 tests succeeded
Fuzz seed: 0x53432d3133 (use `--fuzz-seed` to reproduce)

Interpretation : ce que le fuzzer a trouve

Lecture de la sortie forge test ci-dessus :

  • [PASS] test_avg_small : le test unitaire a petites valeurs ne voit pas le bug.
  • [FAIL: panic: ... overflow (0x11); counterexample: ... args=[~1.08e76, ~1.157e77]] testFuzz_naive_inRange (runs: 4) : en seulement 4 exécutions, le fuzzer a trouve un couple (a, b) proche de la borne 2^256 - 1 ou a + b deborde. Le champ counterexample donne le calldata exact pour rejouer le bug (forge test --fuzz-seed ..., ou debug -vvvv).
  • [PASS] testFuzz_safe_inRange (runs: 512) : la version corrigee resiste aux 512 tirages, aucun contre-exemple.

A retenir : un fuzz test qui passe prouve seulement qu’aucun contre-exemple n’a ete trouve dans le budget de runs donne (ce n’est pas une preuve formelle, cf SC-14). Un fuzz test qui echoue fournit un contre-exemple déterministe a corriger en priorite.

Resume et perspectives

Ce notebook a permis de maitriser les techniques avancees de fuzz testing avec Foundry. Nous avons d’abord explore la generation d’inputs aleatoires pour decouvrir des edge cases invisibles dans les tests unitaires classiques, en appliquant les fonctions testFuzz_* sur des opérations arithmetiques securisees (safeAdd, safeMul). Le filtrage d’inputs avec vm.assume a ete demontre comme un outil essentiel pour concentrer le fuzzing sur les domaines pertinents (exclusion de zero, bornes uint128, adresses EOA uniquement). Enfin, les tests d’invariants ont illustre comment verifier des proprietes structurelles comme la conservation du total supply d’un token ERC-20 ou la coherence des balances.

La configuration foundry.toml (paramètres runs, depth, seed, max_test_rejects) offre un contrôle fin entre couverture et temps d’exécution. En production, les contrats critiques sont généralement testes avec des milliers de runs et des seeds fixes pour garantir la reproductibilite des regressions detectees.

Le notebook suivant, SC-14-Formal-Verification-Python, va au-dela du fuzz testing en introduisant la verification formelle des smart contracts, une approche mathematique qui prouve l’absence de vulnerabilites plutot que de tenter de les decouvrir empiriquement.


<< Précédent : Foundry Testing | Retour au sommaire | Suivant : Formal Verification >>

Retour au sommet