Write a self-contained Uniswap v4 hook on the starter's BaseHook that tracks, for each pool, the highest and the lowest pool tick observed at a swap since the hook was first used by that pool, together with the block number at which each of those extremes was most recently set. Expose the recorded extremes and their block numbers 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 observed swap sets both the high and the low; a later swap at a more extreme tick in either direction updates the corresponding record and its block number; a swap that is not more extreme leaves both records untouched; extremes 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.

Work

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

    Done. src/TickExtremesHook.sol is the only file added; nothing else in the tree was touched.

    What it does

    • Declares afterSwap and nothing else — no fee, no returned delta, no owner, no privileged caller, no upgrade path, holds no funds. The constructor takes only the IPoolManager, so it drops straight into the harness's deployHook() without a hookConstructorArgs() override.
    • In _afterSwap it reads the pool's own slot0.tick via StateLibrary.getSlot0 — the tick the swap left the pool at — and folds it into a per-PoolId record.
    • 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 keeps lowestTick <= highestTick.
    • The record is one packed storage slot (bool + two int24 + two uint64 = 23 bytes), so a swap costs a single SSTORE.

    Reads (all view, none revert): extremes(PoolId) returns the whole struct; highest(PoolId) and lowest(PoolId) return (bool observed, int24 tick, uint256 blockNumber); hasObservations(PoolId) is the bare predicate. For a pool that has never swapped, observed is false and the rest is zero — since tick 0 is a real tick, observed is the flag that distinguishes "no data" from "sat at tick 0". The doc comment states that and the lowestTick <= highestTick invariant explicitly, for whoever writes the tests.

    Verification

    • forge build clean from forge clean: successful.
    • forge test with test/scratch/ removed (the state that will actually be submitted): the 8 existing test/Hook.t.sol tests 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) return observed == false rather 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 cached
    submission225cffacf12f941af365e512e3acf2e6e6e66feb7d27e2753bf74078ec2953f0
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from851023a457f1267800b4c26ea6c72fd98bc87ec9
    bundle431f912e4ec17c57c55911497064af3a3a663e3e775605f1e5ac2c8bf01377cd · 3.1 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    src/TickExtremesHook.sol
    may write
    src/TickExtremesHook.sol
  3. Tests1 file changed

    All 41 tests pass (33 new + the 8 that were already there), the build is clean, and git status shows only the one file I was allowed to add.

    What the suite covers

    test/TickExtremesHook.t.sol extends the existing BaseHookTest launch 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 pins sqrtPriceLimitX96 to 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 - 1 round-trip through the int24 storage; attacker-chosen hookData naming another pool is ignored; afterSwap from 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 both int24 limits, keys strictly by PoolId, and its strict > / < comparisons hold the lowestTick <= highestTick invariant.

    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. The uint64(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 cached
    submissiond81f27de682f2cc1e3b8f49936e7e4f774ff79ab821b18e2df58947543de8e70
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started fromd6ae010dd41d5e5ad3612124c539f175549a7552
    bundlef4782dc522b75ea639c19514a8b5bf23cf6c53b02449c3c3cb0bd52ffdff4fc0 · 13 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on431f912e4ec17c57c55911497064af3a3a663e3e775605f1e5ac2c8bf01377cd
    changed · 1 file
    test/TickExtremesHook.t.sol
    may write
    test/TickExtremesHook.t.sol