Skip to content

hunterinvariants/hunter-invariants

Repository files navigation

Hunter

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/

What it catches

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.

What it runs

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/--setup and 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.

What it checks

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.

  1. no_free_swap_round_trip — a 0->1->0 swap cannot return more than it spent.
  2. lp_no_free_round_trip — an LP add then remove, with no swap between, cannot profit.
  3. hook_cannot_drain_shared_manager — fuzzing the hook's pool cannot drain another pool's reserves on the same PoolManager.
  4. callbacks_reject_non_poolmanager — every callback reverts unless the caller is the PoolManager.
  5. lp_can_always_withdraw — LPs can eventually exit. The pool-hijack catch above lives here.
  6. fee_within_sane_bound — a tiny round-trip cannot lose more than 50% to fees.
  7. phantom_liquidity_forbidden — a hook holding BEFORE_SWAP_RETURNS_DELTA cannot call modifyLiquidity synchronously 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.

Running on untrusted PRs

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 ffi off before the run, so a hook can't run shell commands on the runner through vm.ffi, even if the repo's foundry.toml turns 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 forge pulling 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 false PASS.
  • Output from the hook is sanitized before it reaches the PR comment.

Tested against tricks

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.

Install

# .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 config

That'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).

Run it locally

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 .

Verdicts

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.

Scope and limits

  • 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-url to 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 scan lists only real hooks.
  • EVM / Solidity / Foundry only. No telemetry. Runs on your CI runner.
  • MIT. Deleting the workflow removes the gate.

Roadmap

  • 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.

Badge

After Hunter passes on your hook in CI:

Hunter

[![Hunter](https://img.shields.io/endpoint?url=https%3A%2F%2Fhunterinvariants.github.io%2Fhunter-invariants%2Fbadge.json)](https://hunterinvariants.github.io/hunter-invariants/)

License

MIT.

About

Automated value-conservation invariant safety gate for Uniswap v4 hooks and ERC-4626 vaults in GitHub Actions.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages