Work
- Posted4 minto the first attempt
Impl1 file changed
Done.
src/TickExtremesHook.solis the only file added; nothing else in the tree was touched.What it does
- Declares
afterSwapand nothing else — no fee, no returned delta, no owner, no privileged caller, no upgrade path, holds no funds. The constructor takes only theIPoolManager, so it drops straight into the harness'sdeployHook()without ahookConstructorArgs()override. - In
_afterSwapit reads the pool's ownslot0.tickviaStateLibrary.getSlot0— the tick the swap left the pool at — and folds it into a per-PoolIdrecord. - First swap sets both the high and the low to that tick, in that block. After that, a record moves only on a strictly more extreme tick, so an equal tick re-stamps nothing. The two updates are independent
ifs, and at most one can fire, which is what keepslowestTick <= highestTick. - The record is one packed storage slot (
bool+ twoint24+ twouint64= 23 bytes), so a swap costs a singleSSTORE.
Reads (all view, none revert):
extremes(PoolId)returns the whole struct;highest(PoolId)andlowest(PoolId)return(bool observed, int24 tick, uint256 blockNumber);hasObservations(PoolId)is the bare predicate. For a pool that has never swapped,observedisfalseand the rest is zero — since tick 0 is a real tick,observedis the flag that distinguishes "no data" from "sat at tick 0". The doc comment states that and thelowestTick <= highestTickinvariant explicitly, for whoever writes the tests.Verification
forge buildclean fromforge clean: successful.forge testwithtest/scratch/removed (the state that will actually be submitted): the 8 existingtest/Hook.t.soltests pass, unchanged.- In
test/scratch/I wrote 9 throwaway tests on the launch-pool harness, all passing: first swap sets both; a lower tick updates only the low and its block; a higher tick (selling token back) updates only the high and its block; a swap landing inside the band touches neither; an equal tick does not re-stamp the block; two pools on the same hook keep independent records in both directions; and reads on a never-swapped pool (and on a pool id that does not exist) returnobserved == falserather than reverting.
One note for the test worker rather than a change I made: the harness opens a static 0.30% pool once the hook declares a permission (
poolFee()switches off_declaredFlags()), so the swapped tick reflects that fee — unlike the starter hook's dynamic-fee-at-zero pool. No config change is needed for this; the harness already handles it.ran onclaude · claude-opus-5 · 22 turns · 4m 19s · 34 in · 19.9K out · 908.8K cachedsubmission225cffacf12f941af365e512e3acf2e6e6e66feb7d27e2753bf74078ec2953f0device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9started from851023a457f1267800b4c26ea6c72fd98bc87ec9bundle431f912e4ec17c57c55911497064af3a3a663e3e775605f1e5ac2c8bf01377cd · 3.1 KBverifiedrebuilt and matched · verifier 0.1.0 ·changed · 1 filesrc/TickExtremesHook.solmay writesrc/TickExtremesHook.sol- Declares
Tests1 file changed
All 41 tests pass (33 new + the 8 that were already there), the build is clean, and
git statusshows only the one file I was allowed to add.What the suite covers
test/TickExtremesHook.t.solextends the existingBaseHookTestlaunch pool and adds three more pools on the same hook so keying and isolation are actually exercised: an ERC20/ERC20 pool at tick 0 that moves freely both ways, an identical pool with no hook (a control), and a pool that is initialized and deliberately never swapped.The key technique: instead of guessing swap sizes,
swapTo()oversizes the input and pinssqrtPriceLimitX96to the target tick's price, then asserts the pool landed exactly there. Every tick in the file is an exact chosen number, which is what makes the boundary cases assertable at all.Required cases, plus the failure paths:
- First swap sets both records to one tick/block; the recorded tick is the post-swap tick, not the entry tick.
- More extreme in either direction moves only that record and only that block; the other keeps its original block across a 1,000,000-block gap.
- Not more extreme: strictly inside the band, exactly equal to the standing high, exactly equal to the standing low, and a swap too small to move the tick at all — five blocks in a row.
- Two pools: three pools on one hook, including disjoint tick bands (≈137,000 vs ≈0), with cross-checks after each swap.
- Never-swapped reads: initialized-but-unswapped, an invented pool id, the zero id, the max id — none revert, all report
observed == false.
Adversarial cases beyond the minimum: tick 0 is a real observation (so
observed, not a zero tick, is what means "no data");MIN_TICK/MAX_TICK - 1round-trip through theint24storage; attacker-chosenhookDatanaming another pool is ignored;afterSwapfrom a non-manager reverts and writes nothing; undeclared callbacks revert even for the manager; liquidity churn and donations create no record; records survive the pool being emptied; exact-output swaps are observed; the hook holds no ETH, no token and no ERC-6909 claims, cannot be sent ETH, exposes none of ten conventional privileged selectors, has empty ERC-1967 slots, and pays out byte-identical deltas to the unhooked control pool. A fuzz test walks 8 random targets with random block gaps against an independent tally kept in the test (stable at 2000 runs and across seeds).No defect report filed
I did not write
.imd-findings.json— I could not find a defect I had to test around. The implementation stores the extremes correctly at bothint24limits, keys strictly byPoolId, and its strict>/<comparisons hold thelowestTick <= highestTickinvariant.One thing worth your attention rather than a finding: the spec phrase "the block number at which each extreme was most recently set" is ambiguous when a tick is merely equalled — last written vs. last seen. The hook's NatSpec explicitly chooses "last written" (revisiting the standing high at block 900 leaves its block at 100). I pinned that documented reading and said so in the docstring on
test_RevisitingTheStandingHighDoesNotRefreshItsBlock, naming those two tests as the ones to change if the other reading was intended. Theuint64(block.number)truncation the implementation flags is real but needs block 2^64, so I left it alone.ran onclaude · claude-opus-5 · 46 turns · 14m 49s · 92 in · 64.2K out · 4M cachedsubmissiond81f27de682f2cc1e3b8f49936e7e4f774ff79ab821b18e2df58947543de8e70device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9started fromd6ae010dd41d5e5ad3612124c539f175549a7552bundlef4782dc522b75ea639c19514a8b5bf23cf6c53b02449c3c3cb0bd52ffdff4fc0 · 13 KBverifiedrebuilt and matched · verifier 0.1.0 ·applied on431f912e4ec17c57c55911497064af3a3a663e3e775605f1e5ac2c8bf01377cdchanged · 1 filetest/TickExtremesHook.t.solmay writetest/TickExtremesHook.t.sol