Fuzzing is automated testing that feeds a program large numbers of generated inputs and watches for a failure. In smart contract security, the failure is a broken property: a rule the contract must always keep, such as “the total staked equals the sum of every user’s stake”. A fuzzer calls the contract with random arguments, from random callers, in random order, and reports the first input or call sequence that breaks the rule.
The technique started with a thunderstorm. In the autumn of 1988, Barton Miller was working from his apartment in Madison, logged in to his office’s Unix system over a 1200 baud dial-up line. Heavy rain put noise on the line. In his own account, the surprise was not the garbage characters. It was that they made everyday Unix utilities crash. He set the idea as a student project and called it “fuzz”. The 1990 paper that came out of it tested almost 90 utilities on seven versions of Unix and crashed more than 24% of them.
Miller needed no specification. A program that crashed or hung had failed. A smart contract almost never crashes. It does exactly what its code says, including when what its code says is wrong. So a contract fuzzer has to be told what wrong looks like, and writing that down is most of the security work.
Quick Answer
Fuzzing tests a smart contract by running it against thousands of generated inputs and call sequences, and reporting any that break a property the team wrote down. It is good at arithmetic edges, rounding, and accounting that drifts over many calls. A clean run does not prove the contract is safe: it covers only the properties written, the paths reached and the environment modelled.
Last updated October 2026.
On this page
- How smart contract fuzzing works
- The same bug, missed by one fuzz test and caught by another
- What fuzzing finds well
- What a passing fuzz run does not prove
- The fuzzers teams use
- Where a manual audit is still needed
- Frequently asked questions
How smart contract fuzzing works
Every campaign has four parts.
- A property. A statement that must hold: an assertion in a test, a function that returns true or false, or a comparison checked after every call. This is the oracle. Without it the fuzzer has nothing to fail.
- Input generation. The fuzzer builds arguments, picks senders and chooses which function to call next. Good fuzzers seed it with constants from the bytecode and values from storage.
- Coverage guidance. Inputs that reach new code are kept and mutated, so the campaign pushes deeper.
- Shrinking. When a property breaks, the fuzzer cuts the failing input down to the smallest version that still fails, so a person can read it.
Contracts add one thing most software fuzzing can ignore: state that persists between calls. Hence two kinds.
| Stateless fuzzing | Stateful fuzzing (invariant testing) | |
|---|---|---|
| One run is | One test function, called with random arguments | A random sequence of calls from random callers |
| What is checked | The test’s own assertions | Invariants, checked after every call |
| Good at | Maths: overflow edges, rounding, fee and price formulas | Bugs that need setup: accounting drift, state machines, call order |
| Forge 1.8.5 default | 256 runs per test | 256 runs of 500 calls each |
Foundry’s invariant testing guide describes the second kind in one sentence: Forge “runs random sequences of function calls and checks invariants after each call.” Outside crypto, the first kind is usually called property based testing.
The same bug, missed by one fuzz test and caught by another
Here is a small staking contract with one bug in it. It keeps accounts only, no tokens move, and it compiles under solc 0.8.37.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Illustrative accounting only: no tokens move.
contract StakePool {
mapping(address => uint256) public staked;
uint256 public totalStaked;
function stake(uint256 amount) external {
staked[msg.sender] += amount;
totalStaked += amount;
}
function unstake(uint256 amount) external {
staked[msg.sender] -= amount;
totalStaked -= amount;
}
function transferStake(address to, uint256 amount) external {
uint256 fromBal = staked[msg.sender];
uint256 toBal = staked[to];
require(fromBal >= amount, "insufficient stake");
staked[msg.sender] = fromBal - amount;
staked[to] = toBal + amount;
}
}
transferStake reads both balances before writing either. On a transfer to yourself, the second write overwrites the first and your stake grows by the amount “sent”. The total does not move.
This stateless fuzz test looks like it should catch that. It stakes a random amount for Alice, moves a random part of it to Bob, and checks that the two stakes still add up to the total.
function testFuzz_TransferKeepsTotal(uint256 deposit, uint256 amount) public {
deposit = bound(deposit, 1, 1e30);
amount = bound(amount, 0, deposit);
vm.prank(alice);
pool.stake(deposit);
vm.prank(alice);
pool.transferStake(bob, amount);
assertEq(pool.staked(alice) + pool.staked(bob), pool.totalStaked());
}
It passes, at Forge’s default of 256 runs and again at 10,000. The test hard-codes Bob as the receiver, so no number of runs will ever send Alice’s stake to Alice.
The stateful version hands those choices to the fuzzer. A handler offers all three functions across three accounts, and one invariant is checked after every call.
// In the handler: the fuzzer chooses sender, receiver and amount.
function transferStake(uint256 from, uint256 to, uint256 amount) external {
address a = actors[from % 3];
amount = bound(amount, 0, pool.staked(a));
vm.prank(a);
pool.transferStake(actors[to % 3], amount);
}
// In the test contract: must hold after every call in every run.
function invariant_TotalMatchesSum() public view {
assertEq(handler.sumOfStakes(), pool.totalStaked());
}
Forge 1.8.5 broke the invariant inside its first run and shrank the failure to two calls. The output, with the address columns trimmed for width:
[PASS] testFuzz_TransferKeepsTotal(uint256,uint256) (runs: 256, μ: 153003, ~: 154796)
[FAIL: assertion failed: 2 != 1]
[Sequence] (original: 2, shrunk: 2)
calldata=stake(uint256,uint256) args=[147, 0]
calldata=transferStake(uint256,uint256,uint256) args=[0, 3, 1065]
invariant_TotalMatchesSum() (runs: 1, calls: 2, reverts: 0)
Read through the handler’s arithmetic: account 0 stakes 1, then account 0 transfers 1 to account 0 (3 mod 3 is 0). Its stake reads 2. The total reads 1.
The fuzzer did not get smarter between the two tests. The harness did. The first test’s green tick was true, but only about the inputs it was allowed to produce.
What fuzzing finds well
Fuzzing pays for itself on bugs that are easy to state and hard to see by reading:
- Arithmetic at the edges. Overflow inside
uncheckedblocks, casts that truncate, division that rounds to zero on small amounts. - Rounding that leaks value. Share and fee maths where rounding the wrong way lets a user take slightly more than they put in, again and again.
- Accounting drift. Totals that stop matching the sum of their parts after an unusual sequence, as above.
- State machines out of order. Claiming twice, withdrawing before a position closes, liquidating an account that was healthy one call ago.
- Reverts that should not happen. A function a valid user must always be able to call, which reverts on one input and can freeze funds.
- Regressions. A property that held last month and breaks after a refactor, caught in CI before anyone reviews the diff.
That last item is how we used it on Adrena, a Solana perpetuals DEX. Every finding generated at least one runnable test, so a fix was checked by rerunning it. The suite had three layers. Around 87 properties covered pure functions such as PnL and borrow-rate logic. Around 60 stateful properties drove real Solana transactions through position lifecycles and liquidations. A coverage-guided fuzzer explored an 82-operation vocabulary and checked global invariants after every step. The audit report lists fuzzing as one of nine methods, next to manual source review, system design analysis and economic modelling.
What a passing fuzz run does not prove
Edsger Dijkstra put the limit of all testing in one sentence in his Notes on Structured Programming: “Program testing can be used to show the presence of bugs, but never to show their absence!” Fuzzing is testing at scale. The limit holds, and it shows up in five ways.
A property nobody wrote is never checked
The fuzzer knows only the rules it was given. If no invariant says a user cannot withdraw more than they deposited plus their share of fees, a sequence that does exactly that is a passing run. Our post on formal analysis notes that most major Web3 failures were code doing what it said rather than what the business meant. A fuzzer inherits that problem whole, because the property list is the business intent in writing.
Green means “not found in these runs”
Two uint256 arguments give 2^512 possible inputs, and 256 runs is a sample. Coverage guidance and value dictionaries make that sample far better than random, but a bug behind a narrow branch can still sit where no run arrived. A report shows what was tried. It cannot show what was not.
The harness decides what is possible
Every bound, every vm.assume and every function left out of the handler removes inputs. That is usually necessary, and it is also how real bugs get excluded, as the hard-coded Bob showed. In Foundry, invariant runs do not fail on a revert by default (fail_on_revert = false), so a handler whose calls keep reverting can explore almost nothing and still report a pass. Always read the revert counts.
The chain around the contract is missing
A local campaign runs against whatever was deployed in the test. Real attacks bring flash-loan capital, other protocols’ prices, oracle updates, transaction ordering and governance. Forking mainnet state brings some of it in. Whether someone can build a profitable sequence from the protocol’s own rules usually needs a person to model it first.
The run describes one version of the code
A campaign that passed on Tuesday says nothing about Wednesday’s commit until it runs again. CI handles part of that. The rest is the decay every audit faces, covered in when a smart contract audit actually expires.
Formal verification attacks the second limit directly. A prover reasons about every input at once instead of sampling, so a clean result holds for inputs no run would try, within its stated assumptions. It still needs the same written property, so a team with a good invariant suite has already drafted most of a verification spec. Our formal verification entry covers that side.
The fuzzers teams use
Each description below comes from the tool’s own documentation.
| Tool | What it is | Chain | Docs |
|---|---|---|---|
| Foundry (Forge) | Fuzzes any test function that takes parameters, 256 runs by default. Invariant tests run random call sequences through handler contracts | EVM | Testing, invariants |
| Echidna | “a Haskell program designed for fuzzing/property-based testing of Ethereum smart contracts”. Tries to falsify echidna_ properties or Solidity assertions, and can also run Foundry-style tests | EVM | crytic/echidna |
| Medusa | “a cross-platform go-ethereum-based smart contract fuzzer inspired by Echidna”, with parallel workers and coverage-guided fuzzing | EVM | crytic/medusa |
| Trident | Ackee Blockchain Security’s Rust framework for fuzzing Solana programs, with stateful fuzzing and Anchor-like macros | Solana | Ackee-Blockchain/trident |
Most teams start with Forge, where fuzz tests sit beside unit tests. Echidna and Medusa earn their place once the invariants are worth hours of compute.
On Solana, we built our own harness for Adrena on LiteSVM, because an off-the-shelf Solana test runner did not give an audit team what it needed, such as resetting only the state that changed between sequences. The case study says it “can run millions of operation sequences without flake”.
Several tools sold as “analysers” are fuzzers or provers underneath; our guide to Solidity static analysis tools sorts them.
Where a manual audit is still needed
A fuzzer checks claims about a contract. Someone still has to decide which claims matter, and whether a contract that keeps them is the one the team meant to build. Four parts of that are human work.
- Deriving the properties. On Adrena, one pass of the review traced every path that moves value through the protocol and turned it into conservation invariants. In the case study’s words, “those invariants become the assertions later fuzzers check.” The fuzzer ran them. It could not have written them.
- Reviewing the design. An admin role with too much reach, an upgrade path, an oracle choice, a fee that capital can game. A fuzzer treats whatever the code allows as allowed.
- Reading the harness. What was bounded away, which functions were left out, how many calls reverted. A green campaign on a weak harness is the most misleading result in security testing.
- Judging the counterexample. An invariant broken by one wei of rounding may be harmless, or it may be the first step of a drain. Telling which, and choosing the fix, takes someone who knows the protocol.
The order that works: write invariants during design, fuzz them from the first commit, and have an auditor challenge both the code and the property list before launch.
That challenge is what our manual smart contract audits are for. They are priced from $5,000, review logic and economic risk line by line, and include two rounds of checking your fixes. Every report is minted on-chain, and the public ones, Adrena’s included, are in our audit portfolio. Code keeps changing after an audit, so continuous scanning re-runs deterministic static analysis on each commit and pull request, from $399 a month.
If you already have an invariant suite, an audit is the quickest way to learn whether it tests the right things. Talk to the audit team before your users find out for you.
Frequently asked questions
What is fuzzing in smart contracts?
Fuzzing is automated testing that calls a smart contract with large numbers of generated inputs and call sequences, and reports any that break a stated property, such as total supply equalling the sum of balances. Foundry, Echidna and Medusa run it for EVM contracts. Trident runs it for Solana programs.
What is the difference between fuzz testing and invariant testing?
A fuzz test runs one test function many times with random arguments. An invariant test, also called stateful fuzzing, runs random sequences of calls from different callers and checks a rule after every call. Fuzz tests suit maths and single functions. Invariant tests find bugs that only appear after a particular series of actions.
Can fuzzing prove that a smart contract is secure?
No. A passing campaign means no run broke the properties that were written, within the inputs the harness allowed and the paths the fuzzer reached. It says nothing about properties nobody wrote, code no run reached, or attacks that depend on other protocols, prices or governance outside the test.
Is fuzzing the same as formal verification?
No. A fuzzer searches for an input that breaks a property, and finding none is evidence, not proof. Formal verification uses a solver to show the property holds for every input under stated assumptions. Both start from the same written property, so a strong invariant suite is also the first draft of a verification spec.
How many fuzz runs are enough?
No run count turns a pass into a proof. Forge defaults to 256 runs per fuzz test, and many teams raise that in CI. Coverage matters more than the count: check which branches the campaign reached and add properties or handler functions where it did not. A badly built test can pass 10,000 runs and still miss the bug.
What tools are used to fuzz Solidity smart contracts?
The usual choices are Foundry’s built-in fuzzer and invariant tests, Echidna and Medusa. Foundry’s fuzz tests sit beside ordinary unit tests. Echidna checks properties written as boolean functions or assertions, and can also run Foundry-style tests. Medusa, inspired by Echidna, runs campaigns across parallel workers with coverage guidance.
Does fuzzing replace a smart contract audit?
No. Fuzzing checks the properties a team wrote. An audit asks whether they are the right properties, reviews the design, access control and economic risk that no property covers, and decides which counterexamples matter. They work best together: auditors can turn each finding into a fuzz test so the fix stays fixed.



