Skip to content
Quantix Security Labs
Primer

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.

Illustration: a field of bars showing coverage across the input space, the fuzzer’s path across it and one input that crashes the contract.
Contents
  1. Why random inputs find real bugs
  2. Stateless, stateful and coverage-guided
  3. Inside the fuzzing loop
  4. Writing a harness
  5. Giving the fuzzer good inputs
  6. Choosing what to check
  7. Did the fuzzer actually explore?
  8. Fuzzing in a development workflow
  9. Conclusions
  10. Recommendations
  11. Bibliography

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

KindWhat variesFindsExamples
Stateless (property-based)Arguments of a single callArithmetic and input-validation errors in one functionFoundry fuzz tests
Stateful (invariant)Sequences of calls, senders, timeBugs that need a particular history of stateFoundry invariant tests, Echidna, Medusa
Coverage-guidedInputs kept or discarded by the code they reachDeep paths that random sequences rarely hitEchidna, Medusa, Harvey (research)
DifferentialSame inputs to two implementationsDivergence from a reference or earlier versionAny fuzzer with a comparison property
Kinds of fuzzing used on smart contracts. Tools named are examples documented in the bibliography.

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 loop inside a coverage-guided stateful fuzzerThe fuzzer selects a call sequence from its corpus and mutates it by reordering calls, changing arguments, senders and time. It executes the sequence against the contracts and checks invariants and coverage. Sequences that reach new coverage are added back to the corpus. When an invariant is violated, the sequence is shrunk to a minimal failing case and reported.Corpuscall sequences thatreached new codeSelect and mutatereorder or splice callschange arguments, senderadvance time and blocksExecuterun the sequenceagainst the contractsObservenew branches covered?any invariant false?Shrink and reportminimal call sequencethat breaks the propertyviolatednew coverage: keep the sequence
A coverage-guided stateful fuzzer keeps sequences that reach new code and shrinks sequences that break a property.

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.

VaultInvariants.t.sol (illustrative, Foundry)solidity
// 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:

VaultEchidna.sol (illustrative, Echidna)solidity
// 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 try and 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

  1. Write a handler per user-facing action and an invariant per promise to users; start with a small set of actors.
  2. Bound inputs and seed interesting constants, then review every bound for cases it excludes.
  3. Run short fuzzing in continuous integration and keep the corpus between runs.

Reviewers

  1. Read coverage reports before trusting a clean run, and plant a known bug to test the harness.
  2. Turn every counterexample into a named regression test.

Bibliography

Primary sources: standards, official documentation, incident reports and papers.

  1. 1Echidna: a smart contract fuzzer(opens in a new tab)Trail of Bits (crytic), GitHub
  2. 2Medusa: parallelised, coverage-guided fuzzing(opens in a new tab)Trail of Bits (crytic), GitHub
  3. 3Invariant testing(opens in a new tab)Foundry documentation
  4. 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.

Want this question asked of your code?

Quantix reviewers apply the same reasoning to production systems. Share what you are building and when it ships.