# Echidna — Property-Based Fuzzing for Smart Contracts
Echidna is a property-based fuzzer for Solidity. It generates random inputs to find edge cases that violate your contract's invariants. Unlike unit tests that check specific scenarios, Echidna explores thousands of input combinations automatically.
Why Echidna?
Traditional testing:
function testTransfer() public {
token.transfer(alice, 100);
assertEq(token.balanceOf(alice), 100);
}
This tests one specific case. What about:
- Transferring 0 tokens?
- Transferring more than balance?
- Transferring to address(0)?
- Integer overflow scenarios?
Echidna tests all of these automatically.
Installation
docker pull trailofbits/eth-security-toolbox
docker run -it -v $PWD:/src trailofbits/eth-security-toolbox
# Or install directly
brew install echidna
Basic Example: ERC20 Token
// Token.sol
contract Token {
mapping(address => uint256) public balances;
uint256 public totalSupply;
function mint(address to, uint256 amount) external {
balances[to] += amount;
totalSupply += amount;
}
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
Echidna Test Contract
// TestToken.sol
contract TestToken is Token {
// Invariant: total supply equals sum of all balances
function echidna_supply_matches_balances() public view returns (bool) {
// In practice, you'd track all addresses
// This is simplified for demonstration
return totalSupply >= 0;
}
// Invariant: balances never go negative (uint overflow check)
function echidna_no_negative_balance() public view returns (bool) {
return balances[msg.sender] >= 0;
}
// Invariant: transfer doesn't increase total supply
function echidna_transfer_preserves_supply() public returns (bool) {
uint256 supplyBefore = totalSupply;
// Echidna will call this with random amounts/addresses
if (balances[msg.sender] > 0) {
transfer(address(0x1234), 1);
}
return totalSupply == supplyBefore;
}
}
Configuration
# echidna.yaml
testMode: assertion
testLimit: 10000
deployer: "0x30000"
sender: ["0x10000", "0x20000", "0x30000"]
Run Echidna
echidna-test TestToken.sol --contract TestToken --config echidna.yaml
Output:
echidna_supply_matches_balances: passed! (10000 tests)
echidna_no_negative_balance: passed! (10000 tests)
echidna_transfer_preserves_supply: passed! (10000 tests)
Real-World Example: Lending Protocol
contract LendingPool {
mapping(address => uint256) public deposits;
mapping(address => uint256) public borrows;
uint256 public totalDeposits;
uint256 public totalBorrows;
function deposit() external payable {
deposits[msg.sender] += msg.value;
totalDeposits += msg.value;
}
function borrow(uint256 amount) external {
require(deposits[msg.sender] * 2 >= amount, "Undercollateralized");
borrows[msg.sender] += amount;
totalBorrows += amount;
payable(msg.sender).transfer(amount);
}
function repay() external payable {
uint256 owed = borrows[msg.sender];
require(msg.value <= owed, "Overpayment");
borrows[msg.sender] -= msg.value;
totalBorrows -= msg.value;
}
}
Echidna Properties
contract TestLendingPool is LendingPool {
// Invariant: pool always has enough funds to cover deposits
function echidna_solvency() public view returns (bool) {
return address(this).balance >= totalDeposits - totalBorrows;
}
// Invariant: no user borrows more than 2x their collateral
function echidna_collateralization() public view returns (bool) {
return borrows[msg.sender] <= deposits[msg.sender] * 2;
}
// Invariant: total borrows never exceed total deposits
function echidna_borrow_limit() public view returns (bool) {
return totalBorrows <= totalDeposits;
}
// Invariant: accounting is consistent
function echidna_accounting() public view returns (bool) {
return totalDeposits >= totalBorrows;
}
}
Bug Discovery
If Echidna finds a violation:
echidna_solvency: failed!
Call sequence:
deposit() (value: 1000000000000000000)
borrow(1500000000000000000)
repay() (value: 500000000000000000)
borrow(1000000000000000000)
This shows the exact sequence that breaks the invariant.
Advanced Features
1. Stateful Testing
contract TestStateful is Token {
address[] public users;
constructor() {
users.push(address(0x10000));
users.push(address(0x20000));
users.push(address(0x30000));
}
function echidna_total_supply_matches() public view returns (bool) {
uint256 sum = 0;
for (uint256 i = 0; i < users.length; i++) {
sum += balances[users[i]];
}
return sum == totalSupply;
}
}
2. Assertion Mode
contract TestAssertions is Token {
function testTransferInvariant(address to, uint256 amount) public {
uint256 balanceBefore = balances[msg.sender];
if (balanceBefore >= amount) {
transfer(to, amount);
assert(balances[msg.sender] == balanceBefore - amount);
}
}
}
3. Optimization Mode
Find maximum values:
contract TestOptimization is Token {
function echidna_max_supply() public view returns (bool) {
// Echidna tries to maximize totalSupply
return totalSupply < 1000000 ether;
}
}
Run with:
echidna-test TestToken.sol --test-mode optimization
4. Custom Events
event AssertionFailed(string reason, uint256 value);
function echidna_custom_check() public returns (bool) {
if (totalSupply > 1000 ether) {
emit AssertionFailed("Supply too high", totalSupply);
return false;
}
return true;
}
Configuration Options
# echidna.yaml
testMode: assertion # or property, optimization
testLimit: 50000 # number of tests
seqLen: 100 # max sequence length
contractAddr: "0x00a329c0648769A73afAc7F9381E08FB43dBEA72"
deployer: "0x30000"
sender: ["0x10000", "0x20000"]
balanceAddr: 0xffffffff # initial balance
codeSize: 0x6000 # max code size
gasLimit: 12500000
timeDelay: 604800 # simulate time passing
Best Practices
1. Start with Simple Invariants
// Good: clear, testable
function echidna_balance_nonnegative() public view returns (bool) {
return balances[msg.sender] >= 0;
}
// Bad: too complex, hard to debug
function echidna_complex_check() public view returns (bool) {
return (totalSupply * 2 + balances[address(this)]) /
(borrows[msg.sender] + 1) <= maxValue;
}
2. Use Descriptive Names
// Good
function echidna_no_unauthorized_withdrawal() public view returns (bool)
// Bad
function echidna_test1() public view returns (bool)
3. Combine with Unit Tests
# Run unit tests first
forge test
# Then fuzz with Echidna
echidna-test . --contract TestContract
4. Gradual Complexity
Start with:
Common Patterns
Reentrancy Protection
contract TestReentrancy is VulnerableContract {
bool private entered;
function echidna_no_reentrancy() public returns (bool) {
require(!entered, "Reentrancy detected");
entered = true;
// Test the vulnerable function
if (address(this).balance > 0) {
withdraw(1 ether);
}
entered = false;
return true;
}
}
Integer Overflow
function echidna_no_overflow() public returns (bool) {
uint256 a = totalSupply;
mint(address(this), 100);
return totalSupply > a; // Should always increase
}
Integration with CI/CD
# .github/workflows/echidna.yml
name: Echidna Fuzzing
on: [push]
jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run Echidna
run: |
docker run -v $PWD:/src trailofbits/eth-security-toolbox echidna-test /src --contract TestContract --config /src/echidna.yaml
Key Takeaways
echidna_* functions returning boolEchidna is essential for production smart contracts. It's caught critical bugs in major protocols like Compound, Uniswap, and Aave.