# Echidna — Fuzzing Basé sur les Propriétés pour Smart Contracts
Les tests unitaires vérifient des scénarios connus. Le fuzzing teste l'inattendu. Echidna excelle à cela.
Qu'est-ce que le Fuzzing ?
Un fuzzer génère des entrées aléatoires pour trouver des inputs qui cassent vos invariants.
Exemple : vous avez un coffre-fort ERC-4626. Invariant :
> "La valeur totale des parts ne peut jamais dépasser la valeur des actifs"
Un fuzzer va tenter des milliers de séquences :
deposit(1 ether)
withdraw(0.5 ether)
deposit(10000 ether)
redeem(9999 shares)
// ... jusqu'à trouver une séquence qui casse l'invariant
Installation
# Via Docker (recommandé)
docker pull trailofbits/eth-security-toolbox
# Ou Homebrew
brew install echidna
Premier Invariant
Testons un contrat de staking simple :
// StakingPool.sol
contract StakingPool {
mapping(address => uint256) public balances;
uint256 public totalStaked;
function stake() external payable {
balances[msg.sender] += msg.value;
totalStaked += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
totalStaked -= amount;
payable(msg.sender).transfer(amount);
}
}
Invariant : totalStaked == address(this).balance
Test Echidna
// StakingPoolEchidna.sol
import "./StakingPool.sol";
contract StakingPoolEchidna is StakingPool {
// Propriété : préfixe "echidna_"
function echidna_balance_matches_total() public view returns (bool) {
return totalStaked == address(this).balance;
}
}
Configuration
# echidna.yaml
testMode: assertion
testLimit: 10000
deployer: "0x10000"
sender: ["0x10000", "0x20000", "0x30000"]
Exécution
echidna StakingPoolEchidna.sol --contract StakingPoolEchidna --config echidna.yaml
# Résultat :
echidna_balance_matches_total: failed!
Call sequence:
1. stake() (value: 1 ether)
2. withdraw(1 ether)
3. stake() (value: 2 ether)
→ totalStaked = 2 ether, balance = 2 ether ✓ (pas de bug ici)
Exemple : Bug de Reentrancy
// VulnerableVault.sol
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool success,) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0; // ❌ Après le transfer !
}
}
// Attacker.sol
contract Attacker {
VulnerableVault vault;
uint256 public attackCount;
constructor(VulnerableVault _vault) {
vault = _vault;
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
if (attackCount < 5 && address(vault).balance > 0) {
attackCount++;
vault.withdraw(); // Reentrancy !
}
}
}
Test Echidna pour Reentrancy
contract VaultEchidna {
VulnerableVault vault = new VulnerableVault();
function echidna_no_drain() public view returns (bool) {
// Le contrat ne doit jamais être complètement vidé
// si des utilisateurs ont encore des balances
uint256 balance = address(vault).balance;
uint256 userBalance = vault.balances(address(this));
return userBalance == 0 || balance >= userBalance;
}
function deposit() public payable {
vault.deposit{value: msg.value}();
}
function withdraw() public {
vault.withdraw();
}
receive() external payable {
// Tente un reentrancy
if (address(vault).balance > 0) {
try vault.withdraw() {} catch {}
}
}
}
echidna VulnerableVault.sol --contract VaultEchidna
# Résultat :
echidna_no_drain: failed!
Call sequence:
1. deposit() (value: 5 ether)
2. withdraw()
→ Reentrancy détecté ! Balance = 0, userBalance = 5 ether
Patterns Avancés
1. Invariants Multi-Contrats
contract DEXEchidna {
TokenA tokenA = new TokenA();
TokenB tokenB = new TokenB();
DEX dex = new DEX(tokenA, tokenB);
function echidna_constant_product() public view returns (bool) {
// Invariant x * y = k pour AMM
uint256 reserveA = tokenA.balanceOf(address(dex));
uint256 reserveB = tokenB.balanceOf(address(dex));
uint256 k = dex.K();
return reserveA * reserveB >= k;
}
}
2. Vérification d'Assertions
// Utilisez assert() dans votre code
function criticalOperation() internal {
uint256 before = totalSupply;
// ... logique
assert(totalSupply >= before); // Echidna va tenter de casser ceci
}
3. Corpus Optimization
Echidna garde les séquences intéressantes :
# echidna.yaml
corpusDir: "corpus"
coverage: true
Après un run, regardez corpus/ pour voir les transactions qui ont augmenté la couverture.
Fuzzing vs Tests Unitaires
| Aspect | Tests Unitaires | Echidna |
|--------|-----------------|---------|
| Scénarios | Connus | Aléatoires |
| Coverage | Partiel | Exhaustif |
| Maintenance | Élevée | Faible |
| Détecte l'inattendu | ❌ | ✅ |
Best practice : combinez les deux.
Limites
- Temps : 10K tests = ~5 min
- Complexité : difficile de tester des workflows multi-étapes très spécifiques
- Oracle problem : vous devez connaître vos invariants
Outils Complémentaires
- Foundry fuzz : intégré, plus simple mais moins puissant
- Medusa : fork d'Echidna avec UI
- Slither : analyse statique (combiné avec Echidna = combo gagnant)
Workflow Recommandé
# CI/CD
echidna . --contract MyContractEchidna --test-limit 50000 --format text
Ressources
Echidna a trouvé des bugs dans Compound, Balancer, et des dizaines d'autres protocoles majeurs. Ne le sautez pas.