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.
Where it lands in Jayverse
- DeFi: upgrade jayverse-defi's fuzz test from single-sequence to a full handler campaign. Build a handler over deposit/wrap/unwrap/requestWithdraw/claim/addRewards/slash across several actors, with ghost sums for the withdrawal queue and the invariant
pool balance == totalPooledETH + pendingWithdrawalETH. - CI: require call/revert distribution reporting on every invariant run, not just pass/fail. A green campaign where withdraw never once succeeds should fail CI review even if no assertion broke, since the distribution is half the test.
- Auditor: log reachability metrics as part of what was checked. When auditing a contract's invariant tests, the methodology write-up should state the call/revert distribution and ghost-variable accounting used, not only that the assertions held.
Key expressions
| Expression | 뜻 · 쓰이는 자리 |
|---|---|
| die at | ~에서 바로 죽다(실패하다) · 무작위 호출 대부분이 초기 require문에서 막힘 · "almost all of them die at the first require" |
| get past the front door | 현관문도 못 넘다(초기 단계도 못 지나다) · 퍼저가 의미 있는 상태에 전혀 도달 못함 · "nobody ever got past the front door" |
| shadow ledger | 그림자 장부 · 고스트 변수로 불변식을 검증하기 위해 별도로 유지하는 집계 · "a shadow ledger (sum of deposits, sum withdrawn)" |
| forcing function | 강제로 특정 행동을 하게 만드는 장치 · 옵션을 강제해 핸들러 작성을 유도 · "useful as a forcing function, too strict as a default" |
| unsurvivable | 버텨낼 수 없는, 생존 불가능한 · open targeting을 계속 실패하게 만들어 막는 것 · "making open targeting unsurvivable" |
| line up | (일련의 조건을) 순서대로 맞추다 · 랜덤 호출이 우연히 필요한 순서로 맞아떨어지는 것 · "essentially never lines those three calls up" |
| in miniature | 축소판으로 · 이미 적용된 원칙이 작은 규모로 드러남을 표현 · "already applies the philosophy in miniature" |
| blunt version | 무딘(거친) 버전, 단순 무식한 방식 · 세밀한 규율 대신 강제로 밀어붙이는 방식 · "the blunt version of this discipline" |
| put ~ on screen | ~을 눈에 보이게 드러내다 · 호출·리버트 분포를 화면에 찍어 문제를 드러냄 · "now that fact is on screen" |
| ERC-4626 | 토큰화된 볼트 표준(ERC-4626) · 예치·인출 인터페이스를 표준화한 볼트 컨트랙트 규격. "Test the same ERC-4626-style vault twice" |
| ghost variable | 고스트 변수 · 컨트랙트 상태와 별도로 테스트에서만 불변식 검증용으로 추적하는 집계 변수. "Ghost variables then do two jobs" |
| stateful fuzzing | 상태 유지형 퍼징(stateful fuzzing) · 무작위 호출 시퀀스 뒤에도 성립해야 하는 속성을 검증하는 Foundry 기법. "Stateful fuzzing is becoming standard Solidity tooling" |
| fail_on_revert | Foundry 설정 플래그(fail_on_revert) · 리버트 발생 시 캠페인을 실패 처리해 핸들러 작성을 강제하는 옵션. "Foundry's fail_on_revert = true is the blunt version" |
| bound() | Foundry 치트코드 함수(bound()) · 무작위 값을 성공 가능한 범위로 제한. "bound() the amount into a range that can succeed" |