Build a self-contained observation hook on the starter's base hook: on each swap it appends the pool's current tick and the block number to a fixed-size ring buffer keyed by pool, and exposes read-only views for the most recent entry and for the buffer's contents in order.

It takes no fee, holds no privileged role, stores no owner or admin, and extends nothing that already exists — the buffer size is a compile-time constant of the contract, not a configurable economic parameter. The independent tester should exercise the paths the implementer would rather avoid: reads before any swap has ever happened, several swaps within a single block, wraparound once the buffer is full, and two pools writing without seeing each other's entries.

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/SwapObservationHook.sol is the only file changed.

    What it does

    Write path — afterSwap only (the single permission it declares; afterSwapReturnDelta stays false, so it claims no part of any swap):

    (, int24 tick,,) = poolManager.getSlot0(poolId);        // the pool's own post-swap tick
    _observations[poolId][count % CAPACITY] = Observation({tick: tick, blockNumber: uint48(block.number)});
    _observationCount[poolId] = count + 1;
    

    CAPACITY = 16 is a constant used as the length of a storage array — it cannot be changed after deployment by anyone. No owner, no admin, no fee, no configuration; it inherits BaseHook and nothing else.

    Read path — three views, all keyed by PoolKey:

    • latestObservation(key) — newest entry; reverts NoObservations() on a pool never swapped on. Tick 0 is a valid tick (1:1 price), so a zeroed struct would be indistinguishable from a real entry — that reasoning is in the error's NatSpec.
    • observations(key) — surviving entries oldest-first, min(count, CAPACITY) long; an empty array before the first swap. The seam is resolved by copying from count - length, so callers never see ring internals.
    • observationCount(key) — all-time swaps for that pool, which keeps rising past CAPACITY. This is what makes wraparound observable; it's explicitly documented as not an index.

    Invariants the tester will look for are stated in the contract: one entry per swap, not per block and not per price change (no coalescing, no dedup — a sub-tick swap re-appends the same tick); pools are fully isolated by PoolId; and it is explicitly documented as not safe as a price feed, since CAPACITY swaps in one block can flush the whole buffer.

    int24 tick + uint48 blockNumber share one slot; uint48 overflows past block 2.8e14, noted in NatSpec as not worth defending in code rather than left silent.

    Verification

    • forge clean && forge build — clean, 94 files, no warnings.
    • forge test on the untouched repo (scratch removed) — 8/8 existing tests pass.
    • Scratch suite (deleted, not submitted): 13 tests via BaseHookTest with hookArtifact() overridden — the real launch pool, no mocked manager. Covers reads before any swap (revert + empty array + unknown-pool reads that aren't stale), three swaps in one vm.roll'd block, exact fill to capacity, wraparound at CAPACITY+5 and 3*CAPACITY+1 with order checked across the seam, two live ETH/token pools not seeing each other, and one pool wrapping twice without evicting the other's single entry. Plus the starter's own 8 hook-agnostic tests re-run against this hook. 29/29 pass.
    • forge fmt --check clean.

    No build-config change was needed, and no dependency was added — everything used (StateLibrary, PoolIdLibrary) is already vendored under lib/. The file is left uncommitted in the working tree; I didn't create a commit since you didn't ask for one.

    ran onclaude · claude-opus-5 · 19 turns · 4m 36s · 28 in · 21.1K out · 723.8K cached
    submission70dadb07064783abaa1a08f12679dc271053f768c02f450373092abcd19a9aad
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from851023a457f1267800b4c26ea6c72fd98bc87ec9
    bundle1627593c8e85c63c30974f969ee8c9aeea825d8466366ab784a08b65a61c276a · 3.7 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    src/SwapObservationHook.sol
    may write
    src/SwapObservationHook.sol
  3. Tests1 file changed

    Done. test/SwapObservationHook.t.sol is the only file added; forge build is clean (no compiler or lint warnings) and forge test passes 39/39 (8 pre-existing + 31 new).

    What the suite covers

    Built on BaseHookTest, so every observation comes from a real PoolManager calling the hook on a real launch pool — nothing mocked.

    Reads before anything happened — untraded pool reports count 0 and an empty buffer; latestObservation reverts NoObservations (tick 0 is a real price, so a zeroed struct would be a lie); a pool key that was never initialized reads the same way; a reverted swap (SwapAmountCannotBeZero) records nothing; liquidity add/remove and donates record nothing.

    What one entry is — post-swap tick and block, cross-checked against getSlot0. The sharp one: a swap stopped early by a price limit fills 3 of 100 ETH offered, and the recorded tick is the limit tick — a value no implementation deriving the tick from the amount or the delta could produce. Also sells and exact-output swaps, and negative ticks via a second pool started at −60000 (the packed int24/uint48 slot is where a sign-extension bug would hide, and the launch pool never goes negative).

    Several swaps in one block — four buys, no vm.roll: four entries, same block number, execution order preserved, nothing coalesced. Plus three sub-tick buys that provably don't move the tick and still append three identical entries.

    Wraparound — exactly-to-capacity; one past capacity (whole buffer compared, oldest confirmed gone, newest confirmed last not first); 53 swaps = three laps plus five, so the seam sits mid-array; latest == last ordered entry at every step; strict monotonicity across the seam; and the emitted slot index decoded from logs, wrapping 15 → 0.

    Two pools — neither sees the other's entries in either direction; wrapping a busy pool twice leaves a quiet pool's single entry untouched; and two pools differing only by fee tier stay separate.

    No fee, no role — a non-manager afterSwap call reverts and writes nothing (observations can't be forged); unrequested callbacks revert HookNotImplemented even for the manager; permissions are afterSwap and nothing else, afterSwapReturnDelta off; hook holds no ETH, no token, no ERC-6909 claim; plain ETH transfers are rejected; probes for owner()/admin()/transferOwnership/setCapacity/sweep all fail.

    Plus a bounded fuzz over 0–22 swaps asserting the buffer is always the last min(count, CAPACITY) in order.

    Defects

    None found — no .imd-findings.json written. I read the implementation adversarially and every path I could reach behaved as its documentation claims. To confirm the suite isn't just agreeing with whatever the code does, I ran it in scratch against five deliberately broken copies of the hook, each killed by multiple tests: observations() ignoring the seam (5 failures), latestObservation off by one (11), per-block coalescing (12), a single shared buffer instead of per-pool (3), and a ring that stops wrapping (5).

    One thing worth flagging as a judgment call rather than a finding: uint48(block.number) truncates silently past block 2.8e14. The contract documents it and the reasoning holds (~100M years at 12s blocks), so I tested that type(uint48).max round-trips exactly and did not write a test blessing the wrapped value.

    ran onclaude · claude-opus-5 · 30 turns · 13m 19s · 60 in · 57K out · 2.2M cached
    submissiond1ef78d2936e81f33fb11b69e914867b2babe31e694b3c3edca036d94eeb77bf
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from53d79c8717f7d3789d60d587021a60a39b145470
    bundle923a5ac81c587abc1f6f2304de1d24b2de64dd5724d8935412e349ffaaac777a · 13 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on1627593c8e85c63c30974f969ee8c9aeea825d8466366ab784a08b65a61c276a
    changed · 1 file
    test/SwapObservationHook.t.sol
    may write
    test/SwapObservationHook.t.sol