Hunter generates a Foundry invariant harness for a Uniswap v4 hook, runs it under fuzzing in CI, and fails the PR if value conservation breaks. Simple hooks and complex ones alike: a bespoke oracle / vault / protocol constructor dependency is auto-mocked with fuzzer-driven returns, and src too large for the default codegen pipeline is recompiled with --via-ir automatically. Pure-Python generator (regex + string templates). No LLM. Runs offline by default; network only if you opt into --fork-url.
Registry: https://hunterinvariants.github.io/hunter-invariants/
Run against a hook that bricks LP withdrawals (a reproduction of the pool-hijack-via-reinitialization pattern OpenZeppelin documented in their 2025 Rewind), Hunter failed invariant_lp_can_always_withdraw and Foundry shrank the counterexample to a single call:
[FAIL: an LP could not remove their liquidity: the hook can brick withdrawals (funds locked)]
[Sequence] (shrunk: 1) h_check_can_withdraw(445175632382375062)
-> beforeRemoveLiquidity reverts WrongPool() -> withdrawal bricked
No hand-written harness. Hunter parsed the hook, mined a flag-correct address, deployed it against a real PoolManager, fuzzed the swap and liquidity handlers, and reduced the failure to its minimum reproducing call. The catch is a real forge run.
Hunter stands up and fuzzes simple and complex hooks against a real PoolManager. Every row below is a real forge run on a public repo, not a claim:
| Hook | Repo | Verdict |
|---|---|---|
Counter |
Uniswap v4-template |
PASS — conservation held across thousands of fuzzed value-bearing ops |
DynamicFeeHook |
AqilJaafree/v4-dynamic |
PASS |
FlashLiquidationHook |
leprechaun-dao/liquidation-hook |
PASS — complex (see below) |
| pool-hijack reproduction | planted vuln | FAIL — lp_can_always_withdraw, shrunk to one call (above) |
FlashLiquidationHook is the case a simpler tool gives up on: its constructor takes a bespoke ILiquidationProtocol, the repo's own test suite doesn't compile, and its src exceeds the default codegen stack limit. Hunter parsed the dependency interface, stood up a mock and fuzzed its return values (so conservation is tested against adversarial protocol responses, not a fixed stub), excluded the repo's broken tests from the build, and recompiled with --via-ir — then returned a real, honestly-caveated PASS.
The honesty contract is the whole point. Hunter refuses to fake a verdict:
- NEEDS_CONFIG is a deliberate refusal, not a failed parse. A constructor argument Hunter genuinely cannot construct (a struct, an unknown non-interface type) is named, not guessed at. Supply a one-line
--ctor-args/--setupand it runs. - ERROR names the exact unresolved import or solc conflict. A build problem is never reported as an invariant violation.
A safety gate is only worth running if its green check is trustworthy. Hunter never emits a PASS it didn't earn on real bytecode. A continuously-updated public scan of open-source hooks lives on the registry.
Seven value-conservation properties under invariant fuzzing: six universal, plus one state-integrity check that is on by default. --no-state-integrity drops to the six.
no_free_swap_round_trip— a 0->1->0 swap cannot return more than it spent.lp_no_free_round_trip— an LP add then remove, with no swap between, cannot profit.hook_cannot_drain_shared_manager— fuzzing the hook's pool cannot drain another pool's reserves on the same PoolManager.callbacks_reject_non_poolmanager— every callback reverts unless the caller is the PoolManager.lp_can_always_withdraw— LPs can eventually exit. The pool-hijack catch above lives here.fee_within_sane_bound— a tiny round-trip cannot lose more than 50% to fees.phantom_liquidity_forbidden— a hook holdingBEFORE_SWAP_RETURNS_DELTAcannot callmodifyLiquiditysynchronously inside its swap callbacks. Default-on; exempted for hook classes where it does not apply.
A run where every fuzz call reverted reports INCONCLUSIVE, not PASS. A build or config failure reports ERROR, not FAIL.
Hunter runs forge against the hook in the PR under review. That code is untrusted and can be actively adversarial. A few things make running it safe:
- It forces
ffioff before the run, so a hook can't run shell commands on the runner throughvm.ffi, even if the repo'sfoundry.tomlturns it on. - It runs only its own generated suite. The repo's own test files are excluded from the build, so a broken or hostile test in the PR cannot run or block the gate.
- It makes no network calls and sends no telemetry. The generator is deterministic; the only thing that leaves the runner is
forgepulling the project's declared dependencies. - Every step is time-bounded. A hook that tries to hang the run gets killed and reported as
ERROR, never a falsePASS. - Output from the hook is sanitized before it reaches the PR comment.
The benchmark shows Hunter won't fake a PASS on a real repo. A second suite goes after the gate itself, with hooks written to force a false green: chaotic formatting, comments injected mid-signature, decoy strings that imitate permission declarations, callbacks declared but never implemented. Detection reads through the noise. Anything it can't build or deploy fails closed as ERROR or NEEDS_CONFIG. None of it produces a PASS. These behaviors are pinned by the engine's own test suite, so the gate doesn't quietly regress.
# .github/workflows/hunter-invariants.yml
name: hunter-invariants
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
invariants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { submodules: recursive }
- uses: hunterinvariants/hunter-invariants/actions/v4-invariants@v3
with:
scan: src # auto-discovers + gates every hook under src/ — no per-hook configThat's the whole setup. scan finds your hooks, stands up the deployable ones (including complex ones with bespoke dependencies), and posts a verdict table as a sticky PR comment. To target one hook explicitly instead, drop scan and set hook: src/MyHook.sol (+ impl: for an abstract base).
python3 -m hunter.ci scan path/to/foundry-project
Walks the project, finds the v4 hooks, picks up deps and solc, gates each one it can build. Flags worth knowing:
--auto— resolve an abstract hook to its concrete*Mock/subclass automatically.--json/--json-out PATH— structured report: per-invariant status, coverage, counterexamples.--seed 0x..— pin the fuzz seed for a reproducible run.--fork-url URL— fuzz against live chain state for hooks with external calls.--no-state-integrity— drop to the six universal invariants.
After pip install, the same engine is on PATH as a console script:
forge-hunter check src/MyHook.sol
forge-hunter scan .
The verdicts are load-bearing. They are the reason a green check from Hunter means something.
PASS— the suite ran, every invariant held, and the anti-vacuous coverage gate was satisfied.FAIL— a value-conservation invariant was violated. A real bug, with a shrunk reproducing sequence.NEEDS_CONFIG— Hunter could not auto-deploy the hook (abstract base, or a constructor argument it will not fabricate). It names the blocker. Skipped, not failed.ERROR— the harness did not build or run (solc, remapping, missing dependency). A toolchain problem, not an invariant violation.INCONCLUSIVE— the suite ran but coverage was too thin to trust a PASS.
- Fuzzing, not proof. Not an audit. Catches structural value-conservation breaks, not protocol-specific logic.
- Auto-runs simple and complex hooks: a bespoke interface dependency is mocked (returns fuzzed), a bare-address constructor arg is defaulted to a labeled mock, and oversized src is recompiled with
--via-ir. A PASS under a mocked dependency is labeled as such — it validates conservation under the mock, not the real integration; use--fork-urlto exercise the real dependency. A constructor argument Hunter genuinely cannot construct (a struct, an unknown non-interface type) still needs a one-line--ctor-args/--setup; it names which. - Discovery skips interfaces and non-deployable contracts, so a
scanlists only real hooks. - EVM / Solidity / Foundry only. No telemetry. Runs on your CI runner.
- MIT. Deleting the workflow removes the gate.
- A continuously-updated public leaderboard of open-source hook scans on the registry — raw, verifiable data, not marketing.
- A one-command "Hunter Verified" badge generated from any scan report.
- Broader dependency-layout coverage (soldeer
dependencies/,node_modules) so more real repos build with zero config.
After Hunter passes on your hook in CI:
[](https://hunterinvariants.github.io/hunter-invariants/)MIT.