Why
Stateful fuzzing is becoming standard Solidity tooling, yet its main failure mode is silent: the test passes because the generator never performed meaningful work. Reachability metrics belong beside the assertion.
Foundry checks invariants after randomized sequences of calls. A campaign can remain green while accomplishing almost nothing when generated calls revert before reaching meaningful protocol states.
How it works
Write an open invariant test and a handler-based version for the same vault, compare call/revert distributions, add ghost-variable accounting, and deliberately seed a sequence-only bug.
PoC
Test the same ERC-4626-style vault twice: first by targeting the contracts directly, then through handlers that prepare balances and approvals. Track calls, reverts, deposits, withdrawals, actors, and reached states with metrics and ghost variables. Seed a bug that requires a deposit-transfer-withdraw sequence.
What it proves
The assertion is only half the test. The action distribution must reach the state space where the property could fail. Compare the open and handler-based campaigns by coverage and meaningful transitions, not merely runs and depth.
Reference: Foundry invariant testing.
Review clarification
A passing campaign has two possible meanings — the property held, or the fuzzer never got close enough to break it — and Foundry will not say which by default. With open targeting the generated calls are random functions from random senders with random arguments, and almost all of them die at the first require: a withdraw with no shares, a deposit with no balance and no approval. Thousands of runs can leave the vault in roughly the state setUp() built, so the invariant passes over a state space of about one state — the alarm was tested while nobody ever got past the front door.
The handler pattern constrains randomness into meaningful randomness: pick an actor from a managed set, deal tokens, approve, bound() the amount into a range that can succeed, then call the vault. Ghost variables then do two jobs. One is accounting — a shadow ledger (sum of deposits, sum withdrawn) the invariant can assert against. The other is the card's real thesis: measurement. Per-action call and revert counters, dumped in a call-summary, make the distribution visible — if withdraw succeeded zero times in ten thousand calls, the green campaign proved nothing about withdrawals, and now that fact is on screen. The assertion is half the test; the distribution is the other half.
The seeded bug is the empirical proof. Plant a defect only reachable via deposit → transfer the shares to someone else → that person withdraws: open targeting essentially never lines those three calls up with consistent actors, while the handler campaign finds it quickly. Same assertion, same tool — only the action distribution differs, and only one campaign catches it. Foundry's fail_on_revert = true is the blunt version of this discipline: it forces handlers by making open targeting unsurvivable — useful as a forcing function, too strict as a default.
This connects directly to jayverse-defi (2026-09-07): its fuzz test already applies the philosophy in miniature — bound() on every input is handler thinking — but it is a single-sequence test, not a stateful campaign. A real campaign there would be a handler over deposit/wrap/unwrap/requestWithdraw/claim/addRewards/slash across several actors, ghost sums for the queue, and invariants like pool balance == totalPooledETH + pendingWithdrawalETH. The withdrawal queue — request needs shares, claim needs a time warp past readyAt — is exactly a sequence-only region open fuzzing would never touch, and the natural done path for this card.