Skip to content
Quantix Security Labs
Primer

Writing invariants that find bugs

Fuzzers and provers are only as good as the properties they check. How to move from vague requirements to invariants precise enough to fail.

Written by
Quantix Research, Formal Methods
Published
Reading time
7 minutes
Category
Formal Verification

Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Illustration: state trajectories inside a box that marks the invariant, with one trajectory crossing its boundary.
Contents
  1. Start from consequences
  2. Three kinds of property
  3. Handlers and ghost variables
  4. An invariant suite for a vault
  5. Letting the handler behave like an attacker
  6. Proving your properties can fail
  7. From fuzzing to proofs
  8. Conclusions
  9. Recommendations
  10. Bibliography

Fuzzers and provers are often described as tools that find bugs. More precisely, they find counterexamples to properties someone has written down. A fuzzer with no properties checks only that nothing reverts unexpectedly. A prover with a weak specification proves something true and unimportant. The quality of the property sets the ceiling on what the tool can find.

This primer describes how Quantix reviewers turn requirements into properties precise enough to fail, using an ERC-4626 vault as a running example.

Start from consequences

The most useful properties rarely come from reading the code. They come from asking what must never happen to the people who use the system: depositors must be able to withdraw what they are owed; nobody may mint shares without paying for them; only governance may change a fee; the protocol must not owe more than it holds.

Each of those sentences can be made precise. “Depositors can withdraw what they are owed” becomes a statement about the relationship between the vault’s asset balance and the value of all outstanding shares. Writing it down forces decisions the specification may have left open: owed at which exchange rate, rounded in which direction, under which assumptions about the underlying token.

From a requirement to a failing invariantA plain-language requirement is turned into a precise property, which is encoded in a test harness with handlers and ghost variables, then checked by a fuzzer or prover. The tool either reports that the property held, in which case coverage must be checked, or produces a shrunk counterexample, which leads to fixing the code or refining the property.Requirement“Users can alwayswithdraw what theydeposited.”Propertyfor every user:redeem(balance)succeeds and pays≥ assets owedHarnesshandlers per actionghost totalsbounded inputsToolstateful fuzzeror proverHoldscheck coverage firstCounterexampleshrunk call sequencefix the code, or the property was wrong: refine it and run againlow coverage: improve handlers
From a requirement to a checked property. Either outcome feeds back into the harness or the code.

Three kinds of property

KindVault exampleTypically checked with
State invariantAssets held ≥ convertToAssets(totalSupply): shares are always fully backed.Invariant fuzzing after every call sequence; formal verification.
Transition propertyA deposit never decreases the price per share; redeem delivers at least previewRedeem.Assertions inside fuzzing handlers; pre/post rules in a prover.
Relational or economicNo sequence of deposits and withdrawals by one account ends with more assets than it put in, absent yield.Stateful fuzzing with ghost variables; scenario tests.
Access controlOnly the governance role can change the fee or the strategy.Fuzzing with arbitrary callers; rules over all functions.
Property types, with vault examples and how each is usually checked

State invariants are the most powerful because they must hold after any sequence of calls, including sequences nobody thought to test. Transition properties catch errors in a single step that an invariant might only reveal much later. Economic properties capture what users actually care about, and are often where the most serious findings come from.

Handlers and ghost variables

Stateful fuzzers call functions in random sequences with random inputs. Unconstrained, most of those calls revert: random amounts exceed balances, random addresses lack approvals. A handler sits between the fuzzer and the system, choosing realistic actors, bounding inputs to meaningful ranges, and setting up preconditions such as minting and approving tokens before a deposit.

Handlers also keep ghost variables: running totals the system does not store itself, such as the sum of all deposits and withdrawals. Ghost variables give invariants an independent model to compare against. The system’s own accounting is never the only witness to its correctness.

An invariant suite for a vault

VaultInvariants.t.sol (illustrative)solidity
// Illustrative Foundry invariant suite for an ERC-4626 vault.// The handler constrains inputs and records what *should* have happened.contract VaultHandler is Test {    Vault internal vault;    TestToken internal asset;    address[] internal actors;     uint256 public ghostDeposited;   // ghost variables: the model's view    uint256 public ghostWithdrawn;   // of the system, kept independently     constructor(Vault v, TestToken a, address[] memory users) {        vault = v;        asset = a;        actors = users;    }     function deposit(uint256 seed, uint256 assets) external {        address actor = actors[seed % actors.length];        assets = bound(assets, 1, 1e30);        asset.mint(actor, assets);        vm.startPrank(actor);        asset.approve(address(vault), assets);        vault.deposit(assets, actor);        vm.stopPrank();        ghostDeposited += assets;    }     function redeem(uint256 seed, uint256 shares) external {        address actor = actors[seed % actors.length];        shares = bound(shares, 0, vault.balanceOf(actor));        if (shares == 0) return;        uint256 preview = vault.previewRedeem(shares);        vm.prank(actor);        uint256 assets = vault.redeem(shares, actor, actor);        // ERC-4626: previewRedeem must not promise more than redeem delivers.        assertGe(assets, preview, "redeem returned less than previewRedeem");        ghostWithdrawn += assets;    }} contract VaultInvariants is Test {    Vault internal vault;    TestToken internal asset;    VaultHandler internal handler;     function setUp() public {        asset = new TestToken();        vault = new Vault(asset);        handler = new VaultHandler(vault, asset, _actors());        targetContract(address(handler));    }     /// Solvency: every outstanding share can be redeemed for what it claims.    function invariant_solvency() public view {        assertGe(asset.balanceOf(address(vault)), vault.convertToAssets(vault.totalSupply()));    }     /// Conservation: with no yield source, assets in = assets held + assets out.    function invariant_conservation() public view {        assertEq(asset.balanceOf(address(vault)), handler.ghostDeposited() - handler.ghostWithdrawn());    }}

Three properties are checked here. The assertion inside redeem encodes a requirement from the ERC-4626 standard: previewRedeem must return no more than redeem actually delivers in the same transaction. The solvency invariant states that outstanding shares are backed. The conservation invariant compares the vault’s balance with the ghost record of what went in and out; it would catch an accounting path that loses or creates assets, even if every individual function looked plausible.

Letting the handler behave like an attacker

The handler above models honest users. Real systems also face users who do things the designers did not expect, and the most valuable handlers include those actions deliberately.

  • Donations. A donate action that transfers assets directly to the vault, bypassing deposit, is how share-price inflation attacks begin. Adding it immediately exposes vaults that lack virtual offsets. It also means the conservation invariant must change from equality to “at least”, which is itself a useful prompt to think about where donated value should go.
  • Boundary values. Bounds such as bound(assets, 1, 1e30) keep calls meaningful, but they should still include the edges: one unit, the full balance, and amounts that exercise rounding at both ends. Many accounting bugs live only at those values.
  • Time and external state. If the system accrues interest, rebalances or reads prices, the handler should be able to advance time and move the external inputs within realistic ranges, including the extreme ones.
  • Privileged actions. Governance changes, pauses and upgrades belong in the handler too, executed by the roles allowed to perform them. Properties should state what remains true across those changes, and which guarantees users lose while the system is paused.

Each new action widens the space the fuzzer explores and usually forces at least one property to be restated more precisely. That is the process working as intended: the specification becomes more exact every time the model of the world becomes more realistic.

Proving your properties can fail

A passing invariant suite is weak evidence until you know it could have failed. Two checks make it stronger.

  • Coverage of calls. Inspect how many handler calls succeeded. If nearly every call returns early or reverts, the fuzzer explored almost nothing and the invariants passed vacuously. Tooling can report call summaries; review them.
  • Mutation of the system. Introduce deliberate bugs, such as flipping a rounding direction, removing an access check or skipping a state update, and confirm that at least one property fails for each. A mutation that survives marks a gap in the specification.

Quantix applies the same discipline to its own formal specifications: every rule delivered to a client has been shown to fail against a mutated version of the code.

From fuzzing to proofs

Fuzzing samples behaviours; formal verification reasons about all of them within a model. The properties are the same. A well-written invariant suite is therefore also the starting point for formal verification, and moving between the two is cheaper than starting either from scratch.

What changes is the treatment of assumptions. A prover will ask what the token does on transfer, what an oracle may return, and whether arithmetic can overflow. Each answer becomes an explicit assumption in the result. Those assumptions are not a weakness of formal methods; they are a precise record of what the guarantee depends on, and they should be monitored in production like any other critical dependency.

It also helps to be precise about what kind of result each tool gives. A fuzzing campaign that finds nothing after many millions of calls is evidence, not proof: it says the explored sequences were safe, weighted by how well the handler steered exploration. A bounded model check proves the property for every sequence up to a given length, and nothing beyond it. An unbounded proof covers every reachable state of the model, under its assumptions. Reports should say which of these a result is, because readers will otherwise assume the strongest.

A short summary of the approach:

  • Write properties from requirements and user consequences, not from the implementation.
  • Prefer state invariants that must hold after any sequence of calls.
  • Use handlers to make calls meaningful and ghost variables to keep an independent record.
  • Check call coverage and mutation results before trusting a passing suite.
  • State every assumption explicitly, and carry it from fuzzing into verification.

Conclusions

The tools that check properties are mature; the bottleneck is the properties themselves. Invariants written from users’ requirements, compared with an independent model in ghost state, exercised by handlers that include adversarial actions, and shown to be capable of failing, find bugs that reviews and unit tests miss.

The same properties carry over from fuzzing to proofs. Smart contract fuzzing techniques covers how to make a fuzzer explore, and formal verification in DeFi covers what a proof of the same property adds.

Recommendations

Developers

  1. Write one sentence for each promise the system makes to its users, then translate each into a property.
  2. Keep ghost state independent of the code under test, and include adversarial actions such as donations, dust amounts and same-block sequences.

Reviewers

  1. Mutate the code to confirm that every property can fail.
  2. Read harness bounds and preconditions for cases they exclude.
  3. Turn every counterexample into a named regression test.

Bibliography

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

  1. 1Invariant Testing(opens in a new tab)Foundry documentation
  2. 2ERC-4626: Tokenized Vaults(opens in a new tab)Ethereum Improvement Proposals
  3. 3How to use Echidna to test smart contracts(opens in a new tab)ethereum.org developer tutorials
  4. 4Echidna: an Ethereum smart contract fuzzer(opens in a new tab)crytic/echidna on GitHub

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.