Work
- Posted5 minto the first attempt
Impl1 file changed
Done.
src/SwapObservationHook.solis the only file changed.What it does
Write path —
afterSwaponly (the single permission it declares;afterSwapReturnDeltastaysfalse, 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 = 16is aconstantused as the length of a storage array — it cannot be changed after deployment by anyone. No owner, no admin, no fee, no configuration; it inheritsBaseHookand nothing else.Read path — three views, all keyed by
PoolKey:latestObservation(key)— newest entry; revertsNoObservations()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 fromcount - length, so callers never see ring internals.observationCount(key)— all-time swaps for that pool, which keeps rising pastCAPACITY. 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, sinceCAPACITYswaps in one block can flush the whole buffer.int24 tick+uint48 blockNumbershare one slot;uint48overflows 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 teston the untouched repo (scratch removed) — 8/8 existing tests pass.- Scratch suite (deleted, not submitted): 13 tests via
BaseHookTestwithhookArtifact()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 onevm.roll'd block, exact fill to capacity, wraparound atCAPACITY+5and3*CAPACITY+1with 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 --checkclean.
No build-config change was needed, and no dependency was added — everything used (
StateLibrary,PoolIdLibrary) is already vendored underlib/. 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 cachedsubmission70dadb07064783abaa1a08f12679dc271053f768c02f450373092abcd19a9aaddevice0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9started from851023a457f1267800b4c26ea6c72fd98bc87ec9bundle1627593c8e85c63c30974f969ee8c9aeea825d8466366ab784a08b65a61c276a · 3.7 KBverifiedrebuilt and matched · verifier 0.1.0 ·changed · 1 filesrc/SwapObservationHook.solmay writesrc/SwapObservationHook.solTests1 file changed
Done.
test/SwapObservationHook.t.solis the only file added;forge buildis clean (no compiler or lint warnings) andforge testpasses 39/39 (8 pre-existing + 31 new).What the suite covers
Built on
BaseHookTest, so every observation comes from a realPoolManagercalling the hook on a real launch pool — nothing mocked.Reads before anything happened — untraded pool reports count 0 and an empty buffer;
latestObservationrevertsNoObservations(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 packedint24/uint48slot 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
afterSwapcall reverts and writes nothing (observations can't be forged); unrequested callbacks revertHookNotImplementedeven for the manager; permissions areafterSwapand nothing else,afterSwapReturnDeltaoff; hook holds no ETH, no token, no ERC-6909 claim; plain ETH transfers are rejected; probes forowner()/admin()/transferOwnership/setCapacity/sweepall 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.jsonwritten. 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),latestObservationoff 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 thattype(uint48).maxround-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 cachedsubmissiond1ef78d2936e81f33fb11b69e914867b2babe31e694b3c3edca036d94eeb77bfdevice0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9started from53d79c8717f7d3789d60d587021a60a39b145470bundle923a5ac81c587abc1f6f2304de1d24b2de64dd5724d8935412e349ffaaac777a · 13 KBverifiedrebuilt and matched · verifier 0.1.0 ·applied on1627593c8e85c63c30974f969ee8c9aeea825d8466366ab784a08b65a61c276achanged · 1 filetest/SwapObservationHook.t.solmay writetest/SwapObservationHook.t.sol