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. Posted7 minto the first attempt
  2. Impl1 file changed

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

    What the hook does now

    • getHookPermissions() sets beforeSwap: true; everything else stays false (no return-delta flags).
    • mapping(PoolId => uint256) private _swapCount, keyed by pool id — the hook is a singleton across pools, so the tally has to be per-pool.
    • _beforeSwap increments the counter for key.toId() and returns (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0) — no delta, no fee override. Direction is not read, so one swap counts once either way.
    • swapCount(PoolId) public view returns (uint256) exposes it.

    I also fixed two doc lines in the header that referenced a HOOK_FLAGS constant the test harness no longer has (it derives the address from getHookPermissions itself).

    Verification — forge build clean, forge test 8/8 passing on the existing suite. In test/scratch/ I added 10 throwaway tests, all passing:

    • counter starts at zero, counts buys, sells (oneForZero), and exact-output swaps, one per swap
    • two pools sharing the hook keep separate tallies
    • adds, removes and donates do not bump it
    • against a hookless control pool with identical liquidity and fee tier, a buy produces byte-identical deltas — the hook takes nothing, and the hook holds zero ETH and zero token afterwards
    • the zero-liquidity case matches a hookless pool exactly (both return a zero delta; note it does not revert in v4 with the default price limit, so the swap counts — a swap call happened)

    One consequence worth flagging, from the harness rather than the hook: BaseHookTest.poolFee() returns DYNAMIC_FEE_FLAG only while the hook declares no permissions, so enabling beforeSwap flips the test pool to the 0.30% static tier. That is the template's intended behavior, and the existing tests still pass under it.

    ran onclaude · claude-opus-5 · 45 turns · 6m 19s · 82 in · 21.1K out · 2.3M cached
    submissionb7f5d9191836f81db9d968f4ab0c8ace9398df84f88cf1c66b85adc5808d0c84
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from851023a457f1267800b4c26ea6c72fd98bc87ec9
    bundleafb273c37afe64d6902f6879b2314da3a84b3fdb3af931d4f6692f29350acaff · 2 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    src/Hook.sol
    may write
    src/Hook.sol
  3. Tests1 file changed

    Done. forge build and forge test both pass from clean: 32 tests, 0 failures, with only test/Hook.t.sol modified.

    What the tests cover

    I kept the six inherited tests intact and added 26, in four groups:

    Permissions — exactly one flag is set and it is beforeSwap; beforeSwapReturnDelta and afterSwap are explicitly off; every other callback still reverts HookNotImplemented even when the manager itself asks; and beforeSwap is called directly (impersonating the manager) to read its three return values — selector, ZERO_DELTA, fee override 0.

    Counting — buys, sells, exact-output swaps, arbitrary hookData, swaps from a third-party sender, and a fuzz over 1–15 swaps.

    Per-pool keying — a second pool on the same hook counts separately; an unknown pool id reads zero; a pool with no hook installed is never counted.

    Failure paths (the count must not move):

    • a forged direct beforeSwap call reverts NotPoolManager and leaves the count at zero
    • a swap that reverts after the hook has already incremented (PriceLimitAlreadyExceeded) rolls the increment back
    • a zero-amount swap is rejected upstream of the hook
    • initialization, liquidity add/remove, and donations do not count

    "Changes nothing else" — each operation runs against twin pools identical but for the hook, asserting results match to the wei: swaps (both directions, exact-in and exact-out), liquidity withdrawal with earned fees, donations, and empty-pool no-ops. Plus: the hook holds no ETH, no token, and no ERC-6909 claims after trading.

    I mutation-tested the suite in test/scratch/ against five broken hooks (no counting, one global counter, double counting, one-direction-only, and one that skims 1%). Every mutant failed — the global-counter mutant is caught by exactly the two per-pool tests, and the skimming mutant by the permissions test plus all three twin-pool comparisons. A sixth mutant (unguarded callback) turned out not to be expressible: BaseHook.beforeSwap is non-virtual, so the guard cannot be dropped while the hook extends BaseHook.

    One finding reported

    .imd-findings.json records one low: swapCount counts beforeSwap invocations, not trades. Two measured repros — 50 consecutive 1-wei swaps on the launch pool give swapCount == 50 for 50 wei and zero token received; and a swap against a liquidity-free pool returns BalanceDelta(0, 0), refunds the full 1 ETH, and still counts.

    The code matches the brief's literal wording ("increment once per swap"); what the brief doesn't settle is whether a call that trades nothing is a swap. Because a fix would have to choose a rule, I deliberately made no count assertion in test_SwapsAgainstAnEmptyPoolAreNoOpsAsTheyAreWithoutTheHook — it pins only the pool-level behaviour (zero deltas, full refund, identical to the hookless twin) and leaves the counting semantics to the report rather than blessing them.

    ran onclaude · claude-opus-5 · 67 turns · 13m 57s · 98 in · 63.9K out · 4.8M cached
    submission0de6d8aabe2056e6b4e935bb8ec8c2bf7ed6c3c5433333ebc37a945f36547a55
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started fromc0b9d1640f5d32d12e611db4afec257d9fbcc604
    bundled8885c47ab417e0bfe23ce7fb32b340820bce51d08da8e56941e9a266719beb0 · 9.3 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied onafb273c37afe64d6902f6879b2314da3a84b3fdb3af931d4f6692f29350acaff
    changed · 1 file
    test/Hook.t.sol
    may write
    test/Hook.t.sol
    • lowswapCount counts swap calls that trade nothing, so it can be inflated for gas alonesrc/Hook.sol:55

      _beforeSwap increments unconditionally, before the pool has computed anything, so the counter measures beforeSwap invocations rather than swaps that moved value.

      Uniswap v4 lets a swap succeed while trading nothing, in two ways that are both reachable by anyone: on a pool with no active liquidity the price simply walks to sqrtPriceLimitX96, finds nothing to trade against and settles a zero delta; and on a funded pool a 1-wei exact-input swap is entirely consumed by the 0.30% LP fee and returns zero output. Both increment the count.

      The brief said 'increment it once per swap in either direction', which the code does on the literal reading -- what the brief does not settle is whether a call that trades nothing is a swap, so this is reported rather than pinned by an assertion.

      Consequence: anything that reads swapCount as a measure of trading activity (a vesting milestone, a launch-graduation trigger, an off-chain analytics feed) can be driven to an arbitrary value for gas cost alone. The hook itself never consumes the count, so nothing in this contract is directly exploitable today.

      A fix has to pick a rule and say so -- skip the increment when the pool has no active liquidity, or count in afterSwap and skip a zero BalanceDelta -- and picking one in the tests alone would be guesswork, which is why test/Hook.t.sol asserts only the pool-level behaviour of the empty-pool case (zero deltas, full refund, identical to the same pool with no hook) and deliberately makes no assertion about the count there.

      Both run against the launch pool built by test/BaseHookTest.sol (ETH/LaunchToken, fee 3000, tickSpacing 60, opened at tick 138000 with the full supply seeded below it).

      A) Funded pool, 1 wei at a time. Run swap(key, true, -1, ZERO_BYTES) fifty times. Each call returns BalanceDelta(amount0 = -1, amount1 = 0): one wei of ETH taken, zero token returned. Expected, if the counter tracks trades: swapCount(key.toId()) == 0. Actual: swapCount(key.toId()) == 50, for a total outlay of 50 wei plus gas, with 0 token received. Measured, not inferred.

      B) Pool with no liquidity, which anyone can open on this hook. Initialize PoolKey({currency0: CurrencyLibrary.ADDRESS_ZERO, currency1: , fee: 3000, tickSpacing: 60, hooks: hook}) at TickMath.getSqrtPriceAtTick(138000) and add no liquidity, then call swap(k, true, -1 ether, ZERO_BYTES). The swap does not revert: it returns BalanceDelta(0, 0) and refunds the entire 1 ETH, which is exactly what the same pool carrying no hook does. Expected: swapCount(k.toId()) == 0. Actual: swapCount(k.toId()) == 1, at a net cost of 0 ETH. The oneForZero direction behaves the same way, so an empty pool yields one count per direction before the price is pinned at the limit and further swaps revert with PriceLimitAlreadyExceeded.

  4. Reviewfound 1 low

    Review complete. Wrote one substantiated low-severity finding to .imd-findings.json.

    Validation passed: forge fmt --check, forge build, and all 32 tests. No source or test files were changed.

    ran oncodex · 4 turns · 2m 43s · 53.1K in · 5.7K out · 539.6K cached
    submission3f86b35e42b02c15e0ea038ce6aa2f4a5cc64746c9eb6733986fd3eea6391647
    devicea1c5c6c3e93f5a311d26715fe81382674dca82117134c2e6f97c1bc5faea9f09
    started from54ec527ea8b368a835c879b1037f75e434d23203
    bundlenone
    applied onafb273c37afe64d6902f6879b2314da3a84b3fdb3af931d4f6692f29350acaff, d8885c47ab417e0bfe23ce7fb32b340820bce51d08da8e56941e9a266719beb0
    may write
    src/Hook.soltest/Hook.t.sol
    • lowZero-liquidity no-op swaps inflate the per-pool countersrc/Hook.sol:55

      The counter is incremented in beforeSwap before the pool determines whether any assets can be exchanged. On an initialized pool with no liquidity, Uniswap v4 accepts a nonzero swap request, emits a Swap with amount0 = amount1 = 0, and refunds the caller, but this hook still persists an increment. This makes the observable counter differ in the zero-liquidity case that is required to retain empty-hook behavior and lets callers inflate it without executing a trade.

      Initialize a pool using this hook at START_TICK but add no liquidity.

      Starting from swapCount(key.toId()) == 0, call PoolSwapTest.swap with zeroForOne=true, amountSpecified=-1 ether, and sqrtPriceLimitX96=MIN_PRICE_LIMIT.

      The call succeeds and returns BalanceDelta(amount0=0, amount1=0), with the offered 1 ether fully refunded, but swapCount(key.toId()) is 1; expected it to remain 0 because no swap amount executed.

      Calling again in the opposite direction with zeroForOne=false, amountSpecified=-1 ether, and sqrtPriceLimitX96=MAX_PRICE_LIMIT returns another zero delta and raises the count to 2 instead of leaving it at 0.

  5. Manifest1 file changed

    Done. launch.json is the only file added.

    What I verified first — the hook was already implemented by an earlier contributor (src/Hook.sol), so this job was the manifest. I pinned every claim the manifest makes with a scratch test (test/scratch/ManifestFacts.t.sol, excluded from the repo via .git/info/exclude) rather than reading them off the source:

    • getHookPermissions() returns beforeSwap: true and all 13 other fields false — flagsOf(p) == BEFORE_SWAP_FLAG exactly
    • the pool key is native ETH / LaunchToken, fee 3000, tickSpacing 60, hooks = the hook (fee is the static tier, not dynamic, because BaseHookTest.poolFee() switches once the hook declares any flag)
    • the getter is swapCount(PoolId), ABI signature swapCount(bytes32)

    forge build compiles clean; forge test is 35/35 passing (32 inherited + 3 scratch). jq -e . parses the manifest.

    The manifest names the two contracts, mirrors all 14 permission fields verbatim, and describes the pool as its v4 PoolKey. It carries no supply, ownership, allocation or price field — the starting tick and the full-supply seeding live in the test harness and belong to the launch policy, so they are deliberately absent.

    One assumption worth flagging: the launch manifest schema is not in this working tree, and the sandbox confines me to the repo, so I could not validate against it mechanically. I inferred the shape from the task's three named requirements — hook contract, token contract, declared permissions, and the pool — as top-level hook / token / pool objects plus a name and description. The content is verified against the code; the key names are my best reading of the schema. If the gate rejects it on structure, the field values should all carry over unchanged to whatever the real key names are.

    ran onclaude · claude-opus-5 · 41 turns · 3m 30s · 58 in · 13.7K out · 1.4M cached
    submission08fefe0703ea660230767915e22f3925e61e640c06c22a081941007e8c190acc
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from54ec527ea8b368a835c879b1037f75e434d23203
    bundlef9212df4adc9766ba0f54e7cb6bdfbc5279691dbd152eaaefffbb81e7ea9c9c7 · 10 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied onafb273c37afe64d6902f6879b2314da3a84b3fdb3af931d4f6692f29350acaff, d8885c47ab417e0bfe23ce7fb32b340820bce51d08da8e56941e9a266719beb0
    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
    4 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-24-hook
    commit
    fe3e129a17b6f8dda95f9742a1d12af460ded283
    attestation
    27144bad894503b20829ecc830543155053f856a9379033b89c99f97c638a216
    manifest
    none
    tree
    1d21c43ce91d3bf14cefdec18f5bb7af35c1bc52
    compiler
    solc 0.8.26, optimizer 200 runs, reproducible
    contract
    Hook
    src/Hook.sol · 3858 bytes
    creation 03fa2ce5c88e372f003631f5e6106be49398cd09fcbf2ffeeb734c5e44236f8c
    abi 33f894e9da08fb4934a9770fd0b07bf538668f435fcd2f6e24f2c157e58718a5
    metadata ae1e888bf82f62eb2c480c2fd592ecf42855473a68800d2be75575733ab94bb0
    contract
    LaunchToken
    src/LaunchToken.sol · 2878 bytes
    creation 2e9b8a4b6ff262f11a7a17cc92b72d8fe513d761afe1469798d259a8214b7232
    abi 9e923589032afdb96a15a4584aef6852c96eee39578a7e7c8f57fa121768b648
    metadata 18a891b6581c4258483f79c1f60218fb7b7df364e3b0665e7e248d5ffdb5cfe2