2025-05-06·9 min read

Stateful Fuzzing Explained: Why Sequence Matters in Smart Contract Testing

By Antonio · Security researcher

Stateful Fuzzing Explained: Why Sequence Matters in Smart Contract Testing

In smart contract security, the order of operations is everything. A function that is perfectly safe when called in isolation can become catastrophically vulnerable when called after a specific sequence of other operations. This is why stateful fuzzing is essential — and why stateless fuzzing alone is insufficient.

Stateless vs Stateful Fuzzing

Stateless fuzzing tests a single function with random inputs. Each test is independent — the contract is redeployed fresh for every test case. This is what Foundry's fuzz modifier does by default:

// Stateless: each call starts from the same initial state
function testFuzz_deposit(uint256 amount) public {
    amount = bound(amount, 1, type(uint128).max);
    vault.deposit(amount);
    assertGe(vault.balanceOf(address(this)), 0);
}

This is useful for testing input validation and simple edge cases, but it will never find bugs that require a specific state to be set up first.

Stateful fuzzing generates sequences of function calls, maintaining state between them. Each transaction builds on the state left by the previous one:

Call 1: alice.deposit(5000)
Call 2: bob.deposit(3000)
Call 3: admin.setRewardRate(1e16)
Call 4: warp(1 days)
Call 5: alice.withdraw(5000)
Call 6: bob.claimRewards()    // <-- Bug manifests here!

The bug at call 6 only exists because of the specific state created by calls 1-5. No stateless test would ever reach this state.

A Concrete Example: The Lending Protocol Bug

Consider a lending protocol where the bug only manifests through a specific four-step sequence:

// Step 1: User deposits collateral
lendingPool.depositCollateral{value: 10 ether}();

// Step 2: User borrows against collateral
lendingPool.borrow(8000e6); // 8000 USDC

// Step 3: Price changes (oracle update)
priceOracle.setPrice(address(weth), 1000e8); // ETH drops from 2000 to 1000

// Step 4: Liquidation should work, but...
lendingPool.liquidate(user, 4000e6); // Reverts unexpectedly!

The liquidation fails because the protocol calculates the liquidation bonus based on the pre-price-change collateral value, causing an integer underflow. This bug is invisible to:

  • Unit tests: They test liquidation with hardcoded states, never exploring the specific deposit amount + borrow ratio + price change combination that triggers the underflow.
  • Stateless fuzz tests: They test liquidate with random inputs but start from a clean state where no one has borrowed.
  • Static analysis: The arithmetic is correct in isolation — the underflow only occurs with specific runtime values.

A stateful fuzzer discovers this naturally. It calls depositCollateral, borrow, setPrice, and liquidate in sequence, and the invariant "all liquidatable positions can be successfully liquidated" fails.

How Echidna and Medusa Maintain State

Both Echidna and Medusa implement stateful fuzzing, but with different strategies.

Echidna maintains a single EVM state and builds sequences incrementally:

  1. Start with a fresh deployment
  2. Generate a random function call
  3. Execute it and record coverage
  4. If new coverage was achieved, save this sequence prefix
  5. Build longer sequences by extending saved prefixes
  6. After each call, check all properties

Echidna is methodical. It builds sequences one call at a time, preferring to extend sequences that achieved new coverage. This makes it excellent at finding bugs that require very specific sequences.

Medusa uses parallel workers that independently explore the state space:

  1. Spawn N workers, each with its own EVM instance
  2. Each worker generates and executes random call sequences
  3. Workers share coverage information through a synchronized corpus
  4. When one worker discovers a new coverage path, others can build on it
  5. Properties are checked after each call in every worker

Medusa's parallelism means it explores more total sequences per second, but each individual worker's exploration is less directed than Echidna's.

Corpus Management and Shrinking

When a fuzzer finds a property violation, the failing sequence might be 100+ transactions long. Most of those transactions are irrelevant — the bug only needs 4-5 specific calls. Corpus shrinking reduces the sequence to its minimal reproduction.

How shrinking works:

  1. Start with the full failing sequence (say, 100 calls)
  2. Try removing calls one at a time
  3. If the property still fails without a call, that call was not needed
  4. Repeat until no more calls can be removed
  5. Result: the minimal sequence (say, 5 calls) that triggers the bug
Before shrinking (100 calls):
  deposit, transfer, approve, deposit, setFee, withdraw, deposit, borrow,
  deposit, deposit, setPrice, withdraw, ... [90 more] ..., liquidate

After shrinking (5 calls):
  deposit(10 ether)
  borrow(8000e6)
  setPrice(1000e8)
  warp(1 days)
  liquidate(user, 4000e6)

Echidna's shrinking is currently more aggressive and produces cleaner minimal sequences. Medusa's shrinking is improving with each release.

Writing Properties for Stateful Fuzzing

Effective stateful fuzzing requires two things: target functions that the fuzzer can call, and properties that are checked after each call.

Target functions should cover all user-facing operations:

contract TargetFunctions is Setup {
    function handler_deposit(uint256 amount) external {
        amount = clamp(amount, 1, token.balanceOf(currentActor));
        vm.prank(currentActor);
        vault.deposit(amount, currentActor);
    }

    function handler_withdraw(uint256 shares) external {
        shares = clamp(shares, 1, vault.balanceOf(currentActor));
        vm.prank(currentActor);
        vault.withdraw(shares, currentActor, currentActor);
    }

    function handler_warpTime(uint256 seconds_) external {
        seconds_ = clamp(seconds_, 1, 365 days);
        vm.warp(block.timestamp + seconds_);
    }
}

Properties should check fundamental invariants that must hold regardless of what sequence of operations occurred:

contract Properties is TargetFunctions {
    function invariant_solvency() public view returns (bool) {
        return token.balanceOf(address(vault)) >= vault.totalAssets();
    }

    function invariant_sharePriceNonDecreasing() public returns (bool) {
        uint256 price = vault.convertToAssets(1e18);
        bool ok = price >= lastSharePrice;
        lastSharePrice = price;
        return ok;
    }
}

When Sequence Length Matters

The seqLen (Echidna) or callSequenceLength (Medusa) parameter controls how many function calls are in each test sequence. This is a critical tuning parameter:

  • Short sequences (10-20): Good for finding bugs that manifest quickly. Fast iteration.
  • Medium sequences (50-100): Good for most DeFi protocols. Covers multi-step user journeys.
  • Long sequences (200+): Needed for protocols with complex state machines (governance, vesting, multi-phase auctions).

If your bug requires a deposit, a reward distribution, a price change, and a withdrawal, you need at least 4 calls. In practice, the fuzzer needs many more calls to "stumble into" the right combination, so set the sequence length well above the minimum.

Key Takeaways

  1. Stateful fuzzing finds bugs that stateless fuzzing cannot — most DeFi vulnerabilities require specific state conditions.
  2. Transaction sequence matters — the same function call can be safe or exploitable depending on prior state.
  3. Corpus shrinking is essential — it reduces 100-call sequences to minimal reproducers you can actually debug.
  4. Use both Echidna and Medusa through Chimera for maximum coverage.
  5. Tune sequence length to match your protocol's complexity.

Stateful fuzzing is the most effective automated technique for finding smart contract vulnerabilities. If you are not using it, you are missing an entire category of bugs. Request an audit with Recon to get expert stateful fuzzing coverage for your protocol.

Related Posts

Related Glossary Terms

Need help securing your protocol?