Build a passive observer hook for a single pool: on each swap it increments a per-pool swap counter, records the block of the most recent swap, and marks the calling address as first-seen if it has not been seen before, all readable through view getters. It must take no fee, hold no funds, expose no owner or privileged function, and never revert the swap it observes.

Tests must cover: counters advance exactly once per swap, an unseen pool id reads zero, first-seen marking is idempotent across repeated calls from the same address, the hook's balance stays at zero across a full swap, and the hook's permission flags match the callbacks it actually implements. Note in the tests that the address seen in the swap callback is the caller of the pool manager rather than the end user, so the count is of calling addresses and not of humans.

Work

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

    Done. src/SwapObserverHook.sol is the only file delivered.

    What it does

    One callback, afterSwap, and one flag (AFTER_SWAP_FLAG). On each swap it:

    • ++swapCount[poolId] — advances exactly once per PoolManager.swap
    • lastSwapBlock[poolId] = block.number
    • writes firstSeenBlock[sender] = block.number only when it is still zero, and bumps observedCallerCount in the same branch

    Reads are four public-mapping/variable getters plus hasSeen(address).

    The four "not"s are structural rather than enforced: no receive/fallback/payable and no settle/take, so the balance has no path off zero; afterSwapReturnDelta: false plus a returned zero delta, so no fee; no constructor argument beyond the manager and no setter, so no owner; and no require, revert, or external call in the callback, with the one increment unchecked — that is the reason for unchecked, not gas. The contract's natspec lists the invariants (swapCount[id] == 0 iff lastSwapBlock[id] == 0; firstSeenBlock written at most once; observedCallerCount ≤ total swaps) and the note about sender being the router.

    Verification

    forge build succeeds from a clean out/. I wrote 20 tests in test/scratch/SwapObserverHook.t.sol and all pass alongside the 8 existing ones (28 total). They cover each required case: counters advance exactly once per swap (and per direction, and split correctly across pool ids), unseen and arbitrary pool ids read zero, first-seen is unchanged across repeated calls from one address including a 256-run fuzz over (caller, repeats), the hook's ETH and token balance stay zero across one swap and across five, and the permission struct matches the code — every unclaimed callback is asserted to revert HookNotImplemented when called by the manager. test_TheAddressSeenIsTheRouterNotTheEndUser confirms empirically that the recorded address is swapRouter, not the pranked trader EOA.

    One thing I could not deliver

    The task lists required test coverage, but the path rule permits only src/SwapObserverHook.sol — and the reference warns that writing a test file outside the granted paths fails the path check. So the tests live in test/scratch/, which is deleted before submission and not delivered. If the tests are meant to ship, the task needs test/SwapObserverHook.t.sol added to the writable paths; the suite above is ready to move there unchanged apart from the import paths. I made no change to foundry.toml, remappings.txt, or lib/, and none was needed — git status shows src/SwapObserverHook.sol as the only tracked-scope addition.

    ran onclaude · claude-opus-5 · 26 turns · 5m 58s · 44 in · 29.4K out · 1.3M cached
    submissionc7a9f642ed861752c5f5b8dcbee86b6931832befb9ad9312ed235199305e35f5
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from851023a457f1267800b4c26ea6c72fd98bc87ec9
    bundle67ae9fe0770715f87b04c356233b9030522cd0e767d7992ff506b00eb3170c08 · 3.5 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    src/SwapObserverHook.sol
    may write
    src/SwapObserverHook.sol
  3. Tests1 file changed

    forge build and forge test both pass: 43 tests, 0 failures (35 new, plus the 8 pre-existing ones still green). Only test/SwapObserverHook.t.sol is added; test/scratch/ is removed.

    What the suite covers

    The five required areas

    • Counters advance exactly once per swap — plus the cases that could break "exactly once": exact-output and oneForZero swaps, a fuzzed run of N swaps, three swaps batched into a single unlock (counted as three, via a purpose-built BatchSwapRouter), and a swap against zero liquidity that moves nothing but still fires the callback.
    • Unseen pool id reads zero — a ghost id, a fuzzed id, and an initialized-but-never-swapped pool carrying the same hook; a second live pool proves the per-pool counters stay isolated.
    • First-seen is idempotent — the same router swapping six times across five block boundaries never moves firstSeenBlock or observedCallerCount, while swapCount reaches 6.
    • Hook balance stays zero — ETH, ERC-20 and ERC-6909 claims checked around buy/sell/exact-output; direct ETH sends bounce; ETH cannot be forced in via manager.take either (with a control case proving that route works on a normal address).
    • Permission flags match implementation — asserted from both directions: all nine undeclared callbacks answer HookNotImplemented when called as the manager (only afterSwap responds), and a pool exercising initialize → add liquidity → swap → donate → remove liquidity succeeds, which it could not if the manager invoked any of them.

    Failure paths, not just the happy one: a zero-amount swap the pool rejects, a swap the caller cannot pay for, five swaps observed inside an unlock that then aborts (all rolled back), callbacks from a non-manager, unknown/privileged selectors (fuzzed over the whole selector space), and a "takes no fee" check against a bare mirror pool built with identical geometry — the payout matches wei for wei.

    The sender caveat is documented in the contract header and pinned by two tests: two different people trading through one router produce one caller entry and neither of them is hasSeen; one person through two routers produces two.

    Defects

    None to report — no .imd-findings.json was written. I verified the suite actually bites by running it against six deliberately broken copies of the hook in scratch (double-counting, last-seen-instead-of-first-seen, no block record, an owner plus receive(), a 1-wei skim, and an over-declared flag). Every mutant was caught, each by the test written for it.

    One thing I deliberately did not assert: the hook uses firstSeenBlock == 0 as its "never seen" sentinel. Under vm.roll(0) that collides — hasSeen reads false for a caller just recorded, and a repeat swap re-increments observedCallerCount. I did not file it, because genesis executes no transactions on any chain, and the implementation's own docstring reasons about exactly this. The header comment in the test file states that this is why it is unasserted rather than leaving it silently uncovered.

    ran onclaude · claude-opus-5 · 37 turns · 14m 26s · 74 in · 65.4K out · 3.5M cached
    submission2975a687205b74e97df18466075270a7b333532b2052e177890a6bb127489206
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started fromfb730fd76f1049f24443637f20f43066e8f9a7a5
    bundle2b54abe9887c094ccaf8c4d71d05016b5ca8f7ec1d1c4a4227c16a28092eb3b3 · 14 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on67ae9fe0770715f87b04c356233b9030522cd0e767d7992ff506b00eb3170c08
    changed · 1 file
    test/SwapObserverHook.t.sol
    may write
    test/SwapObserverHook.t.sol