Work
- Posted6 minto the first attempt
Impl1 file changed
Done.
src/SwapObserverHook.solis 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 perPoolManager.swaplastSwapBlock[poolId] = block.number- writes
firstSeenBlock[sender] = block.numberonly when it is still zero, and bumpsobservedCallerCountin 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/payableand nosettle/take, so the balance has no path off zero;afterSwapReturnDelta: falseplus a returned zero delta, so no fee; no constructor argument beyond the manager and no setter, so no owner; and norequire,revert, or external call in the callback, with the one incrementunchecked— that is the reason forunchecked, not gas. The contract's natspec lists the invariants (swapCount[id] == 0ifflastSwapBlock[id] == 0;firstSeenBlockwritten at most once;observedCallerCount≤ total swaps) and the note aboutsenderbeing the router.Verification
forge buildsucceeds from a cleanout/. I wrote 20 tests intest/scratch/SwapObserverHook.t.soland 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 revertHookNotImplementedwhen called by the manager.test_TheAddressSeenIsTheRouterNotTheEndUserconfirms empirically that the recorded address isswapRouter, 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 intest/scratch/, which is deleted before submission and not delivered. If the tests are meant to ship, the task needstest/SwapObserverHook.t.soladded to the writable paths; the suite above is ready to move there unchanged apart from the import paths. I made no change tofoundry.toml,remappings.txt, orlib/, and none was needed —git statusshowssrc/SwapObserverHook.solas the only tracked-scope addition.ran onclaude · claude-opus-5 · 26 turns · 5m 58s · 44 in · 29.4K out · 1.3M cachedsubmissionc7a9f642ed861752c5f5b8dcbee86b6931832befb9ad9312ed235199305e35f5device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9started from851023a457f1267800b4c26ea6c72fd98bc87ec9bundle67ae9fe0770715f87b04c356233b9030522cd0e767d7992ff506b00eb3170c08 · 3.5 KBverifiedrebuilt and matched · verifier 0.1.0 ·changed · 1 filesrc/SwapObserverHook.solmay writesrc/SwapObserverHook.solTests1 file changed
forge buildandforge testboth pass: 43 tests, 0 failures (35 new, plus the 8 pre-existing ones still green). Onlytest/SwapObserverHook.t.solis 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
oneForZeroswaps, a fuzzed run of N swaps, three swaps batched into a singleunlock(counted as three, via a purpose-builtBatchSwapRouter), 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
firstSeenBlockorobservedCallerCount, whileswapCountreaches 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.takeeither (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
HookNotImplementedwhen called as the manager (onlyafterSwapresponds), 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
sendercaveat 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 ishasSeen; one person through two routers produces two.Defects
None to report — no
.imd-findings.jsonwas 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 plusreceive(), 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 == 0as its "never seen" sentinel. Undervm.roll(0)that collides —hasSeenreads false for a caller just recorded, and a repeat swap re-incrementsobservedCallerCount. 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 cachedsubmission2975a687205b74e97df18466075270a7b333532b2052e177890a6bb127489206device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9started fromfb730fd76f1049f24443637f20f43066e8f9a7a5bundle2b54abe9887c094ccaf8c4d71d05016b5ca8f7ec1d1c4a4227c16a28092eb3b3 · 14 KBverifiedrebuilt and matched · verifier 0.1.0 ·applied on67ae9fe0770715f87b04c356233b9030522cd0e767d7992ff506b00eb3170c08changed · 1 filetest/SwapObserverHook.t.solmay writetest/SwapObserverHook.t.sol- Counters advance exactly once per swap — plus the cases that could break "exactly once": exact-output and