7 common smart contract fuzzing mistakes (and how to fix them)
7 common smart contract fuzzing mistakes (and how to fix them)
You set up a fuzzing campaign. It runs for 24 hours. Zero property violations. You ship to mainnet confident your code is solid. Three weeks later, an attacker drains $2M from a state transition your fuzzer never explored.
The fuzzer didn't fail. Your campaign did. Here are the seven mistakes we see most often, and how to fix each one.
<a href="/request-audit" class="cta-button">Get expert fuzzing for your protocol</a>
Mistake 1: Weak properties
This is the most common and most damaging mistake. You write properties that are technically correct but too weak to catch real bugs.
// Weak: this almost never fails
function invariant_totalSupplyNotZero() public view returns (bool) {
return vault.totalSupply() >= 0; // uint256 is always >= 0
}
// Weak: true by construction
function invariant_ownerIsSet() public view returns (bool) {
return vault.owner() != address(0);
}
These properties pass no matter what. They don't encode any meaningful security assumption. A fuzzer could run for a year and learn nothing from them.
The fix: Properties should encode things that could break. Think about what an attacker would want to violate. If your protocol is a vault, the key property is solvency — the contract holds enough assets to cover all shares.
// Strong: encodes the core solvency invariant
function invariant_vaultSolvency() public view returns (bool) {
return token.balanceOf(address(vault)) >= vault.totalAssets();
}
// Strong: encodes deposit/withdraw symmetry
function invariant_noFreeShares() public view returns (bool) {
// Total minted shares should never exceed what deposits justify
if (vault.totalSupply() == 0) return vault.totalAssets() == 0;
return vault.totalAssets() > 0;
}
If you're not sure whether your properties are strong enough, ask yourself: "If this property were violated, would I consider it a bug?" If the answer is "probably not," the property is too weak. For inspiration on what strong properties look like, see our property design patterns for DeFi lending.
Mistake 2: Too-tight bounds on inputs
Developers often constrain fuzzer inputs to "realistic" ranges. This kills the fuzzer's ability to find edge cases.
// Too tight: only tests "normal" deposits
function handler_deposit(uint256 amount) public {
amount = bound(amount, 1e18, 100e18); // 1 to 100 tokens
vault.deposit(amount, address(this));
}
Real attackers don't use "normal" values. They deposit 1 wei. They deposit type(uint256).max. They deposit the exact amount that causes a rounding error at the boundary between two precision ranges.
The fix: Use the widest bounds that your contract's preconditions allow. If a function accepts a uint256, let the fuzzer explore the full range. Only constrain when the contract itself would revert, and even then, test the boundary.
// Better: full range the contract allows
function handler_deposit(uint256 amount) public {
uint256 maxDeposit = vault.maxDeposit(address(this));
if (maxDeposit == 0) return;
amount = bound(amount, 1, maxDeposit);
vault.deposit(amount, address(this));
}
The fuzzer's job is to find the inputs you didn't think of. Don't take that job away from it.
Mistake 3: Missing actors
Most DeFi bugs involve multiple users interacting. If your fuzzing campaign only uses a single msg.sender, you're missing an entire dimension of the state space.
// Only one actor: misses multi-user interactions
function handler_deposit(uint256 amount) public {
amount = bound(amount, 1, token.balanceOf(address(this)));
vault.deposit(amount, address(this));
}
This setup will never find bugs that require two depositors, a borrower and a liquidator, or an admin and a regular user acting in sequence.
The fix: Set up multiple actors and let the fuzzer choose between them. Chimera and Echidna both support multi-sender campaigns.
// Multi-actor setup
address[] internal actors;
constructor() {
actors.push(address(0x10000));
actors.push(address(0x20000));
actors.push(address(0x30000));
}
function handler_deposit(uint256 actorSeed, uint256 amount) public {
address actor = actors[actorSeed % actors.length];
amount = bound(amount, 1, token.balanceOf(actor));
if (amount == 0) return;
vm.prank(actor);
vault.deposit(amount, actor);
}
At Recon, we typically configure 3-5 actors for each role in the protocol (depositors, borrowers, liquidators, admins). The more realistic your actor setup, the more realistic the bugs the fuzzer finds.
Mistake 4: No state setup
Starting every fuzzing run from a completely empty state means the fuzzer has to rediscover basic setup sequences every time. Worse, some bugs only appear when the protocol is already in a specific state: high utilization, near-liquidation, paused and unpaused.
// Empty state: fuzzer wastes time bootstrapping
constructor() {
token = new MockERC20();
vault = new Vault(address(token));
// ... and that's it
}
The fix: Seed the fuzzer with realistic initial state. Pre-fund actors, make initial deposits, set non-trivial oracle prices, move time forward.
constructor() {
token = new MockERC20();
vault = new Vault(address(token));
// Pre-fund actors
for (uint256 i = 0; i < actors.length; i++) {
token.mint(actors[i], 1_000_000e18);
vm.prank(actors[i]);
token.approve(address(vault), type(uint256).max);
}
// Seed with initial deposits so the vault isn't empty
vm.prank(actors[0]);
vault.deposit(100_000e18, actors[0]);
vm.prank(actors[1]);
vault.deposit(50_000e18, actors[1]);
}
Think of it this way: you're giving the fuzzer a head start so it can spend its compute budget exploring interesting states instead of setting up basic conditions.
Mistake 5: Ignoring coverage
You ran Medusa for 12 hours and it found zero violations. Great news? Maybe. Or maybe the fuzzer never reached 60% of your code.
Most fuzzers report coverage metrics. If you're not reading them, you're flying blind.
The fix: Check coverage after every campaign. Both Echidna and Medusa produce coverage reports.
# Medusa: check coverage in the corpus directory
ls corpus-medusa/coverage/
# Echidna: coverage is reported in the output
echidna . --contract CryticTester --corpus-dir corpus-echidna
# Look for "Coverage: X/Y unique reverts, Z/W unique lines"
If coverage is low, your handlers aren't reaching deep enough into the protocol. Common causes:
- Missing handler functions. If the fuzzer can't call
liquidate(), it can never reach post-liquidation states. - Precondition reverts. If 90% of generated calls revert because inputs are out of range, the fuzzer barely makes progress. Tighten your
bound()calls to match actual preconditions. - Missing state transitions. If the fuzzer can't advance time or change oracle prices, entire code paths stay unreachable.
Add handlers for every external function you want tested. Add time-warp and price-manipulation handlers for protocols that depend on those. Then check coverage again.
Mistake 6: Wrong tool for the job
Foundry's built-in fuzzer is fast and convenient. It's also stateless by default and doesn't persist a corpus. Running Foundry alone on a complex DeFi protocol with multi-step state transitions is like searching for a needle in a haystack with a magnet that resets every 10 seconds.
Conversely, running Echidna on a pure math library where stateless fuzzing would suffice is overkill, slower setup for no additional bug-finding power.
The fix: Match the tool to the problem.
| Problem | Right tool |
|---|---|
| Pure function edge cases | Foundry stateless fuzz |
| Mathematical proofs | Halmos |
| Multi-step state transitions | Echidna or Medusa |
| Fast CI/CD smoke tests | Foundry |
| Deep overnight campaigns | Echidna (directed) or Medusa (parallel) |
| Large codebase, fast coverage | Medusa |
For a detailed comparison, see our fuzzing tools comparison. Or use Chimera to write properties once and run them everywhere. Our Echidna vs Medusa comparison covers when each shines.
Mistake 7: Not running long enough
The most invisible mistake. You set testLimit: 50000 because the campaign takes 30 minutes at that setting, and you want results before lunch. But the bug is 200,000 tests deep, a six-call sequence that requires the fuzzer to discover each step incrementally.
# echidna.yaml
testLimit: 50000 # Finds shallow bugs
seqLen: 50 # Short sequences
# vs
testLimit: 5000000 # Finds deep bugs
seqLen: 200 # Long sequences
We've seen critical bugs that only surface after millions of tests. The fuzzer needs time to build up its corpus, discover interesting state transitions, and chain them together.
The fix: Run tiered campaigns.
- Development: Foundry, 10,000 runs, under 2 minutes. Catches obvious regressions.
- Pre-merge: Medusa, 500,000 tests, 30 minutes. Catches most stateful bugs.
- Pre-audit/pre-deploy: Echidna + Medusa, 5,000,000+ tests, 4-24 hours. Catches deep sequence bugs.
- Cloud: Recon Pro for 50M+ test campaigns with high parallelism.
You can automate these tiers in CI/CD. We wrote a full guide on setting up continuous security testing.
The meta-mistake: treating fuzzing as a checkbox
All seven mistakes above share a root cause: treating fuzzing as something you do once and forget about. Real fuzzing is iterative. You run a campaign, check coverage, improve properties, add handlers, run again. Each round makes the next one more effective.
The teams that get the most out of fuzzing treat their invariant test suite as a living artifact — updated with every feature, refined with every campaign. They don't ask "did we fuzz?" They ask "are our properties strong enough to catch the next bug?"
If you want help building that kind of test suite, or want a team that's run thousands of fuzzing campaigns to check your properties, request an audit with Recon. We'll make sure your fuzzer is actually finding what it should.
Related Posts
What is smart contract fuzzing?
Smart contract fuzzing throws millions of random inputs at your contracts to find states that violat...
Halmos symbolic execution for smart contracts: setup, limitations, and when it beats fuzzing
Fuzzers sample randomly. Symbolic execution explores every path. Halmos brings symbolic execution to...