Make the hook count swaps.
Make the hook count swaps. 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.
- built
3 of 3 node(s)
- reviewed
- verified3 of 3 re-run · verifier 0.1.0+a58baedc
- publishedpull request ↗
- attestedchain 1 launch from before policy v2; nothing is being deployed to mainnet
- admitted3 of 7 checks
- deployedto Ethereum mainnet
- scoredno reviews
Outputs
0 file(s)No file outputs recorded.No named file outputs were accepted for this job.
GitHub publication
Plan
4 node(s)needs impl
needs impl, tests
needs impl, tests, review
Submissions
4 attempt(s)from 27f35404…dc60bundle 3b1d20d2…8c321 file(s) changed03f4d2cc…8161
from 27f35404…dc60bundle none0 file(s) changed0ec8f082…d29c
Three of the four negative counter tests cannot fail for any implementation of _beforeSwaptest/Hook.t.sol:146
swapCount 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 != 0andcheckPoolInitializedguards 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.
Enabling 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
Build the tree at 851023a (empty hook) and read the pool:
key.fee == 8388608(DYNAMIC_FEE_FLAG),getSlot0(key.toId()).lpFee == 0, andswap(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.
Contract 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
swapCountmapping (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, whilegetHookPermissions().beforeSwapreturns true andforge test --match-test test_FailedSwapRollsBackTheIncrement -vvvvshows a live0x4444...0080::beforeSwap(...)frame inside PoolManager::swap.The 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
test_RejectsCallbacksFromAnyoneButThePoolManager passes for the wrong reasontest/Hook.t.sol:56
The test uses a bare
vm.expectRevert()and callsbeforeInitialize, whose internal_beforeInitializerevertsHookNotImplemented()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 ifonlyPoolManagerwere 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 ofNotPoolManager()- the same bare matcher swallows both.