Echidna is the open-source property-based fuzzer by Trail of Bits that tests smart contracts by exploring thousands of input combinations to find violations of your invariants. Unlike static analysis, which reads code patterns, Echidna executes your contract repeatedly with randomized and evolved inputs, discovering edge cases and business logic flaws that static tools cannot detect.
If Slither finds "this function has no access control," Echidna finds "when users call these functions in this sequence, the protocol invariant breaks." This makes Echidna invaluable for testing DeFi protocols, AMMs, lending markets, and vaults where the real risk lies in broken assumptions, not simple coding errors.
This guide covers installation, writing your first invariant, testing vulnerable ERC-20 and vault contracts, understanding Echidna's output, and comparing it with Foundry's fuzzing capabilities.
What Echidna Is
Echidna is a property-based fuzzer designed specifically for Solidity. It doesn't look for crashes — it looks for property violations.
Key concepts:
- Property/Invariant: A boolean condition that should always be true. Example: "the sum of all user balances equals total supply."
- Fuzzing: Automatically generating and executing randomized transactions to find inputs that break your properties.
- Stateful testing: Echidna can call multiple functions in sequence, evolving transaction sequences based on what it learns.
- Counterexample: When Echidna finds a property violation, it reports the exact sequence of function calls that triggered it.
What Echidna excels at:
- Protocol invariants (AMM price curves, vault exchange rates, token accounting)
- Reentrancy and callback logic under adversarial sequences
- Access control boundaries under stress
- Integer math properties (no accidental price inversions, shares never exceed assets)
What Echidna doesn't do:
- Check for simple coding errors (use Slither for that)
- Verify cryptographic proofs
- Test off-chain logic or oracle integrations
- Detect front-running or MEV patterns
Installation
Echidna is written in Haskell and distributed as a prebuilt binary. The easiest approach is Docker.
Option 1: Docker (Recommended)
Docker ensures consistency across environments and avoids Haskell dependency hassles.
docker pull trailofbits/echidna:latest
# Run Echidna on a contract
docker run -it -v $(pwd):/src trailofbits/echidna /src/contracts/MyVault.sol
Option 2: Direct Binary (macOS / Linux)
Download the prebuilt binary from the Echidna releases page:
# macOS (Intel)
wget https://github.com/crytic/echidna/releases/download/v2.2.7/echidna-2.2.7-macos.tar.gz
tar xzf echidna-2.2.7-macos.tar.gz
sudo mv echidna /usr/local/bin/
# Linux x86
wget https://github.com/crytic/echidna/releases/download/v2.2.7/echidna-2.2.7-ubuntu-20.04.tar.gz
tar xzf echidna-2.2.7-ubuntu-20.04.tar.gz
sudo mv echidna /usr/local/bin/
# Verify installation
echidna --version
Option 3: Build from Source (Cargo)
Requires Rust and Cargo. Slower but gives you the latest development version:
git clone https://github.com/crytic/echidna.git
cd echidna
cargo build --release
./target/release/echidna --version
Writing Your First Invariant
Echidna invariants are Solidity functions that return bool. Echidna calls your contract repeatedly and checks that every invariant function always returns true. If an invariant ever returns false, Echidna has found a violation.
Invariant Function Pattern
// Invariant functions follow this pattern:
// - Name starts with "echidna_"
// - Take NO parameters
// - Return bool
// - Always return true (violations return false)
contract MyContract {
uint256 public balance;
// This is an invariant
function echidna_balance_never_negative() public view returns (bool) {
return balance >= 0;
}
}
Echidna calls echidna_balance_never_negative() after every transaction. If it ever returns false, Echidna stops and reports a violation.
Basic Example: Simple Token
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract SimpleToken {
mapping(address => uint256) public balances;
uint256 public totalSupply;
constructor(uint256 initialSupply) {
balances[msg.sender] = initialSupply;
totalSupply = initialSupply;
}
function transfer(address to, uint256 amount) public {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
function mint(uint256 amount) public {
balances[msg.sender] += amount;
totalSupply += amount;
}
// Invariant: total supply should never exceed max
function echidna_total_supply_bounded() public view returns (bool) {
return totalSupply <= type(uint256).max;
}
// Invariant: no account balance should exceed total supply
function echidna_no_balance_exceeds_supply() public view returns (bool) {
return balances[msg.sender] <= totalSupply;
}
}
When you run Echidna on this contract, it will randomly call transfer() and mint() with various amounts and addresses, checking that the invariants always hold.
Testing a Vulnerable ERC-20
Let's write a realistic example: an ERC-20 token with a hidden bug in how it tracks total supply.
Vulnerable Contract
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract VulnerableToken is ERC20 {
constructor() ERC20("BadToken", "BAD") {
_mint(msg.sender, 1000e18);
}
// VULNERABLE: allows burning without updating total supply properly
function burn(uint256 amount) public {
balanceOf[msg.sender] -= amount;
// BUG: forgot to decrement totalSupply!
// totalSupply -= amount;
}
// Invariant: sum of all balances should equal totalSupply
function echidna_total_supply_equals_sum_of_balances() public view returns (bool) {
// In a real test, this would iterate over all possible accounts
// For this example, we simulate two key accounts
return balanceOf[msg.sender] + balanceOf[address(this)] <= totalSupply;
}
}
When Echidna runs this, it will call burn(), then check the invariant. It will discover that burning breaks the invariant because the balance decreased but total supply didn't.
Echidna output (when it finds the bug):
Invariant "echidna_total_supply_equals_sum_of_balances" violated!
Counterexample sequence:
1. burn(500000000000000000) [called by: 0x0000...]
Property violation found.
Testing a DeFi Vault: ERC-4626
ERC-4626 vaults are complex — shares represent claims on assets, and the exchange rate must be protected. Let's write invariants for a vault:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC4626/ERC4626.sol";
contract VaultForTesting is ERC4626 {
constructor(IERC20 asset)
ERC4626(asset)
ERC20("Vault", "vTKN")
{}
// Invariant 1: shares should never exceed total supply
function echidna_shares_never_exceed_total_supply() public view returns (bool) {
return totalSupply() >= 0; // Always true, but demonstrates the pattern
}
// Invariant 2: price per share should never decrease over time (for yield-bearing vaults)
// Note: this requires state tracking across multiple Echidna runs
uint256 private lastPricePerShare = 1e18;
function echidna_price_per_share_never_decreases() public returns (bool) {
uint256 assets = totalAssets();
uint256 supply = totalSupply();
if (supply == 0) {
lastPricePerShare = 1e18;
return true;
}
uint256 currentPrice = (assets * 1e18) / supply;
bool result = currentPrice >= lastPricePerShare;
lastPricePerShare = currentPrice;
return result;
}
// Invariant 3: actual assets >= expected assets (vault can't over-promise)
function echidna_assets_backed() public view returns (bool) {
return asset.balanceOf(address(this)) >= totalAssets();
}
// Invariant 4: redeeming shares should always return non-zero assets (unless shares are zero)
function echidna_redeem_returns_assets() public view returns (bool) {
uint256 assets = totalAssets();
uint256 supply = totalSupply();
return supply == 0 || assets > 0;
}
}
These invariants protect against:
1. Share minting bugs that exceed actual backing
2. Oracle price reversals that shouldn't happen
3. Asset miscounting
4. Redemption dead-ends where shares exist but can't be redeemed
Echidna Configuration
Echidna's behavior is controlled by .echidna.yaml in your project root.
Basic Configuration
# Number of fuzzing transactions to generate per property
testLimit: 10000
# Maximum sequence length (number of function calls per test)
seqLen: 30
# Fuzzing mode: "normal" (stateful) or "assertion" (transaction-level assertions)
testMode: "normal"
# Corpus directory for reproducible fuzzing
corpus: ".echidna-corpus"
# Seed for reproducibility (set to number for reproducible runs)
seed: 12345
# Only fuzz these functions (optional)
filterFunctions:
- "transfer"
- "approve"
- "burn"
# Contract to test (if multiple contracts in file)
contractName: "VaultForTesting"
# Solc version
solcVersion: "0.8.20"
# Gas limit per transaction
txGasLimit: 4000000
# Time limit in seconds
timeout: 30
# Verbosity: "normal", "verbose", or "quiet"
verbosity: "normal"
Advanced Configuration for DeFi
# For testing protocols with state evolution
testLimit: 50000
seqLen: 100
# Keep generated test cases that violate properties
corpus: ".echidna-corpus"
# Only test state-changing functions
filterFunctions:
- "deposit"
- "withdraw"
- "transfer"
# Test all invariants
allProperties: true
# Increase mutation pressure
seed: null # Random seed each run
# For protocols where transaction ordering matters
testMode: "normal"
# Don't simplify counterexamples (useful for understanding transaction sequences)
simplifyFailingTests: false
Reading Echidna Output
Successful Run (No Violations)
Echidna completed. No violations found.
All properties satisfied (5):
* echidna_total_supply_equals_sum_of_balances
* echidna_no_overflow_in_transfers
* echidna_price_per_share_monotonic
* echidna_shares_backed_by_assets
* echidna_cannot_mint_arbitrarily
Transactions executed: 10000
Unique sequences explored: 5432
Max sequence length: 30
Property Violation
Invariant "echidna_price_per_share_monotonic" violated!
Counterexample (minimized):
1. deposit(address(0x1234...), 1000000000000000000)
2. approve(address(0x5678...), 500000000000000000)
3. transfer(address(0xabcd...), 500000000000000000)
Property: echidna_price_per_share_monotonic
Result: false at transaction #3
Final state:
totalAssets: 1000000000000000000
totalSupply: 1500000000000000000
pricePerShare: 0.666...
Analysis: Price per share decreased from 1.0 to 0.666 after transfer
Key elements to understand:
- Counterexample: The exact sequence that breaks the property
- Minimized: Echidna simplifies it to the shortest sequence that reproduces the bug
- Final state: Contract values when the property failed
- Transaction number: Which call in the sequence caused the violation
Corpus Files
Echidna saves interesting test cases to .echidna-corpus/ for regression testing. These files contain transaction sequences that reproduce specific behavior.
Comparing Echidna with Foundry Fuzzing
Both Echidna and Foundry have fuzzing capabilities. Here's how they differ:
| Feature | Echidna | Foundry |
|---|---|---|
| Fuzzing Type | Stateful property-based | Fuzz testing (assertion-based) |
| Sequence Length | Up to 100+ transactions | Single transaction per test |
| Setup | Complex (requires invariant functions) | Simple (uses existing fuzz tests) |
| Protocol Testing | Excellent for invariants | Good for unit-level fuzzing |
| CI/CD Integration | Manual invocation, Docker-friendly | Native Forge integration |
| Counterexample Quality | Highly minimized sequences | Transaction-level traces |
| Configuration | YAML-based, detailed tuning | Command-line flags or config |
| Performance | Slower (stateful execution) | Faster (single-transaction) |
| Output Clarity | Property-focused | Test assertion-focused |
When to Use Each
Use Echidna when:
- Testing DeFi protocols where transaction sequences matter
- Validating invariants (e.g., "total supply always equals sum of balances")
- You need to find edge cases in state evolution
- Testing vaults, AMMs, lending markets, or complex token mechanics
Use Foundry when:
- Testing individual functions or views
- Running in CI/CD pipelines (simpler integration)
- Testing contracts that don't depend on complex state sequences
- You want fast feedback in development
- Testing contracts with external dependencies that are hard to simulate
Best practice: Use both. Static analysis (Slither) catches code smells, Foundry tests catch simple bugs quickly, and Echidna finds protocol-level invariant violations.
Running Echidna in Practice
Step 1: Create .echidna.yaml
In your project root:
testLimit: 10000
seqLen: 30
testMode: "normal"
contractName: "VaultForTesting"
solcVersion: "0.8.20"
Step 2: Add Invariants to Your Contract
Add echidna_* functions to your contract or a testing harness:
contract VaultHarness is VaultForTesting {
function echidna_invariant_1() public view returns (bool) {
return totalSupply() > 0 || totalAssets() == 0;
}
}
Step 3: Run Echidna
# Using Docker
docker run -it -v $(pwd):/src trailofbits/echidna /src/contracts/VaultHarness.sol
# Using local binary
echidna contracts/VaultHarness.sol
Step 4: Review Results
- No violations: You passed! Properties hold under fuzzing.
- Violations found: Examine the counterexample sequence and fix the contract.
ContractScan Coverage: What Echidna Finds
Echidna complements static analysis and AI-powered scanning. Here's what each tool catches:
| Vulnerability Class | Echidna | Static Analysis (Slither) | AI Analysis |
|---|---|---|---|
| Protocol invariant violations | Yes | No | Sometimes |
| Reentrancy in complex sequences | Yes | Partial | Yes |
| Token accounting bugs | Yes | No | Yes |
| Vault share inflation/rounding | Yes | No | Yes |
| Price oracle attacks | No | No | Yes |
| Access control misconfiguration | Partial | Yes | Yes |
| Integer overflow/underflow | No | Yes | Yes |
| Uninitialized storage | No | Yes | Yes |
| Missing transfer verification | No | Yes | Yes |
| Business logic flaws | Yes | No | Yes |
Summary:
- Echidna: Finds bugs in how functions interact (stateful behavior)
- Slither: Finds bugs in code structure (static patterns)
- AI Analysis: Finds context-aware logical flaws and novel attack vectors
For maximum coverage, run all three. ContractScan integrates these tools to give you defense in depth.
Common Pitfalls and Solutions
Echidna runs forever: Infinite loops or unbounded state growth. Add bounds or use filterFunctions in .echidna.yaml to skip problematic functions.
Invariant always passes: Invariant too weak or trivial. Test actual properties: echidna_total_supply_consistent() should verify real relationships.
Can't generate counterexamples: Contract needs external state (oracles, timestamps). Mock dependencies in a test harness contract.
Next Steps
Echidna is a powerful tool, but it requires careful invariant design. Start with simple contracts and basic invariants, then scale to protocol-level testing.
Resources:
- Echidna Documentation
- Property-Based Testing Guide
- Smart Contract Security Testing
Try it now: Use ContractScan to analyze your contracts with Echidna, Slither, and AI-powered detection. Get instant feedback on protocol invariants, token accounting, and vault security.
Related Reading
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.