We manage your security to weather the AI storm →

← Resource Hub

Foundry Invariant Testing: Handlers, Ghost Variables, Actors

By Owen Thurm Published 6 min read

Foundry Invariant Testing: Handlers, Ghost Variables, Actors

Quick answer. Write a handler that wraps your protocol, bounds every input and pranks as one of several actors. Track totals in ghost variables, target the handler, and assert properties in invariant_ functions, which Forge checks after every call.

Watch: Foundry Invariant Testing: Handlers, Ghost Variables, Actors 1:32
Video transcript

Some bugs only appear after several calls in a row, such as deposit, accrue, then withdraw. Invariant tests in Foundry search for exactly those sequences.

A fuzz test throws random arguments at one function. An invariant test throws random call sequences at the whole system, carrying state, and checks your invariants after every call.

Write a handler that wraps your protocol. Bound every input, prank as one of several funded actors, and track totals in ghost variables. Then target the handler and assert your properties.

Ghost variables record totals the protocol never stores. If the vault's balance drifts from deposits minus withdrawals, value leaked somewhere.

Defaults are 256 runs of 500 calls. Turn on fail on revert once handlers only make valid calls. Raise the check interval with care, since a bug can slip between checks.

When an invariant breaks, Forge shrinks the sequence. Read the verbose trace, print it as Solidity, paste it into a regression test, and suspect the handler too.

On Synthetix's BFP Market, Guardian's suite covered 22 invariants over more than 140 million runs, and Synthetix kept the harness for future updates.

Read the full guide for code, config and debugging details, or talk to Guardian about an invariant suite built for your protocol.

A fuzz test throws random inputs at one function. An invariant test throws random sequences of calls at a whole system, then checks that its rules still hold.

Some bugs only appear after several calls in a row, such as deposit, accrue, then withdraw. Invariant tests search for those sequences.

This guide follows Foundry's docs and forge-std as of Foundry v1.9.0, released November 2, 2026. Choosing a fuzzer instead? See Echidna vs Medusa vs Foundry.

Invariant Tests vs Fuzz Tests

Both run from forge test. They differ in what the fuzzer controls.

Fuzz test Invariant test
Fuzzer controlsArguments to one test functionWhich functions run, in what order, with what arguments
StateStarts fresh each runCarries across the calls in a run
Written asA test function with parametersFunctions named invariant_...
Default size256 runs256 runs of 500 calls each

By default, Forge checks every invariant_ function after each call in a run.

Good invariants are short statements of what must always be true. Foundry's guide lists common types.

  • Conservation: what goes in equals what comes out.
  • Solvency: the contract can cover all its liabilities.
  • Bounds and monotonicity: a value stays in range, or only moves one way.
  • State consistency: related variables stay in sync.

Write a Handler

Aimed straight at a protocol, the fuzzer calls every public function with random callers and amounts. Many of those calls revert before they reach interesting code.

A handler fixes that. Foundry's guide says handlers "wrap target contracts to constrain inputs and track state."

The test contract deploys the protocol and the handler, then targets only the handler.

contract VaultInvariantTest is Test {
    Vault vault;
    VaultHandler handler;

    function setUp() public {
        vault = new Vault();
        handler = new VaultHandler(vault);
        targetContract(address(handler)); // fuzz only the handler
    }

    function invariant_solvency() public view {
        assertGe(address(vault).balance, vault.totalDeposits());
    }
}

The handler is a contract that inherits forge-std's Test, so it can use bound and cheatcodes. Each of its functions makes one valid protocol call.

function deposit(uint256 amount, uint256 actorSeed)
    external
    useActor(actorSeed)
{
    amount = bound(amount, 0.01 ether, 10 ether);
    vault.deposit{value: amount}();
    ghost_depositSum += amount;
}

To narrow a campaign further, forge-std adds targetSelector, excludeSelector, targetSender and excludeContract.

Track State With Ghost Variables

Ghost variables are counters kept in the handler. They record totals the protocol does not store, such as every amount ever deposited.

Declare them as public variables, and update each one in the matching handler function. A withdraw handler adds to ghost_withdrawSum the same way.

uint256 public ghost_depositSum;
uint256 public ghost_withdrawSum;

Then compare them with the protocol's own accounting. If the vault's balance drifts from deposits minus withdrawals, value leaked somewhere.

function invariant_conservation() public view {
    assertEq(
        address(vault).balance,
        handler.ghost_depositSum() - handler.ghost_withdrawSum()
    );
}

Ghosts can also hold per-address deltas, as Foundry's token example does with a mapping of signed balance changes.

Some checks are expensive, or only make sense at the end of a run. Put those in afterInvariant(), which Forge calls once per run against the final state.

A test contract can define only one afterInvariant(), and its state changes are not persisted.

Actors and Bounded Inputs

Real protocols have many users. Give the handler a fixed set of actors, so balances and positions build up across calls.

address[] internal actors;
address internal currentActor;

modifier useActor(uint256 actorSeed) {
    currentActor = actors[bound(actorSeed, 0, actors.length - 1)];
    vm.startPrank(currentActor);
    _;
    vm.stopPrank();
}

constructor(Vault _vault) {
    vault = _vault;
    for (uint256 i = 0; i < 10; i++) {
        actors.push(makeAddr(string.concat("actor", vm.toString(i))));
        vm.deal(actors[i], 100 ether);
    }
}

The fuzzer picks actorSeed, and bound maps it onto the list. The call then runs as that actor.

Bound every other input the same way. Foundry's docs say to use bound rather than vm.assume for ranges.

vm.assume throws the input away instead. Broad checks "slow down tests significantly," and a test may fail once it hits max_assume_rejects: 65,536 per invariant run by default.

Keep bounds realistic, not timid. A range that skips dust amounts or very large values also skips the rounding and overflow cases you want to find.

Configure the Campaign

Set these under [invariant] in foundry.toml. Defaults are from Foundry's testing reference.

Key Default What it does
runs256Call sequences per invariant test
depth500Calls in each sequence
fail_on_revertfalseFails the test when any call reverts
check_interval1Calls between invariant checks
shrink_run_limit5000Attempts to shorten a failing sequence
max_time_delayNoneMaximum random time jump between calls, in seconds
corpus_dirNoneSaves a corpus and turns on coverage guidance
call_overridefalseOverrides unsafe external calls, for reentrancy checks

A few notes on the knobs.

  • fail_on_revert: turn it on once handlers only make valid calls. Then a revert means a bug, not noise.
  • check_interval: Foundry says 10 made deep runs up to 3.6 times faster. A bug that breaks and restores an invariant between checks can slip past.
  • Time: max_time_delay and max_block_delay suit vesting, auctions, TWAPs and cooldowns.
  • Inheritance: set runs in [fuzz] but not [invariant], and invariant tests use the fuzz value.

For CI profiles and nightly campaigns, see our smart contract CI/CD pipeline.

Debug a Failing Sequence

When an invariant breaks, Forge shrinks the call sequence before reporting it. Foundry v1.9.0's release notes say the new shrinker finds the same counterexample up to 35 times faster.

Then work through it.

  1. Read the trace. forge test --match-test invariant_solvency -vvvv prints each call that led to the failure.
  2. Get Solidity. With show_solidity = true, Forge prints the counterexample as Solidity calls.
  3. Write a regression test. Paste those calls into a unit test, so the bug stays fixed.
  4. Rerun the campaign. Forge records failures in cache/invariant and replays them on the next run.
  5. Suspect the handler too. A wrong ghost update fails just as loudly as a protocol bug.

Check green runs as well. Forge's metrics table counts calls, reverts and discards for each handler function. A function that always reverts tests nothing.

To test the tests, plant bugs and see whether an invariant catches them. Foundry v1.8.0 added forge test --mutate, and our mutation testing guide explains the method.

Invariants catch broken properties, not known bad patterns. Run static analysis too, as in our Slither tutorial.

How Guardian Uses Invariant Suites

Guardian's smart contract audit includes an invariant suite: core safety properties become executable invariants, fuzzed across every reachable state.

As the Vanguard page puts it, "The suite outlives the audit as a reusable harness for fixes, retesting, and every change you ship afterwards."

On Synthetix's BFP Market, Guardian's Echidna suite assessed 22 critical invariants over more than 140 million runs. Per the case study, Synthetix came away with "a fully functional fuzzing harness, enabling them to continuously verify protocol invariants in future updates."

Frequently Asked Questions

What is the difference between a fuzz test and an invariant test in Foundry?

A fuzz test calls one test function with random arguments, starting fresh each run. An invariant test runs random sequences of calls, 500 deep by default, and checks your invariant_ functions after each call.

Why use a handler for invariant testing?

Without one, the fuzzer calls protocol functions with random callers and amounts, and many calls revert early. A handler bounds inputs, picks funded actors and tracks ghost totals, so calls reach real logic.

Should I set fail_on_revert to true?

Foundry's default is false. Turn it on once your handlers only make valid calls. From then on, any revert points to a bug in the protocol or in the handler.

How do I reproduce a failing invariant test?

Rerun it with -vvvv to see each call. Set show_solidity = true to print the sequence as Solidity, then paste it into a unit test. Forge also replays recorded failures from cache/invariant on the next run.

Sources

Get an invariant suite built for your protocol

Guardian writes your core safety properties as executable invariants during the audit, and the suite stays with you for every later change.