On the starter's base hook, build a self-contained per-pool tick extremes recorder. On each swap the hook records, for that pool, the lowest and highest tick it has ever observed and a count of swaps seen; it exposes read-only views for those three values and a flag for whether the pool has ever been observed at all.

It takes no fee, holds no privileged role, stores no owner or admin, has no reset or configuration entry point, and extends nothing that already exists — the state is append-only in the sense that extremes only ever widen.

The independent tester should take the paths the implementer would rather avoid: reads on a pool that has never swapped, the very first swap where both extremes must initialise to the same tick rather than to zero, ticks either side of zero so a signed comparison written as an unsigned one is caught, a tick that ties the current extreme rather than beating it, several swaps inside one block, and two pools recording without either seeing the other's values.

Use the harness that hands the hook a real launch pool rather than mocking the manager.

Work

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

    Done. src/TickExtremesHook.sol is the only file I created outside test/scratch/.

    What it does

    A BaseHook subclass with exactly one permission on — afterSwap — and no return-delta flags, so it takes nothing from the swap. It stores one packed struct per PoolId:

    struct Record { int24 lowestTick; int24 highestTick; uint64 swapCount; }
    mapping(PoolId => Record) private _records;   // private: only _afterSwap writes
    

    _afterSwap reads the post-swap tick from slot0 via StateLibrary.getSlot0 and folds it in. Views: lowestTick, highestTick, swapCount, hasBeenObserved. No constructor argument beyond the manager, no owner, no reset, no config.

    Two decisions a tester will care about, both documented in the source:

    • First swap seeds, it doesn't compare. swapCount == 0 gates a branch that sets both extremes to the observed tick. Falling through to the comparisons would leave lowestTick at zero for a pool that opened at tick 138000 — a tick it was never at.
    • A never-swapped pool reads back as all zeros, not a revert. Tick zero is a legitimate observation, so the zeros are ambiguous on their own; hasBeenObserved is what disambiguates them, and it's derived from swapCount != 0 rather than stored, so the flag can't disagree with the record.

    The stated invariant — swapCount != 0 implies lowestTick <= highestTick, and extremes only widen — is in the struct's NatSpec.

    Verification

    forge build succeeds and all 20 tests pass (8 inherited starter tests + 12 scratch tests). The scratch suite covers each path the task named: reads on an unswapped pool and on a pool ID that doesn't exist, first-swap seeding, a tie that counts but doesn't move the extremes, three swaps in one block, a second pool started at tick 0 swung either side of zero, and two pools recording independently.

    I also mutation-checked the two traps rather than just asserting against the correct code:

    • Rewriting the comparisons as uint24(...) → test_ExtremesStraddleZero fails (19 != -20).
    • Deleting the first-swap seeding branch → five tests fail, including low seeded from the observation: 0 != 137980.

    forge fmt --check is clean. Nothing was added to lib/, and foundry.toml, remappings.txt and the tests are untouched — git status shows only ?? src/TickExtremesHook.sol. The contract needs no dependency the template doesn't already vendor.

    ran onclaude · claude-opus-5 · 25 turns · 5m 6s · 40 in · 22.2K out · 1.2M cached
    submission7eb2758470fb6b19d3583936b0a08bc2b627175cdbbcae949bfb5d31434f8397
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from851023a457f1267800b4c26ea6c72fd98bc87ec9
    bundleca9b1eef50518c7e8be57f7d6c9ea97d8b581ac4b7025294b871a3ace68deb51 · 2.9 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    src/TickExtremesHook.sol
    may write
    src/TickExtremesHook.sol
  3. Tests1 file changed

    Done. test/TickExtremesHook.t.sol is the only file added; forge build is clean and forge test passes 33/33 (25 new + the 8 pre-existing).

    What the suite covers

    Reads on a pool that never swapped — a pool id that was never initialized, and the harness's live launch pool before its first swap (reads 0/0/0/false even though it holds the whole supply and sits at tick 138000). Adding liquidity, removing it, and donating are each shown not to be observations.

    The first swap — test_FirstSwapSeedsBothExtremesWithTheSameTick asserts both extremes equal the observed tick and that it is neither 0 nor the pool's starting tick. Plus the ambiguity case the implementation's docs turn on: a first swap that lands exactly on tick zero reads identically to an unseen pool on the ticks alone, and only hasBeenObserved separates them.

    Ticks either side of zero, both orderings — positive-then-negative (an unsigned < would report lowest = 137980, highest = -137, a range whose low is above its high) and negative-then-positive (an unsigned < would drag the low up). Also two negative ticks ordering among themselves.

    Ties — the pool returned exactly to its recorded high and exactly to its recorded low; a 1-wei swap that leaves the tick unmoved and ties both at once; a tick strictly inside the range. All widen nothing and all still count.

    Several swaps in one block, asserted against an unchanged block.number/block.timestamp; plus survival across vm.roll/vm.warp.

    Two pools — two launch pools with incompatible tick signs, and the sharper case: two fee tiers over the same currency pair, told apart only by pool id.

    No fee / no role — a control pool built identically with hooks = address(0) pays out the same to the wei; direct afterSwap calls from the test contract and a stranger revert; ten plausible admin/reset selectors and a bare ETH transfer all fail.

    Plus a 4-swap fuzz that rebuilds min/max/count from the ticks slot0 actually reported.

    Evidence the tests aren't just green

    I mutation-tested in test/scratch/ (discarded) by running the same suite against five broken copies of the hook. All five were killed: unsigned comparisons (8 failures), first swap folded into the comparisons (12), one global record instead of per-pool (2 — only the two-pool tests), ties not counted (6), hasBeenObserved derived from the ticks (1 — only the tick-zero test).

    Defects

    None found, so I did not write .imd-findings.json. I checked the signed comparisons, the first-swap seeding, hasBeenObserved being derived from the count rather than the ticks, the struct packing claim (24+24+64 = one slot), per-pool keying by PoolId, and that afterSwap reads post-swap slot0 — all hold, and every doc claim in the file matches observed behaviour.

    One thing worth knowing for anyone extending these tests: v4 leaves a swap that walks down onto a tick's exact price on the tick below it, at multiples of tickSpacing * 256 (tick zero, notably). That is pool mechanics, not the hook — so the tick-zero tests approach zero from below using a pool whose liquidity straddles it, rather than asserting the -1 as if it were the hook's answer. It's documented on _swapTowardTick.

    ran onclaude · claude-opus-5 · 54 turns · 19m 4s · 108 in · 84.9K out · 6.1M cached
    submissione7b189b9a6fcb6e1e7d10fdd0e50c7a633df7bdd962c17d8bd43988ceb2193bd
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from84db87e94d359ffb646180b18b822cc26a1a1aec
    bundlea5f4ce42fff4200dc9558c602fcbc44c7cb998925c4afa1a45122738af55a487 · 13 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied onca9b1eef50518c7e8be57f7d6c9ea97d8b581ac4b7025294b871a3ace68deb51
    changed · 1 file
    test/TickExtremesHook.t.sol
    may write
    test/TickExtremesHook.t.sol