Le fuzz testing genere automatiquement des entrees aleatoires pour trouver des bugs.
# Concept de fuzz testingprint("""FUZZ TESTING - Concepts cles1. GENERATION ALEATOIRE - Foundry genere des valeurs aleatoires pour chaque parametre - Types supportes: uint, int, address, bytes, string, arrays2. CAS D'USAGE - Trouver des edge cases non couverts par les tests unitaires - Tester des bornes (overflow, underflow) - Verifier des invariants mathematiques3. CONFIGURATION - runs: nombre d'executions (defaut: 256) - depth: profondeur des appels internes - seed: pour reproduire un bug specifique4. 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 basiqueFUZZ_BASIC ='''// SPDX-License-Identifier: MITpragma 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: MITpragma 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-20INVARIANT_TEST ='''// SPDX-License-Identifier: MITpragma 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 :
Verifier que safeMod(a, b) retourne toujours un résultat strictement inferieur a b
Verifier que safeMod(a, 1) == 0 pour tout a
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 revertMODULO_FUZZ_TEST ='''// Contrat a testercontract 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.assumeASSUME_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 tokenimport randomdef 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 transfertTODO 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_transfersreturn []# Test apres implementationrandom.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 ifnot 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.tomlFOUNDRY_CONFIG ='''[fuzz]runs = 1000 # Nombre d'executions par testmax_test_rejects = 1000 # Max rejets avant abandonseed = '0x1234' # Seed pour reproductibilitedictionary_weight = 40 # Poids du dictionnaire[invariant]runs = 256 # Runs par invariantdepth = 15 # Profondeur des appelsfail_on_revert = true # Echouer sur revert'''# CommandesCOMMANDS ='''# Executer les fuzz testsforge test --fuzz-runs 5000# Executer uniquement les tests fuzzforge test --match-path "test/*Fuzz*"# Avec un seed specifiqueforge test --fuzz-seed 0x3e8e# Debug modeforge 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 testercontract 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 testcontract 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 defileEXERCISE_QUEUE ='''// Contrat a testercontract 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 testcontract 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")
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 unitairetest_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, globdef find_forge(): exe = shutil.which("forge")if exe:return exe cand = os.path.expanduser("~/.foundry/bin/forge")return cand if os.path.exists(cand) elseNonedef 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 _ inrange(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)returnNoneforge = find_forge()fstd = find_forge_std(os.getcwd())ifnot forge ornot fstd:print("Foundry/forge-std introuvable - installez Foundry (cf SC-12) puis relancez.")print("forge =", forge, "| forge-std =", fstd)else: SRC ='''// SPDX-License-Identifier: MITpragma 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: MITpragma 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"))withopen(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")withopen(os.path.join(work, "remappings.txt"), "w") as f: f.write("forge-std/=lib/forge-std/src/\n")withopen(os.path.join(work, "src", "Average.sol"), "w") as f: f.write(SRC)withopen(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()ifnot s:continueif s.startswith(("Compiling", "Compiler run", "Solc ", "Installing", "Downloading", "Tip:")):continueprint(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.