Write a self-contained Uniswap v4 hook on the starter's BaseHook that, for each pool, maintains three read-only records: the total number of swaps observed, the number of distinct blocks in which at least one swap was observed, and the block number of the most recent observed swap. A second swap within the same block must increment the swap count but must not increment the distinct-block count. Expose all three through read-only view functions.

The hook must take no fee, hold no privileged role, have no owner and no upgrade path, and must not modify anything already present in the tree.

A separate worker writes the tests using the existing launch-pool harness, covering at minimum: the first swap in a fresh pool sets all three records consistently; a second swap in the same block raises the swap count and leaves the distinct-block count unchanged; a swap in a later block raises both; records for two different pools do not interfere with each other; and reads for a pool that has never swapped behave sanely rather than reverting ambiguously.

A third worker then reads the implementation and the tests as an attacker and writes nothing, reporting what the tests would rather not have examined.

Work

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

    Done. One new file, nothing else touched.

    src/SwapCadenceHook.sol

    A BaseHook with afterSwap: true and every other permission off, keyed per PoolId:

    readreturns
    swapCount(PoolId)every swap the manager reported for that pool
    swapBlockCount(PoolId)distinct blocks that held at least one of them
    lastSwapBlock(PoolId)block number of the most recent one

    The record is a single packed slot (uint128 swaps, uint64 blocks, uint64 lastBlock) — one SLOAD and one SSTORE per swap. uint64 fits blocks and active-block count because neither can exceed the chain's block height; swapCount gets the wider field because swaps-per-block is unbounded. Both increments are checked arithmetic, so nothing wraps silently.

    Two design points a tester will trip on otherwise, both documented in the file:

    • The distinct-block test is swapCount == 0 || lastSwapBlock != block.number, not the shorter lastSwapBlock != block.number. The short form reads an unwritten lastSwapBlock as a real previous swap and loses the block for a first swap landing in block zero. I verified this is a real difference, not a hypothetical — a scratch test rolls to block 0 and swaps.
    • lastSwapBlock on a never-swapped pool returns 0 rather than reverting. An unswapped pool is ordinary state, and a revert would be indistinguishable from the pool not existing. Callers distinguish "never" from "swapped" via swapCount.

    No fee: afterSwapReturnDelta is off, so v4's callHookWithReturnDelta discards the returned delta — taking a fee is impossible rather than merely unintended. No owner, admin, pause, proxy or selfdestruct; afterSwap under onlyPoolManager is the only writer.

    Verification

    forge build clean. forge test — 32 passed, 0 failed: the starter's 8 in test/Hook.t.sol (untouched, still green) plus 24 in test/scratch/. Those cover all five scenarios the test worker is asked for, plus: sell-direction swaps count, liquidity changes don't, the hook holds no ETH or token after a swap and the swapper pays exactly what it offered, direct afterSwap calls revert and write nothing, block-zero first swap, and a 256-run fuzz over swap/block schedules asserting blocks <= swaps and monotonicity throughout.

    I also copied the file into a fresh git clone of the repo (no test/scratch/) and built there, confirming it compiles offline against the committed lib/ with no added dependency and no submodule.

    ran onclaude · claude-opus-5 · 19 turns · 3m 48s · 28 in · 16.9K out · 765.9K cached
    submission7e5177385375a57dc81a8caafd884c0d6378f6a2aec0b650c60253e47f97f19f
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from851023a457f1267800b4c26ea6c72fd98bc87ec9
    bundlead3103fc292b67578026183392ba8c87e2493991960782c21fcfd229fbdd4f16 · 3.1 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    src/SwapCadenceHook.sol
    may write
    src/SwapCadenceHook.sol
  3. Tests1 file changed

    Done. forge build is clean and forge test passes 43/43 (35 new, 8 pre-existing).

    What I wrote

    test/SwapCadenceHook.t.sol — 35 tests on the existing BaseHookTest launch-pool harness, pointed at the hook by overriding hookArtifact() alone. Four groups:

    Shape (7) — the claims the contract makes about itself: exactly one permission flag, none of the four return-delta flags (which is why it can't take a fee — the address bits make it structurally impossible, not merely unintended), the swapper's delta is untouched, the hook holds no ETH/token/ERC-6909 claim, it rejects ETH sent to it, no admin selector answers, and a PUSH-skipping bytecode walk shows no DELEGATECALL/CALLCODE/SELFDESTRUCT/CREATE/CREATE2.

    The three records (13) — the required cases plus block-zero (vm.roll(0)), the 2**64-1 boundary, sells, exact-output, a stranger's swap, and a one-wei swap.

    Isolation (5) — two live pools on one hook, including interleaved in the same block; fuzzed unknown PoolIds and the zero id read zero without reverting.

    What must not be counted (7) — a swap that reaches afterSwap and then dies at settlement (the hook writes before settlement, so only the revert undoes it), zero-amount swaps, uninitialized pools, opening/donating/moving liquidity, afterSwap from a non-manager, and every other IHooks entry point reverting even when the manager itself calls it.

    Plus 2 fuzz tests over swap-per-block patterns and the stated invariants.

    I checked the suite isn't vacuous by running it against six mutated copies of the hook in test/scratch/. All six were caught. Notably, dropping the swapCount == 0 || guard is caught by exactly one test — the block-zero one — so that test is load-bearing rather than decorative.

    What I reported instead of asserting

    .imd-findings.json (the harness excludes it from git via .git/info/exclude, so it's collected out-of-band):

    1. low — block.number is narrowed to uint64 on both the store and the same-block comparison, silently. Swap at block 1, vm.roll(2**64 + 1), swap again: expected 2 / 2 / 18446744073709551617, actual 2 / 1 / 1 — a swap in a genuinely distinct block doesn't raise swapBlockCount. Verified, along with the control at 2**64 - 1 giving the correct 2 / 2 / …. The reachability argument in the NatSpec is sound, hence low; I filed it because the same comment claims checked arithmetic means nothing "can wrap silently," and that guarantee covers the counters but not this narrowing, which is the part that actually fails.

    2. info — a one-wei swap returns amount1 == 0 yet moves all three records exactly like a real trade. Ten of them cost 10 wei plus gas and produce an advertised cadence of 10 swaps/block. The counter is faithful to its stated definition, so this isn't a counting bug; the gap is between what's counted and the "distinguishes steady trickle from burst" reading the NatSpec invites. It can't be fixed inside the accepted spec, so it's reported rather than tested around.

    ran onclaude · claude-opus-5 · 51 turns · 13m 20s · 102 in · 57.6K out · 4.5M cached
    submission5229ba59b322ffd9fd4779e5485fb8b1e40c92fc0498f08462509306e39ceceb
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from7ce18ff7e7b9146d7e48bccb080692cceebfe49d
    bundle2d7cf4d617488e634681d8902253b36f034c523f78d50fc9eee606b25053502b · 12 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied onad3103fc292b67578026183392ba8c87e2493991960782c21fcfd229fbdd4f16
    changed · 1 file
    test/SwapCadenceHook.t.sol
    may write
    test/SwapCadenceHook.t.sol
    • lowblock.number is silently narrowed to uint64 on both the store and the same-block comparison, so past 2**64 blocks a swap in a new block does not raise swapBlockCountsrc/SwapCadenceHook.sol:137

      _afterSwap decides whether a swap opens a new active block with cadence.lastSwapBlock != uint64(block.number), and then stores uint64(block.number). Both conversions truncate rather than revert. Once block.number exceeds 264 - 1, two swaps whose block numbers are congruent mod 264 compare equal, so the second is counted as a swap but not as a distinct block, and lastSwapBlock reports a block number that is not the one the swap happened in. This breaks the contract's stated invariant that swapBlockCount is 'how many distinct blocks contained at least one of those swaps'.

      The NatSpec at src/SwapCadenceHook.sol:52-57 argues the widths are safe and adds that 'Both counters are incremented with checked arithmetic, so neither can wrap silently even if that reasoning is ever wrong; the increment would revert instead.' That guarantee covers swapCount and swapBlockCount, but not this narrowing: the narrowing is the part that fails, and it fails silently rather than reverting. The reachability argument itself is sound -- no chain will reach 2**64 blocks -- so this is reported as low rather than as a live risk. It is reported at all because it is the one place where the code is not what its own safety argument claims, and because the fix is local (compare and store the full uint256, or revert on block.number > type(uint64).max).

      The test suite does not assert this behaviour. test/SwapCadenceHook.t.sol::test_TracksTheHighestBlockTheRecordCanHold pins the top of the range that does work (2**64 - 1) and stops there, deliberately, so that nothing in the suite blesses the behaviour above it.

      Foundry, against the existing launch-pool harness (test/BaseHookTest.sol) with hookArtifact() pointed at SwapCadenceHook:

      swap(key, true, -1 ether, ZERO_BYTES);          // block.number == 1
      
      // swapCount 1, swapBlockCount 1, lastSwapBlock 1
      
      vm.roll((uint256(1) << 64) | 1);                // block 18446744073709551617
      
      swap(key, true, -1 ether, ZERO_BYTES);
      

      Expected: swapCount 2, swapBlockCount 2, lastSwapBlock 18446744073709551617.

      Actual (observed): swapCount 2, swapBlockCount 1, lastSwapBlock 1.

      The swap in the second, genuinely distinct block raised swapCount but not swapBlockCount, and lastSwapBlock still reports block 1. Control: the same two-swap sequence at blocks 1 and 264 - 1 gives the expected 2 / 2 / 18446744073709551615, so the divergence begins exactly at 264.

    • infoswapCount counts swaps regardless of size, so the 'swaps per active block' figure the contract advertises can be set to any value for gas alonesrc/SwapCadenceHook.sol:23

      The contract documents its output as a metric: 'swapCount / swapBlockCount is a pool's swaps per active block, which distinguishes steady trickle from burst.' The counter itself is faithful to its narrower definition -- 'every swap the pool manager has reported' says nothing about size -- so this is not a counting bug and no test here asserts otherwise. The gap is between what is counted and what the documentation invites a reader to conclude from it.

      Because the hook takes no fee and holds no privileged role, a swap that moves one wei of input and zero output is indistinguishable, in all three records, from a swap that moves the whole pool. A single address can therefore drive swapCount, and with it the advertised ratio, to any value it likes for gas plus dust, without ever trading. Anything downstream that reads these records as evidence of real activity -- a launch dashboard, an eligibility gate, a fee tier that keys off cadence -- is reading a number with no cost floor under it.

      This is reported rather than tested around because it cannot be fixed inside the stated behaviour: making the records resistant would mean weighting by volume or notional, which is a different contract from the one that was specified and accepted. test/SwapCadenceHook.t.sol::test_CountsASwapThatMovesAlmostNothing pins the mechanism and says plainly in its comment that the consequence is reported here, so the suite documents the input without endorsing the interpretation.

      Foundry, against the existing launch-pool harness:

      BalanceDelta delta = swap(key, true, -1, ZERO_BYTES);   // one wei of ETH in
      

      Observed: delta.amount0() == -1, delta.amount1() == 0 -- the swapper paid one wei and received no token at all -- and the hook's records move from (0, 0, 0) to swapCount 1, swapBlockCount 1, lastSwapBlock == block.number.

      Repeating that call N times in one block yields swapCount N, swapBlockCount 1, i.e. an advertised cadence of N swaps per active block, for a total economic cost of N wei plus gas. Expected, if the ratio is to mean what the NatSpec says it means: a swap that transfers no output should not be indistinguishable from a real one. Actual: it is indistinguishable in all three records.

  4. Reviewfound 1 low

    Review complete. Wrote one substantiated low-severity finding to .imd-findings.json. No repository source or test files were changed.

    ran oncodex · 5 turns · 2m 29s · 43.9K in · 5.2K out · 311.8K cached
    submission35d484236095646f03800bb14265341b5a5e60bf7a66474ddfea8eaf98bfa303
    devicea1c5c6c3e93f5a311d26715fe81382674dca82117134c2e6f97c1bc5faea9f09
    started fromc688306305c381901e5c9d5e59d825fa465d4b14
    bundlenone
    applied onad3103fc292b67578026183392ba8c87e2493991960782c21fcfd229fbdd4f16, 2d7cf4d617488e634681d8902253b36f034c523f78d50fc9eee606b25053502b
    may write
    src/SwapCadenceHook.soltest/SwapCadenceHook.t.sol
    • lowBlock numbers alias after uint64 truncationsrc/SwapCadenceHook.sol:137

      The hook narrows block.number to uint64 for both the distinct-block comparison and the stored last-swap block. Consequently, block numbers separated by 2**64 are treated as the same block, and lastSwapBlock stops exposing the actual most recent block once block.number exceeds type(uint64).max. This violates two of the three required records, although the triggering height is not realistically reachable on current chains.

      In the existing launch-pool harness, call vm.roll(1), execute one successful swap for key, then call vm.roll(18446744073709551617) (2**64 + 1) and execute a second successful swap for the same key.

      Expected: swapCount == 2, swapBlockCount == 2, and lastSwapBlock == 18446744073709551617.

      Actual: swapCount == 2, swapBlockCount == 1, and lastSwapBlock == 1 because uint64(18446744073709551617) == 1.