Outils·8 min de lecture·Par Solingo

Echidna — Property-Based Fuzzing for Smart Contracts

Use Echidna to automatically find edge cases and invariant violations in your Solidity code.

# 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:

  • Basic arithmetic invariants
  • Access control checks
  • State consistency rules
  • Complex business logic
  • 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 finds edge cases unit tests miss
  • Write invariants as echidna_* functions returning bool
  • Start with simple properties, add complexity gradually
  • Use stateful testing for complex protocols
  • Integrate into CI for continuous fuzzing
  • Combine with Foundry/Hardhat for comprehensive testing
  • Echidna is essential for production smart contracts. It's caught critical bugs in major protocols like Compound, Uniswap, and Aave.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement