Enable the beforeSwap callback, keep a per-pool counter keyed by pool id, increment it once per swap in either direction, and expose it through a public view function.

Change nothing else: take no fee, return zero deltas, and leave liquidity, donations and the zero-liquidity case behaving exactly as they do with the empty hook. Keep it small — this is one counter and one getter.

Not published · Token

No manifest. kind: Invalid discriminator value. Expected 'univ4_hook'

Work

  1. Posted3 minto the first attempt
  2. Impl1 file changed

    Done. src/Hook.sol is the only file changed.

    What it does

    • getHookPermissions flips beforeSwap to true; every other flag — including beforeSwapReturnDelta — stays false.
    • mapping(PoolId => uint256) private _swapCount holds one counter per pool.
    • _beforeSwap increments _swapCount[key.toId()] once per call (direction-agnostic, since it ignores SwapParams) and returns (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0) — no fee taken, no delta claimed.
    • swapCount(PoolId) is the public view getter.

    I also corrected two doc lines that pointed at a HOOK_FLAGS constant which no longer exists — test/BaseHookTest.sol now derives the address from getHookPermissions itself, so no test-side change was needed for the new flag.

    Verification — forge build clean; forge test 17/17 pass (8 existing in test/Hook.t.sol, 9 written in test/scratch/). The scratch tests cover: counter starts at zero, increments per swap in both directions, stays independent per pool, unknown pool reads zero, hook takes no ETH or token, liquidity changes and donations don't bump it.

    Two behaviors worth flagging, both checked against a bare hookless pool for equivalence:

    • A buy on a hooked pool produces byte-identical deltas to the same buy on an identical hooks: address(0) pool.
    • The zero-liquidity case does not revert in v4 — the swap returns zero deltas on both the hooked and hookless pool. The hook still counts that call as a swap, since beforeSwap fires.
    ran onclaude · claude-opus-5 · 28 turns · 3m 20s · 48 in · 15K out · 1.1M cached
    submissionc6d8205afce5df2950a540c08411a6196eeecc6d81f1128bee778d6bab31bd3a
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from6b238a0048e85ade820dc964f3e6e731dcc9abd6
    bundlebd982ee0bc4a589a20ca828a658d1767bb4e0aac372ad889e0d7eb1b2f93e65d · 2.1 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    src/Hook.sol
    may write
    src/Hook.sol
  3. Tests1 file changed

    Done. Clean build, 37/37 passing, only test/Hook.t.sol modified.

    What I wrote

    test/Hook.t.sol — kept the six existing accepted tests verbatim and added 31, in four groups:

    Counting works — one per swap; either direction; exact-input and exact-output; from any caller; through any router; regardless of hookData (fuzzed); regardless of size (fuzzed); cumulative over a fuzzed sequence of buys and sells.

    Keyed per pool — two launch pools sharing the hook keep separate tallies; two fee tiers on the same token pair keep separate tallies; an unknown pool id reads zero (fuzzed) rather than reverting.

    Failure paths (the majority, since that's where a counter actually breaks) — a direct beforeSwap call from a non-manager reverts and cannot inflate the tally; a zero-amount swap reverts before the hook; a swap past an already-exceeded price limit reverts after the hook has incremented, and the increment goes back with it; an uninitialized pool; a swap that prices fine but can't be settled. Plus liquidity add, liquidity remove, donate, and pool initialization — none of which count.

    Neutrality — measured against a control: an identically shaped launch pool, same fee tier, same seeded liquidity, no hook. Swap deltas match exactly in both directions and both exactness modes; add/remove liquidity deltas match; donations match, including both pools refusing one identically when no liquidity is in range; the zero-liquidity swap matches. The hook holds no ETH, no token, and no ERC-6909 claims.

    Verification beyond "it passes"

    I built seven broken hooks in test/scratch/ and ran the delivered suite against each: a global counter instead of a mapping, a counter that only counts buys, one that double-counts, one that skims 1% via beforeSwapReturnDelta, one that declares an extra permission, one that skips counting when hookData is non-empty, and one that filters on the sender argument. All seven fail, each on a named assertion. The last two exposed real gaps — I added testFuzz_CountsSwapsWhateverTheHookDataSays and test_CountsSwapsThroughAnyRouter to close them.

    Reported rather than blessed

    One info finding in .imd-findings.json: swapCount counts swap calls, not filled trades. v4 doesn't reject a swap against a pool with no in-range liquidity — it walks the price to the limit, returns a zero delta, and doesn't revert. That call is counted, as is a swap so small the fee rounds the fill to zero. The hook can't tell these apart without afterSwap, which is outside this task. I flagged it because two of my tests pin that behaviour as correct and the name swapCount reads like a count of trades.

    Two things worth knowing, neither a defect:

    • Enabling a permission moves the pool from dynamic-fee-at-0% to a static 0.30% tier — that's BaseHookTest.poolFee() doing what its comment says, not the hook. Swap output is therefore smaller than the empty-hook template gave, and the difference is LP fee, not hook fee. I pinned it in test_PoolIsAnOrdinaryStaticFeeTier and made every neutrality comparison use a control on the same tier, which isolates the hook from the tier.
    • beforeSwap's sender is the router, never the trader behind it — so test_CountsSwapsFromAnyCaller alone couldn't catch a sender-filtering bug, which is why the second-router test exists.
    ran onclaude · claude-opus-5 · 54 turns · 15m 0s · 94 in · 67.3K out · 4.8M cached
    submissione96c0cfbaa69cb9476786787a0a3b3db99ba627ff259cb661c5cae577d8e74f2
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from22d4c8035993e8f032817ded5307fb79ebc22d56
    bundle8f27f365b57bd0264bb285b1c470812554e687731d3f9cf50579022723d838fd · 11 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied onbd982ee0bc4a589a20ca828a658d1767bb4e0aac372ad889e0d7eb1b2f93e65d
    changed · 1 file
    test/Hook.t.sol
    may write
    test/Hook.t.sol
    • infoswapCount counts swap calls, not filled trades, and is permissionlessly inflatablesrc/Hook.sol:87

      _beforeSwap increments unconditionally, before the pool has computed anything. v4 does not reject a swap against a pool with no in-range liquidity: it walks the price to the limit, moves no value, returns a zero BalanceDelta and does not revert. That call is counted. The same holds for a swap so small the LP fee rounds the fill to zero.

      Because beforeSwap runs ahead of Pool.swap, the hook cannot distinguish these from a real trade without reading the swap result, which needs afterSwap - out of scope for this task, which asked for one counter and one getter. Reported rather than asserted silently because the tests that cover this case pin the current behaviour, and 'swapCount' reads like a count of trades.

      Two consequences for anything that later consumes this number: it is an upper bound on trades, not a count of them; and since anyone may swap on any pool, and anyone may open their own pool on a public hook, it is cheap for an outsider to move.

      Neither is a departure from the stated task - the task says 'increment once per swap' and a swap call is what is being counted - but a caller keying a decision off swapCount (a launch milestone, a fee schedule, an unlock) would be reading it wrong.

      Initialize a pool with the hook installed and no liquidity: currency0 = native ETH, currency1 = LaunchToken, fee = 10000, tickSpacing = 60, at tick 138000.

      Call PoolManager.swap(zeroForOne = true, amountSpecified = -1e18, sqrtPriceLimitX96 = MIN_SQRT_PRICE + 1).

      Observed: the call succeeds, the returned BalanceDelta is (0, 0), no ETH is taken and no token is given, the pool price ends at MIN_TICK - and swapCount(poolId) returns 1.

      Expected of a count of trades: 0.

      Identical against a pool with no hook except for the counter, so pool behaviour is unchanged; only the tally is affected.

      Same outcome for amountSpecified = -1 (one wei) on the funded launch pool, where the 0.30% fee consumes the input and the fill is zero.

      Asserted as-is by test_SwappingAPoolWithNoLiquidityMatchesAnUnhookedPool and testFuzz_CountsOnePerSwapWhateverTheSize in test/Hook.t.sol.

  4. Reviewfound nothing

    Review complete. No substantiated defects found.

    Wrote .imd-findings.json with {"findings":[]}.

    Validation: formatting and build passed; all 37 tests passed with 1,000 fuzz runs. No Solidity files were changed.

    ran oncodex · 5 turns · 2m 7s · 55.9K in · 4.2K out · 555.3K cached
    submissiona4458c1cfc56137b749d0242d25fe36175f204559d19a6086eb6e999c61ecf2b
    devicea1c5c6c3e93f5a311d26715fe81382674dca82117134c2e6f97c1bc5faea9f09
    started from1938df8d9c946b3f7e7c88bb76d360dd3fe6d5d0
    bundlenone
    applied onbd982ee0bc4a589a20ca828a658d1767bb4e0aac372ad889e0d7eb1b2f93e65d, 8f27f365b57bd0264bb285b1c470812554e687731d3f9cf50579022723d838fd
    may write
    src/Hook.soltest/Hook.t.sol
  5. Manifest1 file changed

    launch.json is written. Verification:

    forge build   Compiler run successful
    forge test    37 passed; 0 failed; 0 skipped
    git status    ?? launch.json          (nothing else touched)
    

    What it says

    hookcontract Hook, src/Hook.sol, getter swapCount(PoolId poolId) returns (uint256)
    hook.permissionsall 14 fields, beforeSwap: true and every other one false
    tokencontract LaunchToken, src/LaunchToken.sol, "Launch Token" / LAUNCH, 18 decimals
    poolcurrency0 address zero (native ETH), currency1 LaunchToken, fee 3000, tickSpacing 60, hooks Hook

    The permission block is a field-for-field copy of the struct literal at src/Hook.sol:47-62 — same 14 names, same values, same order — and test_EnablesBeforeSwapAndNothingElse (passing) asserts that struct is what the deployed hook actually returns, so the manifest matches the implementation rather than the source alone.

    I left out supply, ownership, allocation and price entirely, including the pool's starting tick, since that is price by another name. The 0.30% fee tier is in there as a pool parameter: it is what BaseHookTest.poolFee selects once the hook declares any permission, so it describes the pool this hook is for rather than a launch price.

    One thing to flag. The launch manifest schema is not in this working tree — no *.json exists anywhere in the repo, nothing under src/, test/, lib/ or git history mentions a schema or manifest, and I can't fetch one (no network commands). So the field layout above is my reading of the task's own sentence — "name the hook and token contracts, the permissions … and the pool it is for" — expressed as the most conventional JSON shape for it, not something I validated against a real schema. The contents are verified against the code; the key names and nesting are an informed guess. If the schema turns out to want a flat contracts: { hook, token } plus top-level permissions instead of the nested hook/token objects, that is a rename away and nothing about the described facts changes.

    ran onclaude · claude-opus-5 · 37 turns · 3m 49s · 50 in · 15.9K out · 1.4M cached
    submission3ae70c68f0832f2c6d923c3661e28739d92323d08631b9cf28908d3a9b081bf3
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from1938df8d9c946b3f7e7c88bb76d360dd3fe6d5d0
    bundle77a539932e4b100e765a7dcb59c4b35833db75b3e606e9d6c0be0216c5c807d2 · 12 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied onbd982ee0bc4a589a20ca828a658d1767bb4e0aac372ad889e0d7eb1b2f93e65d, 8f27f365b57bd0264bb285b1c470812554e687731d3f9cf50579022723d838fd
    changed · 1 file
    launch.json
    may write
    launch.json
  6. DeployedChain 1 launch from before policy v2; nothing is being deployed to mainnet.
    rebuilt
    Hook, LaunchToken · verifier 0.1.0 · solc 0.8.26
    gates
    3 of 7 passed
    • provenance
    • findings
    • independent review
    • bytecode
    • protected invariants
    • manifest
    • economics
    abandoned
    chain 1 launch from before policy v2; nothing is being deployed to mainnet
    proof
    commit, attestation, manifest, tree, per-contract hashes
    repository
    Identity-md/launch-22-hook
    commit
    1902c4b50dc0950391c7c4523d87ac7ef60a3fe8
    attestation
    58f86b0f078f8c7c7028f99e51221fa876b66c3843895b3b9cd588777499bc38
    manifest
    none
    tree
    5f8d7d48a93bc1852d732f4c5e06dfb7297618e5
    compiler
    solc 0.8.26, optimizer 200 runs, bytecode_hash ipfs, not reproducible
    contract
    Hook
    src/Hook.sol · 3899 bytes
    creation 55afe7df6c1a95f4c9a0865f73716a041b2c562a530f13974790f581acc94d57
    abi 33f894e9da08fb4934a9770fd0b07bf538668f435fcd2f6e24f2c157e58718a5
    metadata 0dc8dd3748d09db640c128251a56bfb0e3fd8b78c70a196140129ce272742a02
    contract
    LaunchToken
    src/LaunchToken.sol · 2919 bytes
    creation c5323754b7e1a45be3a35c583ca53442c0dd534f3f0d16575ddac8d3898fd5f3
    abi 9e923589032afdb96a15a4584aef6852c96eee39578a7e7c8f57fa121768b648
    metadata 15c9b1daf6ea04f280ad680ad90585c985b612de3f8c2f087668c3ddb35a1de1