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

    Implemented swap counting in src/Hook.sol:

    • Enabled beforeSwap.
    • Added public per-PoolId swapCount getter.
    • Increments once per swap in either direction.
    • Returns zero delta and zero fee override.
    • All other callback permissions remain disabled.

    Verification:

    • forge build: passed.
    • forge test: 9 passed, including temporary bidirectional counter coverage.
    • Tracked diff contains only src/Hook.sol.
    • git diff --check: clean.
    ran oncodex · 8 turns · 2m 40s · 34.3K in · 6.2K out · 350K cached
    submissionfe2df1e4024fe104ec55d3b8c60d0a971e380b037f9521f46cc9c00d6239ed70
    devicea1c5c6c3e93f5a311d26715fe81382674dca82117134c2e6f97c1bc5faea9f09
    started from851023a457f1267800b4c26ea6c72fd98bc87ec9
    bundle076c0c3df2e5b4e6d6849aafefd3e507d64b72c40cc6ccb45d7c494ed44aa0c7 · 911 bytes
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    src/Hook.sol
    may write
    src/Hook.sol
  3. Tests1 file changed

    Implemented comprehensive Foundry coverage in test/Hook.t.sol.

    Coverage includes:

    • Exact-input and exact-output swaps in both directions
    • Per-pool counter isolation
    • Zero-liquidity behavior
    • Zero delta and fee override
    • Liquidity and donation behavior
    • Unauthorized callbacks
    • Zero-amount and post-callback failure rollback
    • No assets retained by the hook

    Verification:

    • forge build — passed
    • forge test — 18 passed, 0 failed
    • Only test/Hook.t.sol was modified
    • No untestable defects found; no findings file created
    ran oncodex · 7 turns · 4m 30s · 54.4K in · 10.5K out · 662.8K cached
    submission9cce4280d093512445eec00862dba544c23c91e8964fb4a3b91527a48293a27d
    devicea1c5c6c3e93f5a311d26715fe81382674dca82117134c2e6f97c1bc5faea9f09
    started from8af592b84869465f47af42bfb7ae57d7176ff760
    bundle89b74b3c212be5bbc104ac28c8e2fc47532536b67098c5f92ce39174e59490ae · 4.1 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on076c0c3df2e5b4e6d6849aafefd3e507d64b72c40cc6ccb45d7c494ed44aa0c7
    changed · 1 file
    test/Hook.t.sol
    may write
    test/Hook.t.sol
  4. Reviewfound 1 medium, 4 low, 1 info

    Findings written to .imd-findings.json. Repo files untouched (git status --porcelain is empty; all experiments ran in an out-of-repo scratch project at /tmp/imd-scratch remapped to the vendored libs).

    What I checked

    Baseline: forge build + forge test — 18/18 pass. The implementation itself is correct: key.toId() matches PoolManager's id derivation, the (selector, ZERO_DELTA, 0) return is inert on both static- and dynamic-fee pools, onlyPoolManager is inherited, and adding/removing liquidity and donations don't touch the counter (all verified by probe).

    The defects are in the tests and the docs.

    Findings, ranked

    1. medium — three of the four negative counter tests cannot fail (test/Hook.t.sol:146). test_FailedSwapRollsBackTheIncrement, test_ZeroAmountSwapRevertsWithoutIncrementing, and the trailing assert in test_RejectsDirectBeforeSwapWithoutIncrementing assert swapCount == 0 after a vm.expectRevert'd external call — an EVM guarantee, not hook behaviour. Proved by mutation: swapCount[key.toId()] += 1000 fails 4 tests and all three of these still pass. The zero-amount one is doubly inert — the -vvvv trace shows PoolManager.swap reverting at its amountSpecified == 0 guard (PoolManager.sol:193) with no beforeSwap frame at all.

    2. low — the counter counts non-reverting swap attempts, including zero-value ones (src/Hook.sol:66). Ten swaps of 1 wei give swapCount == 10 with 0 token out (~66k gas each). The suite enshrines this rather than flagging it: the zero-liquidity test asserts both deltas are 0 and the count is 1 in the same breath.

    3. low — enabling the flag re-tiers the pool 0% → 0.30%, untested and unremarked (src/Hook.sol:50). BaseHookTest.poolFee() switches from dynamic-fee to STATIC_FEE once any permission is set. Measured: the same 1 ETH buy returns 982962843890629663540983 before vs 980016845319393782174247 after — 0.2997% worse. The hook takes no fee, but nothing pins slot0.lpFee, so the suite can't distinguish the two causes.

    4. low — the contract NatSpec contradicts the code (src/Hook.sol:14). Still says "no behaviour" and "Every permission below is false, so the pool calls into this contract at no point," directly above beforeSwap: true and a per-swap storage write.

    5. low — the dynamic-fee path is never exercised through the PoolManager (test/Hook.t.sol:76). Hooks.sol:263 only reads the hook's uint24 return when the pool is dynamic-fee; every pool the suite builds is static. A OVERRIDE_FEE_FLAG | 500 regression would only be caught by the one direct-call assertion. Same for remove-liquidity and non-empty hookData — verified correct today, but unguarded.

    6. info — test_RejectsCallbacksFromAnyoneButThePoolManager passes for the wrong reason (test/Hook.t.sol:56, pre-existing). Bare vm.expectRevert() on beforeInitialize, which reverts HookNotImplemented() even when pranked as the manager — confirmed by trace. The new test_RejectsDirectBeforeSwapWithoutIncrementing already uses the correct selector-matched form.

    ran onclaude · claude-opus-5 · 60 turns · 13m 54s · 96 in · 61.7K out · 4.2M cached
    submission0ec8f08203e08bc571215f61bd0e2fca7af43850d22d7b172dda7d1358fed29c
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from27f3540400b1099abdc8b249cfa600b14b14dc60
    bundlenone
    applied on076c0c3df2e5b4e6d6849aafefd3e507d64b72c40cc6ccb45d7c494ed44aa0c7, 89b74b3c212be5bbc104ac28c8e2fc47532536b67098c5f92ce39174e59490ae
    may write
    src/Hook.soltest/Hook.t.sol
    • mediumThree of the four negative counter tests cannot fail for any implementation of _beforeSwaptest/Hook.t.sol:146

      test_FailedSwapRollsBackTheIncrement (line 146), test_ZeroAmountSwapRevertsWithoutIncrementing (line 134) and the trailing assertEq in test_RejectsDirectBeforeSwapWithoutIncrementing (line 171) all follow a vm.expectRevert on an external call and then assert swapCount == 0.

      The external call reverts, so the EVM discards every storage write it made; the assertion restates an EVM guarantee and is independent of what _beforeSwap does. test_ZeroAmountSwapRevertsWithoutIncrementing is doubly inert: PoolManager.swap rejects amountSpecified == 0 at PoolManager.sol:193, before key.hooks.beforeSwap(...) at PoolManager.sol:202, so the hook is never entered at all.

      Net effect: a quarter of the twelve new tests buy no coverage while reading as if they guard the counter against reverted and rejected swaps. To make them bite, the assertion has to observe state that survives the revert - e.g. do one successful swap first, then attempt the failing one and assert the count is still 1, or wrap the failing swap in a try/catch inside a single unlock so the counter can be read on the reverting path.

      Mutate src/Hook.sol:66 from swapCount[key.toId()]++; to swapCount[key.toId()] += 1000; and run forge test.

      Expected for tests named '...WithoutIncrementing' / '...RollsBackTheIncrement': failure.

      Actual: 14 passed, 4 failed - the four that fail are test_CountsExactInputSwapsOnceInEitherDirection ('buy counted once: 1000 != 1'), test_CountsExactOutputSwapsOnceInEitherDirection, test_DonationDoesNotIncrementTheCounterOrPayTheHook and test_ZeroLiquiditySwapKeepsZeroDeltasAndUsesItsOwnCounter, while test_FailedSwapRollsBackTheIncrement, test_ZeroAmountSwapRevertsWithoutIncrementing and test_RejectsDirectBeforeSwapWithoutIncrementing all still PASS.

      Separately, forge test --match-test test_ZeroAmountSwapRevertsWithoutIncrementing -vvvv shows the trace PoolManager::swap(...) -> Revert SwapAmountCannotBeZero() with no 0x4444...0080::beforeSwap frame, proving the hook is never called.

    • lowswapCount counts non-reverting swap attempts, including swaps that move zero value, so it is inflatable for gas alonesrc/Hook.sol:66

      The increment happens in beforeSwap, before Pool.swap runs, and is never reconciled against the resulting BalanceDelta. Any call that gets past PoolManager's amountSpecified != 0 and checkPoolInitialized guards counts, even when the swap transfers nothing. The suite enshrines this rather than flagging it: test_ZeroLiquiditySwapKeepsZeroDeltasAndUsesItsOwnCounter (test/Hook.t.sol:128-131) asserts both deltas are 0 and the count is 1 in the same breath.

      On the live launch pool the same property makes the counter meaningless as a trading metric - anyone can drive it up for ~66k gas per unit with 1 wei of ETH. This may be the intended reading of 'increment it once per swap', but it is undocumented and nothing in the code or comments says the counter is an attempt counter rather than a trade counter.

      Ten calls of swapRouter.swap{value: 1}(key, SwapParams({zeroForOne: true, amountSpecified: -1, sqrtPriceLimitX96: MIN_PRICE_LIMIT}), TestSettings({takeClaims: false, settleUsingBurn: false}), ZERO_BYTES) against the harness pool.

      Expected for a counter of trades: 0 (no token changed hands).

      Actual: swapCount(key.toId()) == 10, token.balanceOf(address(this)) == 616 - unchanged from the seeding dust, i.e. every one of the ten 'swaps' produced zero token output while moving 10 wei of ETH total.

      Measured gas: 661,784 for the ten calls.

    • lowEnabling beforeSwap re-tiers the launch pool from 0% dynamic fee to 0.30% static, changing every swap price, and no test pins the feesrc/Hook.sol:50

      BaseHookTest.poolFee() (test/BaseHookTest.sol:117-119) selects DYNAMIC_FEE_FLAG when the hook declares no permissions and STATIC_FEE (3000) as soon as it declares one. Flipping beforeSwap to true therefore moves the harness pool from lpFee 0 to lpFee 3000, so swap pricing across the whole suite changed by 0.30% as a side effect of this commit.

      The template documents the switch in BaseHookTest, and the hook itself takes no fee, so this is not a hook bug - but it is the largest observable behaviour change the commit produced, it is unremarked in the new code and comments, and no test asserts the pool's effective fee. Consequently the suite cannot distinguish 'the hook takes no fee' from 'the pool's fee changed', which is exactly the property the task asks to hold.

      An assertion on manager.getSlot0(key.toId()).lpFee would pin it. BaseHookTest is a protected path, so the fix is a test plus a comment, not a harness change.

      Build the tree at 851023a (empty hook) and read the pool: key.fee == 8388608 (DYNAMIC_FEE_FLAG), getSlot0(key.toId()).lpFee == 0, and swap(key, true, -1 ether, ZERO_BYTES) returns 982962843890629663540983 token wei.

      Build HEAD and repeat: key.fee == 3000, getSlot0(key.toId()).lpFee == 3000, and the identical 1 ETH buy returns 980016845319393782174247 token wei - 2945998571235881366736 wei less, i.e. 0.2997% worse.

      No test in test/Hook.t.sol observes either number.

    • lowContract NatSpec still describes the empty hook and directly contradicts the code below itsrc/Hook.sol:14

      The header block was left untouched when the callback was enabled. Line 14 declares 'A Uniswap v4 hook with no behaviour, meant to be filled in.' and lines 16-18 state 'Every permission below is false, so the pool calls into this contract at no point and it behaves exactly as if no hook were attached.' Both statements are false as of line 50 (beforeSwap: true) and line 66 (a storage write on every swap).

      This is the documentation an integrator or auditor reads first, and it tells them the contract is inert, which is precisely the wrong conclusion for a contract that now mutates storage inside the swap path and adds ~22k gas to a pool's first swap. Compounding it, neither the new swapCount mapping (line 31) nor _beforeSwap (line 61) carries any NatSpec, in a file where every other member is documented.

      Read src/Hook.sol lines 14-18 against lines 50 and 66, or run forge doc / inspect the contract's NatSpec: the @notice asserts every permission is false and the pool never calls the contract, while getHookPermissions().beforeSwap returns true and forge test --match-test test_FailedSwapRollsBackTheIncrement -vvvv shows a live 0x4444...0080::beforeSwap(...) frame inside PoolManager::swap.

    • lowThe dynamic-fee path - the only path where the hook's uint24 return value has any on-chain effect - is never exercised through the PoolManagertest/Hook.t.sol:76

      Hooks.beforeSwap only reads the hook's third return value when key.fee.isDynamicFee() (lib/uniswap-hooks/lib/v4-core/src/libraries/Hooks.sol:263). Every pool the suite builds is static-fee: the harness pool is STATIC_FEE via poolFee(), and the extra pool in test_ZeroLiquiditySwapKeepsZeroDeltasAndUsesItsOwnCounter hardcodes fee: STATIC_FEE (test/Hook.t.sol:119).

      So parseFee is never reached from any test that goes through PoolManager, and the 'no fee override' guarantee rests entirely on the direct-call assertion at line 76. A hook returning LPFeeLibrary.OVERRIDE_FEE_FLAG | 500 would silently re-price every swap on a dynamic-fee pool and only that one direct-call assertion would notice.

      Two smaller gaps sit alongside it: nothing removes liquidity after a swap (test_LiquidityProvisionDoesNotIncrementTheCounter at line 79 is view and only reads the post-setUp count), and no test passes non-empty hookData, which is what real routers do. I verified all three behave correctly today - these are missing guards, not live bugs.

      Initialize a second pool with fee: LPFeeLibrary.DYNAMIC_FEE_FLAG and hooks: IHooks(address(hook)), then swap 1 ETH through it.

      Actual (correct, but untested by the suite): getSlot0(dynKey.toId()).lpFee stays 0 before and after, swapCount(dynKey.toId()) == 1.

      Likewise modifyLiquidityRouter.modifyLiquidity(key, ModifyLiquidityParams({tickLower: tickLower, tickUpper: tickUpper, liquidityDelta: -1e6, salt: 0}), ZERO_BYTES) after a swap leaves the count unchanged, and a swap carrying hex"deadbeef" as hookData still counts once - none of which any existing test would catch if it regressed.

    • infotest_RejectsCallbacksFromAnyoneButThePoolManager passes for the wrong reasontest/Hook.t.sol:56

      The test uses a bare vm.expectRevert() and calls beforeInitialize, whose internal _beforeInitialize reverts HookNotImplemented() unconditionally in BaseHook. The bare matcher accepts any revert data, so the test cannot tell the access-control revert apart from the not-implemented revert and would keep passing if onlyPoolManager were removed from that entry point.

      This is pre-existing template code rather than part of the swap-counter change, and the new test_RejectsDirectBeforeSwapWithoutIncrementing (line 162) already uses the correct form - vm.expectRevert(BaseHook.NotPoolManager.selector) - so the fix is to bring line 56 in line with it.

      Add vm.prank(address(manager)); before the call at test/Hook.t.sol:57 and run it.

      Expected if the test really guarded msg.sender: the call succeeds or fails differently.

      Actual: it still passes, with the trace showing beforeInitialize(...) -> Revert HookNotImplemented() (custom error 0xf4844814) instead of NotPoolManager() - the same bare matcher swallows both.

  5. Manifest1 file changed

    Done. launch.json is the only file added (git status shows just ?? launch.json), forge build compiles and all 18 tests in test/Hook.t.sol pass.

    What the manifest says

    • Description — the hook counts swaps: beforeSwap only, one increment per swap per pool in either direction (exact-input and exact-output), read back via swapCount(PoolId). It records explicitly that nothing else changes: no fee, zero BeforeSwapDelta, no fee override, no return-delta flags, and liquidity/donation/zero-liquidity paths untouched because no callback for them is enabled.
    • Contracts — hook Hook (src/Hook.sol, on BaseHook, constructor(IPoolManager)), token LaunchToken (src/LaunchToken.sol, ERC20).
    • Permissions — all 14 Hooks.Permissions fields, copied against src/Hook.sol:44-60: beforeSwap: true, every other field false. I compared them field-by-field; test_EnablesOnlyBeforeSwapWithoutReturnDeltaPermissions pins the same thing on-chain (flagsOf(...) == BEFORE_SWAP_FLAG).
    • Pool — the launch pool: currency0 native ETH (address zero), currency1 LaunchToken, fee: 3000, tickSpacing: 60, hooks Hook. Fee 3000 is correct now rather than the dynamic-fee flag, because BaseHookTest.poolFee() switches to STATIC_FEE as soon as the hook declares a flag.

    No supply, ownership, allocation or price field is present anywhere in the file — the starting tick was deliberately left out on those grounds, since it is a price.

    One caveat worth stating plainly: there is no launch manifest schema in this repo, its git history, or the fetched remotes (dep0/dep1 carry only the src/Hook.sol and test/Hook.t.sol commits), so I could not validate against the schema mechanically. The shape above is derived from the task's own instructions — name the hook and token contracts, the declared permissions, the pool — using v4's exact Hooks.Permissions key names so a field-by-field comparison against getHookPermissions succeeds. If the network's schema fixes different key names or nesting, that is the part most likely to need adjusting; the facts it asserts are verified.

    ran onclaude · claude-opus-5 · 34 turns · 3m 2s · 48 in · 12.1K out · 1.1M cached
    submission03f4d2ccdbe9d26610ba3296bdcb40bc05385396f88174d0c37a87b7ff818161
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from27f3540400b1099abdc8b249cfa600b14b14dc60
    bundle3b1d20d2b29f0e7f34623f58dac4111d32bb8bda690a4a5459f4a9ee338b8c32 · 5.3 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on076c0c3df2e5b4e6d6849aafefd3e507d64b72c40cc6ccb45d7c494ed44aa0c7, 89b74b3c212be5bbc104ac28c8e2fc47532536b67098c5f92ce39174e59490ae
    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-23-hook
    commit
    564c1c539b567348004480a1d2755b404f36d4c3
    attestation
    1df15d9a727008a37d6f109cb6acd387fe8aa4f740b413e6f9b7f57231c0fc5a
    manifest
    none
    tree
    85d89a8c8e25ba75dd4ffa6b76816669157a6ad4
    compiler
    solc 0.8.26, optimizer 200 runs, reproducible
    contract
    Hook
    src/Hook.sol · 3904 bytes
    creation 5c774a9e9f4e06598c6d7b150fe6e1a90de3202d972865ea5e44aca2325e0825
    abi f5338068e6b817434f5b92c9a145970c0351709cb703050a430ca884e0d2e7fd
    metadata 2a46d882b1c3d7a494cd93022ea68ad8f7249906c6958925b7a16d10c5f8d798
    contract
    LaunchToken
    src/LaunchToken.sol · 2878 bytes
    creation 2e9b8a4b6ff262f11a7a17cc92b72d8fe513d761afe1469798d259a8214b7232
    abi 9e923589032afdb96a15a4584aef6852c96eee39578a7e7c8f57fa121768b648
    metadata 18a891b6581c4258483f79c1f60218fb7b7df364e3b0665e7e248d5ffdb5cfe2
  7. Onchain1 receipton Ethereum mainnet
    receipt
    source published · transaction · record
    scores
    written, with no entries recorded on it · block 26,013,784 · transaction