Smart contract fuzzing techniques
Coverage-guided and stateful fuzzers explore sequences of calls no reviewer would think to write. How they work, how to give them good inputs, and how to tell whether a green run means anything.
- Written by
- Quantix Research, Formal Methods
- Published
- Reading time
- 6 minutes
- Category
- Formal Verification
Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Contents
A reviewer reading code imagines how it might be called. A fuzzer does not imagine; it tries. It generates inputs and sequences of calls far faster and in far stranger combinations than any person would write, and checks after each one whether something that must be true has become false.
Fuzzing has become one of the most productive techniques for finding smart contract bugs because contracts have small, well-defined interfaces and strong correctness properties. This primer explains the main kinds of fuzzing, how a coverage-guided stateful fuzzer works, how to write a harness that gives it something meaningful to check, and how to tell whether a clean run means anything.
Why random inputs find real bugs
Many serious contract bugs occur only for particular values or orderings: a rounding error that matters only when a balance is very small, an accounting error that appears only after a deposit, a partial withdrawal and a fee change in that order, an overflow at the boundary of a type. Unit tests check the cases their authors thought of. Fuzzers check many cases nobody thought of, and the most effective ones learn from each run which inputs reach new behaviour.
The output of a fuzzer is only as meaningful as the property it checks. A fuzzer with no property finds crashes and reverts; a fuzzer with good invariants finds logic errors. The companion primer on writing invariants covers how to derive those properties.
Stateless, stateful and coverage-guided
| Kind | What varies | Finds | Examples |
|---|---|---|---|
| Stateless (property-based) | Arguments of a single call | Arithmetic and input-validation errors in one function | Foundry fuzz tests |
| Stateful (invariant) | Sequences of calls, senders, time | Bugs that need a particular history of state | Foundry invariant tests, Echidna, Medusa |
| Coverage-guided | Inputs kept or discarded by the code they reach | Deep paths that random sequences rarely hit | Echidna, Medusa, Harvey (research) |
| Differential | Same inputs to two implementations | Divergence from a reference or earlier version | Any fuzzer with a comparison property |
Most DeFi bugs need state: a vault with deposits from several users, a market with open positions, a pool after many swaps. Stateful fuzzing, which explores sequences of calls rather than single calls, is therefore where most of the value lies.
Inside the fuzzing loop
The fuzzer maintains a corpus of call sequences. On each iteration it selects one, mutates it by changing arguments, reordering or splicing calls, switching senders or advancing time, and executes the result. It records which code the execution reached. A sequence that reached something new is added to the corpus, so later iterations build on it. After each call, the fuzzer evaluates the properties; if one fails, it shrinks the failing sequence to the shortest version that still fails and reports it.
Wüstholz and Christakis describe in the Harvey paper how techniques such as predicting inputs that satisfy branch conditions and fuzzing sequences on demand help greybox fuzzers reach deeper contract states. Production tools apply similar ideas: dictionaries of constants extracted from the bytecode, values returned by previous calls fed back as arguments, and coverage feedback to decide what to keep.
Writing a harness
A harness sits between the fuzzer and the system under test. It defines which actions the fuzzer may take, constrains their inputs to meaningful ranges, keeps ghost state that records what the test believes should be true, and states the properties. The example uses Foundry’s invariant testing with a handler contract.
// Illustrative Foundry invariant test with a handler. Not production code.contract VaultHandler is Test { Vault internal immutable vault; TestToken internal immutable asset; // Ghost state: what the test believes each actor put in and took out. mapping(address => uint256) public deposited; mapping(address => uint256) public withdrawn; constructor(Vault v, TestToken a) { vault = v; asset = a; } function actor(uint256 i) public pure returns (address) { return address(uint160(0x1000 + i)); // three actors: collisions are likely } function deposit(uint256 actorSeed, uint256 assets) external { address who = actor(bound(actorSeed, 0, 2)); assets = bound(assets, 1, 1e24); // keep inputs in a meaningful range asset.mint(who, assets); vm.startPrank(who); asset.approve(address(vault), assets); vault.deposit(assets, who); vm.stopPrank(); deposited[who] += assets; } function redeemAll(uint256 actorSeed) external { address who = actor(bound(actorSeed, 0, 2)); uint256 shares = vault.balanceOf(who); if (shares == 0) return; vm.prank(who); withdrawn[who] += vault.redeem(shares, who, who); }} contract VaultInvariants is StdInvariant, Test { VaultHandler internal handler; function setUp() public { TestToken asset = new TestToken(); handler = new VaultHandler(new Vault(asset), asset); targetContract(address(handler)); // fuzz sequences of handler calls } /// With no yield source, nobody can take out more than they put in. function invariant_noActorProfits() public view { for (uint256 i; i < 3; i++) { address who = handler.actor(i); assertLe(handler.withdrawn(who), handler.deposited(who)); } }}Three choices make this harness effective. Actions are expressed as handler functions that always do something valid, so the fuzzer does not waste most calls on reverts. Inputs are bounded, so amounts are neither all zero nor all astronomically large. And a small set of actors makes it likely that the same address deposits, redeems and deposits again, which is where accounting bugs live. The invariant uses ghost state rather than the vault’s own view of itself, so a bug in the vault’s accounting cannot hide from the check.
Echidna and Medusa express properties as functions that return a boolean:
// Echidna: public functions prefixed echidna_ are properties that must always return true.// The fuzzer calls the harness's other public functions in random sequences between checks.contract VaultEchidna is VaultHarness { function echidna_supply_implies_assets() public view returns (bool) { return vault.totalSupply() == 0 || vault.totalAssets() > 0; }}Giving the fuzzer good inputs
Uniformly random 256-bit integers are almost always enormous, so a fuzzer left alone rarely tests a balance of 1 or an amount just above a threshold. Bounding inputs, seeding the corpus with realistic sequences, and including interesting constants such as 0, 1, type maxima, and the protocol’s own thresholds, steer exploration toward the boundaries where bugs cluster. Time and block number are inputs too: interest accrual, vesting, timelocks and oracle staleness only show their bugs when the fuzzer can advance them.
Choosing what to check
Invariants over state are the most powerful properties, but three other kinds of check catch bugs they miss.
- Liveness properties. Some failures are reverts that should not happen: a user who holds shares must always be able to redeem them, and a position that is unhealthy must always be liquidatable. A handler can call the action inside
tryand record a failure when it reverts for a reason other than the documented ones. Bugs of this kind lock funds rather than steal them, and pure state invariants rarely notice them. - Differential properties. When a reference exists, such as a simpler model of the accounting written in the test, or the previous version of an upgraded contract, the fuzzer can apply the same actions to both and compare results. Any divergence is either a bug or an undocumented change in behaviour.
- Transition properties. Assertions inside a handler check a single step: a deposit never lowers the price per share, a fee change never affects balances already settled. They pinpoint the call that broke something, where a state invariant may report the failure many calls later.
A good harness mixes all four. The state invariants describe what must always be true, and the others describe what must always be possible, what must agree with a reference, and what each step may change.
Did the fuzzer actually explore?
A fuzzing run that finds nothing is ambiguous: the code may be correct, or the fuzzer may never have reached the interesting states. Coverage reports, which Echidna and Medusa can produce, show which lines and branches were executed. Uncovered branches in critical functions mean the harness needs new actions, wider bounds or better seeds.
A second check is to introduce a deliberate bug, such as flipping a rounding direction or removing a fee, and confirm that the fuzzer catches it. If it does not, the properties or the harness are too weak to trust a clean run.
Fuzzing in a development workflow
Fuzzing works best as a habit rather than an event. Short runs on every change catch regressions; long runs before a release or an external review explore deeply. Keeping the corpus between runs lets each run start where the last one finished. Every failing sequence becomes a concrete regression test, so a bug, once found, stays found.
Conclusions
Fuzzing finds the bugs that live in combinations of values and orderings, which are the bugs reviewers and unit tests are worst at finding. Stateful, coverage-guided fuzzing against strong invariants is the most productive form for DeFi code.
Its results are only as meaningful as the harness: actions that reach real states, bounds that keep inputs interesting without excluding edge cases, ghost state independent of the code under test, and evidence from coverage and deliberate mutations that the fuzzer could have found a bug if one existed.
Recommendations
Developers
- Write a handler per user-facing action and an invariant per promise to users; start with a small set of actors.
- Bound inputs and seed interesting constants, then review every bound for cases it excludes.
- Run short fuzzing in continuous integration and keep the corpus between runs.
Reviewers
- Read coverage reports before trusting a clean run, and plant a known bug to test the harness.
- Turn every counterexample into a named regression test.
Bibliography
Primary sources: standards, official documentation, incident reports and papers.
- 1Echidna: a smart contract fuzzer(opens in a new tab)Trail of Bits (crytic), GitHub
- 2Medusa: parallelised, coverage-guided fuzzing(opens in a new tab)Trail of Bits (crytic), GitHub
- 3Invariant testing(opens in a new tab)Foundry documentation
- 4Harvey: A Greybox Fuzzer for Smart Contracts(opens in a new tab)Wüstholz and Christakis, arXiv:1905.06944
Applied on this site
Assessments, case studies and tools that apply this publication.






