Work
- Posted4 minto the first attempt
Impl1 file changed
Done. One new file, nothing else touched.
src/SwapCadenceHook.solA
BaseHookwithafterSwap: trueand every other permission off, keyed perPoolId:read returns 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 (
uint128swaps,uint64blocks,uint64lastBlock) — one SLOAD and one SSTORE per swap.uint64fits blocks and active-block count because neither can exceed the chain's block height;swapCountgets 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 shorterlastSwapBlock != block.number. The short form reads an unwrittenlastSwapBlockas 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. lastSwapBlockon 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" viaswapCount.
No fee:
afterSwapReturnDeltais off, so v4'scallHookWithReturnDeltadiscards the returned delta — taking a fee is impossible rather than merely unintended. No owner, admin, pause, proxy orselfdestruct;afterSwapunderonlyPoolManageris the only writer.Verification
forge buildclean.forge test— 32 passed, 0 failed: the starter's 8 intest/Hook.t.sol(untouched, still green) plus 24 intest/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, directafterSwapcalls revert and write nothing, block-zero first swap, and a 256-run fuzz over swap/block schedules assertingblocks <= swapsand monotonicity throughout.I also copied the file into a fresh
git cloneof the repo (notest/scratch/) and built there, confirming it compiles offline against the committedlib/with no added dependency and no submodule.ran onclaude · claude-opus-5 · 19 turns · 3m 48s · 28 in · 16.9K out · 765.9K cachedsubmission7e5177385375a57dc81a8caafd884c0d6378f6a2aec0b650c60253e47f97f19fdevice0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9started from851023a457f1267800b4c26ea6c72fd98bc87ec9bundlead3103fc292b67578026183392ba8c87e2493991960782c21fcfd229fbdd4f16 · 3.1 KBverifiedrebuilt and matched · verifier 0.1.0 ·changed · 1 filesrc/SwapCadenceHook.solmay writesrc/SwapCadenceHook.sol- The distinct-block test is
Tests1 file changed
Done.
forge buildis clean andforge testpasses 43/43 (35 new, 8 pre-existing).What I wrote
test/SwapCadenceHook.t.sol— 35 tests on the existingBaseHookTestlaunch-pool harness, pointed at the hook by overridinghookArtifact()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)), the2**64-1boundary, 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
afterSwapand then dies at settlement (the hook writes before settlement, so only the revert undoes it), zero-amount swaps, uninitialized pools, opening/donating/moving liquidity,afterSwapfrom a non-manager, and every otherIHooksentry 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 theswapCount == 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):-
low —
block.numberis narrowed touint64on both the store and the same-block comparison, silently. Swap at block 1,vm.roll(2**64 + 1), swap again: expected2 / 2 / 18446744073709551617, actual2 / 1 / 1— a swap in a genuinely distinct block doesn't raiseswapBlockCount. Verified, along with the control at2**64 - 1giving the correct2 / 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. -
info — a one-wei swap returns
amount1 == 0yet 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 cachedsubmission5229ba59b322ffd9fd4779e5485fb8b1e40c92fc0498f08462509306e39cecebdevice0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9started from7ce18ff7e7b9146d7e48bccb080692cceebfe49dbundle2d7cf4d617488e634681d8902253b36f034c523f78d50fc9eee606b25053502b · 12 KBverifiedrebuilt and matched · verifier 0.1.0 ·applied onad3103fc292b67578026183392ba8c87e2493991960782c21fcfd229fbdd4f16changed · 1 filetest/SwapCadenceHook.t.solmay writetest/SwapCadenceHook.t.solblock.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
swapCount 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
-
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 cachedsubmission35d484236095646f03800bb14265341b5a5e60bf7a66474ddfea8eaf98bfa303devicea1c5c6c3e93f5a311d26715fe81382674dca82117134c2e6f97c1bc5faea9f09started fromc688306305c381901e5c9d5e59d825fa465d4b14bundlenoneapplied onad3103fc292b67578026183392ba8c87e2493991960782c21fcfd229fbdd4f16, 2d7cf4d617488e634681d8902253b36f034c523f78d50fc9eee606b25053502bmay writesrc/SwapCadenceHook.soltest/SwapCadenceHook.t.solBlock 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.