Skip to main content

HookGuard

external installs self-test

A transparent, CI-native risk scanner for Uniswap v4 hooks.

It is not an audit and never claims to be. It is a heuristic pass that flags documented v4 risk patterns early and cheaply, so scarce audit budget can be aimed where it matters.

Live status feed & dashboard: https://chaosxcode.github.io/hookguard/status/

Add it to your hook in 60 seconds

# .github/workflows/hookguard.yml
name: hookguard
on: [pull_request]

permissions:
  contents: read
  pull-requests: write   # only needed for the summary comment

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: chaosxcode/hookguard@v1
        with:
          paths: src

Annotations land on the offending lines; a single PR comment summarizes. fail-on: HIGH by default — heuristics advise, they don't gate.

Scan any repo, right now

No install, no CI needed:

python3 src/hookguard.py scan https://github.com/owner/v4-hook-repo --html report.html

Auto-detects hook directories, prints findings with an itemized risk score, and writes a standalone HTML report. --fail-on HIGH exits non-zero for pipeline use.

Why

Uniswap's official hooklist registers 551 production hooks across 16 chains (snapshot refreshed Aug 23, 2026 — it grew from 486 in one week) — and explicitly disclaims being a safety signal. A risk pass over that registry (src/risk.py):

hooks with a published audit URL 30 / 551 (5.4%)
hooks that can move value (return-delta permissions) 393 / 551 (71%)
value-moving AND no audit recorded 367 (67% of the ecosystem)
upgradeable 21 (3.8%)
verified source 551 (100%)

Unaudited share by chain: Ethereum 94.9%, Base 95.4%, Unichain 95.5%.

Hooks are trusted code in the swap path and pool creation is permissionless. Bunni v2 — then the largest LP hook by TVL — was exploited for ~$8.3M in September 2025 and shut down. Audit subsidies do not scale to 486 hooks.

Caveat, stated plainly: an empty auditUrl means the registry records no audit, not that none exists. That ambiguity is itself the gap — there is no machine-readable way for a router, LP, or integrator to tell a reviewed hook from an unreviewed one.

The registry is opt-in — so how much does it miss?

Registry coverage assumes hooks register themselves. Scanning every Initialize event in Unichain's full history — every pool ever created and the hook attached to it — measures the gap:

pools ever created on Unichain 7,539
pools that attach a hook 5,366 (71%)
distinct hook contracts deployed 1,211
of those, in Uniswap's registry 15
registry coverage 1.24%

Stated carefully, because the raw number oversells it: 1,094 of the 1,211 serve exactly one pool — launchpads minting a hook per token, not 1,094 distinct designs. The population that matters is the 117 serving 2+ pools and the 35 serving 10+. Even after that discount, the two busiest hooks on Unichain are not in the registry at all.

Correction, Aug 23: the registry moved. Four hooks named in this section — PrediX, UniMeme, Polymarket, UniswapCup — registered within days of these numbers going public. Live coverage is now 19 / 1,211 = 1.57%, and the busiest still-unregistered hook is a 165-pool contract at 0x782d…8444. Full note: findings/author-outreach.md. The Aug 19 figures below stand as measured against the registry as it was.

Reproduce with CHAIN=unichain python3 src/discover.py. The scan aborts rather than publish a partial result — an undercount is the one error that would quietly invalidate the number.

The hooks off the registry are also the unverified ones

Coverage is the smaller half of the problem. Taking the 30 unregistered Unichain hooks that serve 10+ pools — the population left after discounting launchpad one-offs — and asking the block explorer whether each has published source:

registry hooks with verified source 486 / 486 (100%)
busiest unregistered hooks with verified source 5 / 30 (17%)

The registry is 100% verified because publishing source is effectively a condition of being listed. That number describes the listing process, not the ecosystem. Off the registry, 25 of the 30 hooks sitting in the swap path of 10 or more live pools each have no published source at all — nothing to audit, nothing to scan, nothing for an integrator to read.

The five that do publish source are not obscure: PrediXHookProxyV2 (1,034 pools), UniMemeHook (777), BunniHook (50), PolymarketHook (34) and UniswapCupHook (32). Recognisable names are the exception off-registry, not the rule.

This is the ceiling on every source-level tool in v4, including this one. HookGuard's own scanner needs source; on the hooks that most need checking, there isn't any. Bytecode-level analysis is the only thing that reaches them.

The single busiest unregistered hook on Unichain, PrediXHookProxyV2 at 1,034 pools, is a verified proxy — the address bits fix its permissions forever and pools cannot detach, but the implementation behind it can still be swapped.

Reproduce with python3 src/verify_status.py. Data: out/unichain-unregistered-top.json.

Reading the ones that publish nothing

A bytecode pass (src/bytecode.py, keyless and dependency-free) reaches around source for those 25 hooks:

signal count
standard proxies (EIP-1967 / EIP-1167) 0 / 25
DELEGATECALL opcode in runtime code 17 / 25
SELFDESTRUCT opcode in runtime code 17 / 25
distinct programs across the 25 addresses 16

Several shared-code families sit at addresses whose permission bits are all set — every callback, every return-delta flag, no published source.

It also settled something about the chain itself: Unichain's PoolManager is the v4 preview deployment, and hook addresses there encode their callback permissions in the reverse bit order from final v4 (validated against every source-verifiable hook: 17/18 reversed vs 1/18). Any tool reading v4 hook permissions off addresses with mainnet semantics misreads Unichain's.

Details: findings/unichain-bytecode.md.

Extended to every unregistered hook serving 2+ pools — all 107 of them — the picture holds: 19 publish source (17.8%, unchanged from the busiest tier), 88 were bytecode-analyzed with a single standard proxy among them, and 77 distinct programs account for the 88 addresses.

What it checks

src/scan.py analyses concrete, deployable hook contracts (abstract bases, interfaces, mocks and tests are skipped — a scanner that fires on everything is noise) and reports:

Rule Severity Mechanism
PERMISSIONLESS_ATTACHMENT HIGH No beforeInitialize gate or pool validation and the hook holds funds or keeps per-PoolId state. Anyone can create a pool with attacker-chosen tokens pointing at the hook. onlyPoolManager proves the PoolManager called you, not that the pool is trusted.
PERMISSIONLESS_BY_DESIGN INFO Same, but stateless and fund-free — usually intentional.
MISSING_POOLMANAGER_GUARD HIGH A callback with no onlyPoolManager-style guard on a contract that doesn't inherit BaseHook.
UPGRADEABLE_HOOK HIGH Address bits encode permissions forever and pools can't detach, but the implementation can be swapped. The upgrade admin is part of the trust boundary.
DELTA_FLAG_MISMATCH MEDIUM RETURNS_DELTA permission declared but no delta constructed. The inverse bricks every swap (DoS).
REVERT_DOS_RISK MEDIUM External call in a required callback with no try/catch. A paused oracle bricks every pool using the hook — including LP exits.
UNBOUNDED_DYNAMIC_FEE MEDIUM Dynamic fee with no visible upper bound.
REENTRANCY_SURFACE MEDIUM Transfer inside a callback with no guard. One hook serves many pools.

Rules derive from Trail of Bits' Building secure Uniswap v4 hooks (2026), Uniswap's Security Framework, OpenZeppelin, Cyfrin, and the Bunni v2 and Cork Protocol post-mortems.

Usage

python3 src/risk.py                 # registry-wide risk profile -> out/risk.json
python3 src/scan.py path/to/src ...  # source scan            -> out/scan.json

Does it actually discriminate?

Measured against 306 real deployed hooks — registry bundles across nine chains, plus the source-publishing production hooks that were never registered (Unichain's off-registry hooks, up to 1,034 pools) — not fixtures: 56% come back completely clean (170/306; the corpus grew today when a scanner bug that skipped abstract-first hook files was fixed), and only 2 carry any HIGH finding (<1%), both the upgradeable-proxy class. One of the two is proven, not inferred: the proxy's implementation was read out of its EIP-1967 slot on-chain.

Eight false-positive classes have been found by reading that corpus and fixed. Three were caught only by verifying findings before contacting their authors — and the tool's highest-firing rule was downgraded from HIGH to MEDIUM as a result, because it turned out to detect accurately but could not justify the severity it claimed. The second pass then found three more spellings of an already-fixed class and left its flagship HIGH rule with zero findings ecosystem-wide; the details are the interesting part: docs/precision.md.

Full method, per-rule firing rates, and what it does not establish: docs/precision.md.

Use it in CI

Add this to .github/workflows/hookguard.yml in your hook repo. It runs on every pull request and annotates the offending lines.

name: hookguard
on: [pull_request]

permissions:
  contents: read
  pull-requests: write   # only needed for the summary comment

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: chaosxcode/hookguard@v1
        with:
          paths: src
Input Default Meaning
paths src Space-separated paths. Directories are searched recursively for .sol.
fail-on HIGH Severity that fails the check: HIGH, MEDIUM, LOW, or never.
comment true Maintain one PR comment, edited in place. Needs pull-requests: write.
json-out (none) Write machine-readable results to this path.

Outputs high, total and contracts for downstream steps.

On fail-on. The default fails the check only on HIGH. HookGuard is a heuristic pattern scanner, so blocking a merge on a MEDIUM would be overreach — MEDIUM and below are advisory. Set fail-on: never if you want the annotations without a gate, which is the right setting while you decide whether you trust it.

Forks. Pull requests from forks get a read-only token, so the comment step is skipped automatically. Annotations and the job summary still appear; the run does not fail because of it.

Status

Early. Heuristic, regex-based, deliberately biased toward precision over recall. Findings are starting points for review, not verdicts.

Where this is going: docs/ROADMAP.md.

Live status feed: every known hook now has a machine-readable record at docs/status/<chain>/<address>.json, one aggregate feed.json, and a dashboard to read it all — docs/status/index.html.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

hookguard-0.9.0.tar.gz (58.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

hookguard-0.9.0-py3-none-any.whl (59.4 kB view details)

Uploaded Python 3

File details

Details for the file hookguard-0.9.0.tar.gz.

File metadata

  • Download URL: hookguard-0.9.0.tar.gz
  • Upload date:
  • Size: 58.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for hookguard-0.9.0.tar.gz
Algorithm Hash digest
SHA256 a0307efe362b288048ee66c78731d1424461cae6ae89d5e567c50ae8082102c7
MD5 26fc34fe6d1f836ceb55f81fa2765f3c
BLAKE2b-256 a2317560c9ab11def2339cd4a9a6fa42ea03e2db4dae85c99d38076706b6d57b

See more details on using hashes here.

Provenance

The following attestation bundles were made for hookguard-0.9.0.tar.gz:

Publisher: publish.yml on chaosxcode/hookguard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file hookguard-0.9.0-py3-none-any.whl.

File metadata

  • Download URL: hookguard-0.9.0-py3-none-any.whl
  • Upload date:
  • Size: 59.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for hookguard-0.9.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9b7ba526307a5bb8ae2442eee3319814401981232626c78cfcc3f137a19fc18a
MD5 137ba905158e6fb416389eeb4ab619e1
BLAKE2b-256 8f7528f7a7edef72b728c5bdeb69ccce1e2e4ae3e65027d9c0024a5966447292

See more details on using hashes here.

Provenance

The following attestation bundles were made for hookguard-0.9.0-py3-none-any.whl:

Publisher: publish.yml on chaosxcode/hookguard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.9.1

2 files

This release

0.9.0 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page