{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"ae115cf4-22d5-4e44-949a-2136ee66fb7e","kind":"audit","nodes":[{"acceptedSubmissionHash":"761d33da8e4aaee039c421ea3111caeb5231dbd50f233b3cdb439274b83d2e29","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"3c68464062a945bf8fd3a4e4f16dfc7cf145c95c7fbe74e080f428ccd7cc8a08","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"9f4d241c6db936c4a03d96ac78b6d98bb504e48265536af5dba79aced23751e6","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"ecc3a8d5dc6f7403366ffab65d9decfd43af5960d5e2fbb92499ecf7c2cc29cf","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"23ba873b1b94e0223c2cf7a4dd0824ccac324cd22a41984162619f560854aaff","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"The code is in the public repository https://github.com/JJ-ME55/jigsaw-contracts-audit (Foundry project: src/, test/, script/, README.md; OpenZeppelin 5.1 and Uniswap v4 core/periphery are the only dependencies, installed with forge). Read README.md first: it describes every contract, the fee split, the launch plan and the design decisions an auditor should know. Read the Solidity, not just the README. This is the fourth round. Rounds 1-3 (jobs fbe44d90, 489901b6, 0c246e1d) and every fix are tabled in README.md and swarm-audit-report*.md. Round 3 fixes in this commit: CreatorFeeHook has BEFORE_ADD_LIQUIDITY (flags 0x2844) and after the factory calls seal(key) a creator pool refuses all liquidity forever; LPDeepening.run collects fees after its own sale; CoinFactory constructor also requires the AirdropClaim to be unbound (factory() == 0) and launch accepts msg.value >= fee with a refund; recycle staleness runs from Earned.firstCredit. Verify each fix holds and hunt for anything the fixes introduced (especially: can seal() be called before seeding or skipped so a pool stays open; can the factory's own seed be blocked or front-run by the liquidity lock; does the launch refund open reentrancy or a griefing path via a reverting msg.sender; does firstCredit misbehave across claim epochs; is anything in the hook now callable in a wrong order). If, after a genuine attempt, nothing above Info remains, say so plainly: this round decides whether the swarm phase is complete. Then do the full scopes below. SCOPE A (whole base): SCOPE: the whole repository. Threat model: (a) nobody can remove or move the locked LP positions held by JIGLaunch, CoinFactory or LPDeepening; (b) nothing leaves PotVault or AirdropClaim except through declared/merkle distributions; (c) JIG and CreatorCoin supply cannot be increased; (d) Vesting cannot be claimed early or redirected; (e) JIGLaunchHook: nobody gets the 1% fee during the 10-minute window without being on the allowlist, and the hook cannot brick the pool; (f) any owner, keeper or factory privilege that could take user funds. Also check: single-sided seeding maths in JIGLaunch and CoinFactory for both currency orderings; CREATE2 hook address flag assumptions; reentrancy on every claim/pull/run path. SCOPE B (CreatorFeeHook in depth; this is where the money is): SCOPE: src/CreatorFeeHook.sol in depth (with CoinFactory.sol and CreatorCoin.sol for context). This hook handles every creator coin's fees. Threat model: (a) a swapper paying less than the current fee, including via exact-output swaps, hookData tricks, or the nested conversion swap; (b) the _converting guard: can a reentrant or nested call skip the fee or double-credit; (c) ERC-6909 claim accounting: can claimable balances ever exceed what the hook holds, can anyone claim another's balance, can totalClaimable drift; (d) the nested coin->JIG swap: price manipulation, sandwiching the conversion, pools that cannot absorb the amount, dust left as coin claims; (e) register/lowerFee/setFactory/setDeepening access control; (f) the anti-snipe decay arithmetic; (g) the fee-band and split arithmetic (sum must equal the fee, no overflow at 10% on large swaps); (h) hook permission flags vs. the address bits. Give exact exploit transactions where you find anything. SCOPE C (factory and modules): SCOPE: src/CoinFactory.sol, src/HolderDrip.sol, src/SweepAndBurn.sol, src/BuybackAndBurn.sol, src/LPDeepening.sol, src/AirdropClaim.sol, reference/LoomCollection.sol (reference copy, not compiled: check the 1-based sequential id, monotone totalSupply and transferFrom-to-dead assumptions HolderDrip and SweepAndBurn make). Threat model: (a) CoinFactory: can a launch leave the pool, vesting or airdrop in an inconsistent state; can the 90/5/5 split be bypassed; ordering of coin vs JIG address handled correctly for price and range checks; LP locked; (b) HolderDrip: per-token accrual correctness when supply changes, double claims, non-owners; (c) SweepAndBurn: can the keeper extract value beyond overpaying for a real NFT; can a malicious marketplace call reenter; the JIG->ETH swap and ETH handling; (d) BuybackAndBurn and LPDeepening: unlock callbacks callable only by the PoolManager, settle/take correctness, leftover balances; (e) AirdropClaim: owner powers, expiry/sweep, per-coin isolation; (f) LoomCollection: do the id/supply/burn assumptions hold; anything in its owner powers or mint path that affects HolderDrip or SweepAndBurn. For every finding: contract, function, concrete exploit scenario with a Foundry reproduction where possible, and a concrete code fix. Answer every threat-model item above explicitly yes/no with reasoning, and note anything that would break on an L2 such as Robinhood Chain. Be specific; no generic advice; if something is fine say so and why.","parentJobId":null,"planHash":"ad93129df27f20ab21ec0d85582a32ea8598ec3fa49d9473928cf8e9d6272d65","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"ae115cf4-22d5-4e44-949a-2136ee66fb7e","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51142","feedbackHash":"cf9b8003fb968f24b5731b11cc9659a29a3bb4a3f88dae427f2dd78e47302d17","nodeKey":"audit_economics","submissionHash":"761d33da8e4aaee039c421ea3111caeb5231dbd50f233b3cdb439274b83d2e29","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51447","feedbackHash":"01e0300187213f57334e234faee381b0bab554a522b154f14e9132004037957e","nodeKey":"audit_flow","submissionHash":"3c68464062a945bf8fd3a4e4f16dfc7cf145c95c7fbe74e080f428ccd7cc8a08","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52169","feedbackHash":"bd0e2002318e727ea19d1141338f03e92d3692ef7147b0bad1c73c6a1dcd5f8e","nodeKey":"audit_judge","submissionHash":"9f4d241c6db936c4a03d96ac78b6d98bb504e48265536af5dba79aced23751e6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51145","feedbackHash":"e25f09c6de7e093fd537cfcace2aff6376bcba5fe15337f7b3114a238f3c456b","nodeKey":"audit_math","submissionHash":"ecc3a8d5dc6f7403366ffab65d9decfd43af5960d5e2fbb92499ecf7c2cc29cf","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52264","feedbackHash":"69a47c3e30b5694dfb27cd2ddc7d77e57064d52254bd5ab78040eb1a668c7fed","nodeKey":"audit_permissions","submissionHash":"23ba873b1b94e0223c2cf7a4dd0824ccac324cd22a41984162619f560854aaff","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"470f45b5b8a5806db1ec315c03405693dd21a337807fdbddae827f40e2d34eb2","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c97fd6a73e3737dc","findings":[{"citation":"resolved","description":"Round 3 moved the staleness clock from Earned.lastCredit to Earned.firstCredit so a dust buy cannot keep a stale balance alive. The side effect is the mirror image: once the FIRST unclaimed credit on a pool is RECYCLE_AFTER old, recycle() takes e.amount in full (line 271), and e.amount includes every credit since, down to one made in the same block. For an actively traded coin whose flywheel module (HolderDrip, SnapshotForwarder, BuybackAndBurn, SweepAndBurn) has not been pulled for 7 days, anyone can fold the whole flywheel slice earned in those 7 days through the split: platformBps (25-40%) to the platform, 3% pot, 2% deepening and the rest to the pot. None of that JIG is 'lost' money: the modules' pull() is permissionless and the keeper is meant to call it, so the only party that gains is the platform, which is also the party that runs the keeper. The same applies to the creator's own slice (recycled to the flywheel), but that is the creator's own inaction. Severity Low: no attacker profit, holders' flywheel slice is redirected to platform/pot only when nobody (holders included) calls pull() for a week; the README documents recycling but not that fresh credits are swept with the stale ones. Fix (minimal, keeps the design): keep two buckets per Earned entry, e.g. {uint128 stale; uint128 recent; uint64 recentSince}. In _creditEarned: if (block.timestamp >= recentSince + RECYCLE_AFTER) { stale += recent; recent = 0; recentSince = now; } recent += amount. In recycle(): roll the same way, then require stale > 0 and recycle only `stale`, leaving `recent` untouched. Alternative policy fix: exempt contract flywheels from recycle (their pull() is permissionless so their balance can never be orphaned) and recycle only creator balances. Operational mitigation until then: the keeper must pull every module at least weekly.","line":270,"path":"src/CreatorFeeHook.sol","reproduction":"Local harness (test/scratch/RecycleFresh.t.sol; this test contract acts as the factory; needs FOUNDRY_OPTIMIZER_RUNS=44444 because the repo's via-ir/800-runs profile cannot compile a local PoolManager, no config change). Pool coin/JIG at 1:1, fee 1%, flywheel = an address F, default split. t0 (after the snipe window): buyer swaps 1,000 JIG exact-input -> claimable[F] = 2.0019e18 (stale). warp(t0 + 7 days + 1 s): buyer swaps 1,000,000 JIG -> F credited a further 3.9392e21 in the same block. Then anyone calls hook.recycle(key, F). Expected (what the README's 'unclaimed for RECYCLE_AFTER' implies): 2.0019e18 recycled, the fresh 3.9392e21 stays with F. Actual: recycle() returns 3,941,201,868,487,893,266,331 wei (= stale + fresh), claimable[F] == 0, claimable[platform] grows by 25% of the fresh credit (~985 JIG), pot by ~2,955 JIG. Assertion `assertEq(recycled, stale)` fails with 3941201868487893266331 != 2001960280243146605.","severity":"low","snippet":"        if (block.timestamp < e.firstCredit + RECYCLE_AFTER) revert NotStale(); // from the first unclaimed credit, so a dust buy cannot reset the clock","title":"recycle() sweeps a flywheel's entire balance, including same-block credits, once its first credit is 7 days old"},{"citation":"resolved","description":"Hooks.afterSwap in v4-core (lib/v4-periphery/lib/v4-core/src/libraries/Hooks.sol line 293) returns before calling the hook whenever msg.sender == address(self), and the nested conversion in _sellCoinForJig is a poolManager.swap() made by the hook itself. So afterSwap is never re-entered during the conversion, the branch at line 339 is unreachable, and the fee-skip / double-credit protection the comment and the threat model attribute to _converting actually comes from the PoolManager. Not a vulnerability (the outcome is correct either way), but the storage write/clear costs ~5k gas per coin-fee swap and the reasoning in the README/round reports should cite the v4 self-call rule, not the flag. Same rule applies to beforeAddLiquidity (noSelfCall): the seal would not bind a modifyLiquidity call made by the hook itself, which the hook has no code path for; keep it that way. Fix: delete _converting and its three uses, or keep it and correct the comments.","line":339,"path":"src/CreatorFeeHook.sol","reproduction":"Any exact-input buy on a creator pool after launch (e.g. the scratch harness above: 1,000 JIG in). afterSwap runs once, calls poolManager.swap() for the fee coin; set a breakpoint or emit in afterSwap: it is entered exactly once per outer swap, never with _converting == true. Static: Hooks.sol:293 `if (msg.sender == address(self)) return (swapDelta, BalanceDeltaLibrary.ZERO_DELTA);`.","severity":"info","snippet":"        if (_converting) return (IHooks.afterSwap.selector, 0);","title":"_converting guard is dead code: v4 never calls afterSwap for the hook's own nested swap"},{"citation":"resolved","description":"The round-3 refund path compares msg.value with the storage variable launchFeeWei twice (lines 164 and 166-167) after an external call to the platform. If the platform is a contract whose receive() (or anything it calls) invokes setLaunchFee(), the second read differs from the first: with msg.value 0.015 ETH and fee 0.01 raised to 0.015 during receive, the platform is paid 0.01, `msg.value > launchFeeWei` is false, and 0.005 ETH stays in CoinFactory forever (the factory has no withdraw and no receive, so it is unrecoverable by anyone). Raising it above msg.value gives the same result. The platform is a trusted role and gains nothing, so this is Info, but it is the only path by which ETH can be stuck in the factory and it is a one-line fix: cache `uint256 fee = launchFeeWei;` at the top of launch() and use it for the check, the payment and the refund.","line":164,"path":"src/CoinFactory.sol","reproduction":"Deploy CoinFactory with platform = a contract whose receive() calls factory.setLaunchFee(0.015 ether); launchFeeWei = 0.01 ether. Creator calls launch{value: 0.015 ether}(...). Expected: platform receives 0.01, creator refunded 0.005. Actual: platform receives 0.01, creator refunded 0, address(factory).balance == 0.005 ether permanently.","severity":"info","snippet":"        (bool ok,) = platform.call{value: launchFeeWei}(\"\");","title":"launch() re-reads launchFeeWei after the platform's receive, so a fee change inside that call strands the creator's refund in the factory"}],"hash":"23ba873b1b94e0223c2cf7a4dd0824ccac324cd22a41984162619f560854aaff","nodeId":"b2862cd9-d324-46a5-82d7-0470509feefb","outcome":"completed","summary":"Review complete. The findings file holds one Low and two Info items; nothing above Low survived a genuine attempt, so on this evidence the swarm phase can close once the Low is decided.\n\n## Outcome\n\nEvery round-3 fix holds. The permission inventory, the trust gaps and the asymmetries all checked out against the code and against v4-core's actual hook dispatch. Build was green after installing the three gitignored dependencies, and the 14 unit tests pass here. The fork tests need Sepolia and were not run.\n\n**Low, `CreatorFeeHook.recycle`, line 270.** The `firstCredit` clock has a mirror-image side effect. Once a pool's first unclaimed credit is 7 days old, `recycle` folds the whole balance, including credits from the same block, through the split. For an active coin whose flywheel module nobody pulls for a week, anyone can send the holders' entire weekly slice to the pot with 25 to 40 percent to the platform. I reproduced it on a local PoolManager: a 2 JIG stale balance plus a 3,939 JIG same-block credit were recycled together. No attacker profits, and `pull()` is permissionless, so the keeper pulling weekly is a full mitigation. The fix I propose keeps two buckets per ledger entry and recycles only the one older than 7 days.\n\n**Info.** The `_converting` flag is dead code, because v4's `Hooks.afterSwap` returns before calling the hook when the hook itself is the swapper. The protection is real but comes from the PoolManager, not the flag. Second Info: `launch` re-reads the fee after paying the platform, so a platform contract that changes the fee inside its receive strands the creator's refund in the factory, which has no withdraw path. Cache the fee at the top of `launch`.\n\n## Round-3 questions and threat model\n\n- **seal before seeding or skipped:** No. Both are factory-only and happen in one `launch` call with no untrusted external call between `register` and `seal`. The collection is only staticcalled, and the airdrop notify, dev buy and refund all run after the seal.\n- **factory seed blocked by the lock:** No. The seal is set after the mint, and the pool cannot exist before `register`, so nobody can pre-seal or pre-seed.\n- **refund reentrancy or griefing:** No fund impact. `launch` is nonReentrant, a reverting creator only reverts their own launch. See the Info on the platform's receive.\n- **firstCredit across epochs:** Correct. A claim bumps the epoch, the next credit resets the entry and its clock. The side effect is the Low above.\n- **hook callable in wrong order:** No. `beforeInitialize` needs registration, `seal` needs registration, `register` is one-shot per pool id, and `setFactory`/`setDeepening` are owner one-shots.\n- **(a) LP positions:** Locked. JIGLaunch, CoinFactory and LPDeepening have no transfer, approve or decrease path, only a zero-liquidity decrease that collects fees. Contracts cannot sign the position permit.\n- **(b) PotVault and AirdropClaim:** Yes, only via declared or merkle distributions. The owner authors the trees, which is a documented trust assumption.\n- **(c) supply:** Cannot increase, both tokens are fixed-mint and burnable only.\n- **(d) Vesting:** Cannot be claimed early or redirected. Unused in production.\n- **(e) JIGLaunchHook:** Nobody gets the low fee without a proof for `tx.origin`, the fee is an LP fee and the only LP during the window is JIGLaunch. `beforeSwap` is view and only reverts on malformed hookData from the swapper. It cannot brick the pool.\n- **(f) privileges:** No privilege takes user funds beyond the documented ones. The keeper's min-out values bound sandwich loss. The hook owner can bind the 2 percent deepening slice once. The platform sets the launch fee.\n- **Scope B:** Fee arithmetic is exact for both orderings and both swap directions, exact-output sells are refused, split sums equal the fee, 10 percent on 1e27 fits comfortably in int128. ERC-6909 JIG claims always equal `totalClaimable`. Nobody can claim another's balance. The nested sale is bounded to the seeded range ","treeHash":null,"usage":{"cachedInputTokens":1553553,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":51674,"runtime":"claude","turns":36,"wallClockMs":874806}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e0f64c10e30e33e2","findings":[{"citation":"resolved","description":"recycle() credits the creator's stale creator+flywheel block to the flywheel through _creditEarned. _creditEarned (line 427) only resets firstCredit when e.amount == 0. If the flywheel already holds any unclaimed credit on this pool, its firstCredit stays at that old time, and recycle checks staleness against firstCredit (line 270). So the amount just moved to the flywheel can be recycled again in the same block, and the flywheel's whole ledger goes through the split with its block sent to the pot. Under the round-2 lastCredit rule the second call reverted NotStale, because the inflow refreshed lastCredit. The round-3 change introduced this. README line 106 says 'a stale creator balance feeds the flywheel'. The chain instead pays the pot, and the platform/pot/deepening cut (30-45%) is taken twice. Any address can run the chain, but it cannot profit from it. Who loses: the flywheel's beneficiaries (HolderDrip NFT holders, BuybackAndBurn, SnapshotForwarder coin holders). The same rule also makes a large fresh fee credit recyclable at once whenever an earlier unclaimed credit on the pool is 7 days old. That matches the documented 'untouched for 7 days' only if 'untouched' means 'not claimed'.","line":283,"path":"src/CreatorFeeHook.sol","reproduction":"Pool P with creator C and flywheel F = a HolderDrip, default split at a 1% fee (creator 5000 bps, flywheel 2000 bps). Day 0: a swap pays a 1,000 JIG fee in JIG, so C gets 500 and F gets 200; both firstCredit = day 0. Nobody calls C.claim or F.pull. Day 7 + 1 s, one transaction from any EOA: (1) hook.recycle(P, C): 500 is split, 15+10+125 go to pot/deepening/platform and rest = 350 goes to _creditEarned(F, 350). F's e.amount was 200 > 0, so firstCredit stays day 0. (2) hook.recycle(P, F) succeeds instead of reverting NotStale and splits 550: 16.5 to pot, 11 to deepening, 137.5 to platform, 385 to the pot (line 284). Expected (README 'stale creator balance feeds the flywheel', and the pre-round-3 behaviour): step 2 reverts NotStale and F keeps 550 for the holders. Actual: F's claimable on P drops to 0 and the holders get nothing from either balance. Confirmed locally with a harness. It deploys a PoolManager, deploys CreatorFeeHook at a 0x2844-flagged address, uses the test contract as factory, registers a coin0/JIG pool at 1% with ticks -20000..20000, initializes it, adds liquidity, seals, warps past the snipe window, and does an exact-input coin->JIG sell of 1e20. After warping 7 days + 1 s, hook.recycle(key, creator) followed by hook.recycle(key, flywheel) in the same block does NOT revert (test asserting vm.expectRevert(NotStale) failed with 'next call did not revert as expected'). Suggested fix: credit the recycled block to the flywheel with _credit(fly, rest) instead of _creditEarned, so recycled money is never re-recyclable, or set e.firstCredit = block.timestamp for a recycle inflow.","severity":"low","snippet":"        if (from == c.creator && fly != c.creator) _creditEarned(id, fly, rest);","title":"Round-3 firstCredit fix lets one transaction chain recycle(creator) into recycle(flywheel), sending a stale creator's block to the pot instead of the flywheel"},{"citation":"resolved","description":"launch() pays platform.call{value: launchFeeWei} and then reads launchFeeWei from storage again to size the refund. nonReentrant guards launch() but not setLaunchFee(), and setLaunchFee() is callable by the platform. If the platform contract (for example a Safe with a fallback or module) calls setLaunchFee() inside the ETH receipt, the refund is computed from the new value. Raising the fee to >= msg.value skips the refund and the creator's excess stays in CoinFactory, which has no receive or withdraw function, so it is lost. Lowering it makes the refund larger than the ETH the factory still holds, so the launch reverts (or, if the factory holds stray ETH from a forced send, pays that out). The platform is trusted, so this is informational. The fix is one line.","line":166,"path":"src/CoinFactory.sol","reproduction":"launchFeeWei = 0.01 ether. The platform is a contract whose receive() calls factory.setLaunchFee(1 ether). A creator calls launch{value: 0.012 ether}(p). The platform receives 0.01, sets the fee to 1 ether, and `msg.value > launchFeeWei` is false. Expected: refund 0.002 ether to the creator. Actual: no refund, and 0.002 ether stays in CoinFactory for good. Fix: cache `uint256 fee = launchFeeWei;` at the top of launch() and use `fee` for the check, the platform payment and the refund.","severity":"info","snippet":"        if (msg.value > launchFeeWei) {\n            (bool r,) = msg.sender.call{value: msg.value - launchFeeWei}(\"\");","title":"launch() reads launchFeeWei from storage after the external call to platform, so the refund can be wrong and the excess stranded in the factory"}],"hash":"3c68464062a945bf8fd3a4e4f16dfc7cf145c95c7fbe74e080f428ccd7cc8a08","nodeId":"992fe838-e7bd-4c45-86e8-d83919940ead","outcome":"completed","summary":"Round 4 found nothing above Low. All four round-3 fixes hold. There is one Low, which the round-3 `firstCredit` change itself introduced, plus one Info. Both are in `.imd-findings.json`.\n\n## Findings\n\n**1. Low — `CreatorFeeHook.recycle` (`src/CreatorFeeHook.sol:283`): one transaction can recycle a stale creator's balance twice, so it lands in the pot instead of the flywheel.**\n- `recycle(key, creator)` moves the creator's block to the flywheel through `_creditEarned`.\n- `_creditEarned` (line 427) only restarts `firstCredit` when the flywheel's balance on that pool is zero. If the flywheel already has an unclaimed credit at least 7 days old, the money just moved to it counts as stale too.\n- So `recycle(key, flywheel)` succeeds in the same block. The creator's block goes to the pot, the platform/pot/deepening cut is taken twice, and the flywheel's holders get nothing.\n- Under the round-2 `lastCredit` rule the second call reverted `NotStale`.\n- I confirmed this with a local harness: real PoolManager, hook at a 0x2844 address, 1% pool, sell, warp 7 days + 1 s, then both recycles. The test expecting `NotStale` failed with \"did not revert\". The harness was removed afterwards.\n- Fix: credit the recycled amount with `_credit(fly, rest)` so it can never be recycled again.\n\n**2. Info — `CoinFactory.launch` (`src/CoinFactory.sol:166`): the refund re-reads `launchFeeWei` after the ETH call to `platform`.**\n- If the platform contract calls `setLaunchFee` while receiving that ETH, the refund is sized from the new fee.\n- Example: fee 0.01, creator sends 0.012, platform raises the fee to 1 ETH. The 0.002 refund is skipped and stays in the factory, which has no way to withdraw it.\n- The platform is trusted, so this is informational. Fix: read the fee into a local variable once at the top of `launch`.\n\n## Round-3 fixes\n- **`seal()` order:** `seal` is factory-only, and the factory calls it right after `_seed` in the same transaction. Between register, initialise, seed and seal, the only outside contact is permit2 moving a plain ERC-20, so nobody gets a turn. If seeding fails the whole launch reverts, so a pool cannot end up registered but unsealed.\n- **Factory's own seed:** the liquidity lock cannot block it, because `seeded` is still false when the seed is added. Nobody can front-run it either: only the factory can register, and the pool cannot be initialised until it is registered. Adding zero liquidity (fee collection) goes through the remove path, which the hook doesn't intercept.\n- **Launch refund:**\n  - No reentrancy: `launch` is `nonReentrant` and the refund is the last step.\n  - A `msg.sender` whose receive reverts only reverts its own launch.\n  - The creator could buy during the refund callback, but would pay the 60% snipe fee; that only hurts them.\n- **Constructor checks on the AirdropClaim:** both hold.\n- **`firstCredit` across claim epochs:** it restarts correctly on a new epoch and after a recycle. The one problem is finding 1.\n- **Hook callback order:**\n  - The nested conversion swap is called by the hook itself, so the PoolManager does not call the hook back. `_converting` is a backup.\n  - No other hook function depends on running in a particular order.\n\n## Scope A (whole repo)\n- **(a) Locked LP can't be removed or moved:** yes. JIGLaunch, CoinFactory and LPDeepening have no function that transfers the position or removes liquidity. Their only PositionManager calls are mint, increase, and decrease-by-zero to collect fees.\n- **(b) Nothing leaves PotVault or AirdropClaim outside merkle distributions:** yes. The owner writes each tree, which is a trust assumption.\n- **(c) JIG and CreatorCoin supply can't grow:** yes. Each is minted once in the constructor.\n- **(d) Vesting can't be claimed early or redirected:** yes. Whoever funds a schedule picks its start, which is by design.\n- **(e) JIGLaunchHook:** the reduced fee in the window needs a `tx.origin` merkle proof. Liquidity is locked from `markSeeded` until the window ends, and no ot","treeHash":null,"usage":{"cachedInputTokens":1765264,"inputTokens":42,"model":"claude-opus-5-5","outputTokens":38410,"runtime":"claude","turns":25,"wallClockMs":532578}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e57a8e639cccfbab","findings":[{"citation":"resolved","description":"For an exact-input buy (JIG in, coin out) afterSwap takes f of the gross coin output and _sellCoinForJig sells that coin back into the same pool starting from the price the buyer's own trade just left (sqrtPriceLimit = range bottom, exact-input, no cap). The JIG recovered is the cost of the LAST f*G coin the buyer bought, i.e. the marginal segment of their own price impact, not f of what they paid. On the single-sided launch position every launch-sized buy has real price impact, so the fee the buyer actually loses in JIG grows without bound with trade size. Measured on a Sepolia fork at the repo's standard launch (1 JIG = 1000 coin, 950M coin seeded, 1% fee, snipe window over): exact-input 1k JIG -> 1.00%; 100k JIG (9% of pool) -> 1.10%; 1M JIG (50% of pool) -> 2.00%; 10M JIG (90% of pool) -> 10.19%; 50M -> 34.5%; 100M -> 51.1%; 300M (buy-out, 937M coin) -> 75.7%: 227M of the 300M JIG paid is credited to creator/platform/pot/deepening and only 73M stays in the pool backing the coin. Buying the same 937M coin with an exact-output swap costs 73.7M JIG and pays 0.99%. Exact-output buys (fee charged on the JIG input, line 351) pay the nominal rate in every case, so the two swap kinds price the same trade up to 4x apart. The buyer is not an attacker and receives exactly the quoted coin, so a router quote shows nothing wrong; the loss is that the pool keeps 73M instead of 300M JIG, which is all their coin can ever be sold back for. 'Pay X' router flows are exact-input by default, so this is the common path for a launch sniper or a large first buyer, and 70% of the inflated fee is credited to the creator (flywheel = 0) and 25% to the platform. Round 3 (#4) recorded the direction ('above nominal, by construction') as Info without the magnitude; the magnitude makes the stated 1-10% fee band untrue for any trade with price impact, which on a fresh single-sided pool is every meaningful trade. Fix (minimal, keeps the 'fee in the unspecified currency, held as JIG' design): bound the conversion to the nominal JIG value. In afterSwap when feeIsCoin: target = (|specified JIG input| * feePips) / PIPS; run the nested sale as EXACT-OUTPUT for `target` JIG (amountSpecified = +int256(target), same price limit); read coinUsed from the delta; mint JIG claims for the JIG received; return int128(coinUsed) instead of `fee` as the hook delta so the fee coin that was not needed stays with the swapper; fall back to coin claims / pendingCoin only when the price sits at the range edge and nothing can be sold. Cleaner alternative: take the buy fee on the JIG input with BEFORE_SWAP + BEFORE_SWAP_RETURNS_DELTA (return +f*amountIn on the specified currency) and drop the nested conversion entirely; that changes the hook flags/address and needs a redeploy.","line":365,"path":"src/CreatorFeeHook.sol","reproduction":"Sepolia fork (POOL_MANAGER 0xE03A1074c86CFeDd5C142C4F04F1a1536e203543, POSM 0x429ba70129df741B2Ca2a85BC3A2a3328e5c09b4, PERMIT2 0x000000000022D473030F116dDEE9F6B43aC78BA3), same setUp as test/Regressions.fork.t.sol: deploy PotVault, JIGLaunch, JIG, launch the JIG pool at tick 202400 (range -887200..202400), deployCodeTo CreatorFeeHook at a 0x2844-flag address, CoinFactory(0.01 ether fee), AirdropClaim, bind both factories; buyer swaps 10 ETH on the JIG pool for ~783M JIG. Launch a coin with feePips 10_000, flywheel 0, creatorShareBps DEFAULT, creatorBps 0, price 1 JIG = 1000 coin (coinIs0: lower = (startTick/200+1)*200, upper 887_200; else lower -887_200, upper = (startTick/200-1)*200). vm.warp(block.timestamp + 11). For each S in {1e21, 1e22, 1e23, 1e24, 1e25, 5e25, 1e26, 3e26}: snap = vm.snapshotState(); PoolSwapTest.swap(key, {zeroForOne: !coinIs0, amountSpecified: -int256(S), limit MIN+1/MAX-1}); spent = buyer JIG delta, got = coin.balanceOf(buyer), feeJig = hook.heldJig() delta; effective = feeJig*10000/spent; vm.revertToState(snap); then the same with amountSpecified = +int256(got) (exact-output for the same coin). Expected (README: the fee is the creator's 1-10% of the swap, same rate both directions): ~100 bps for both kinds at every size. Actual, exact-input / exact-output in bps: 1k JIG 100/99; 10k 101/99; 100k 110/99; 1M 200/99; 10M 1019/99; 50M 3451/99; 100M 5107/99; 300M 7567/99. For the 300M exact-input buy: coin got 937M, feeJig 227,034,114 JIG, pool left with ~73M JIG; exact-output for the same 937M coin: spent 73,702,914 JIG, fee 737,029 JIG. hook.totalClaimable() == hook.heldJig() holds throughout: the claim accounting is consistent, the defect is the price the fee is converted at.","severity":"medium","snippet":"if (feeIsCoin) jigAmount = _sellCoinForJig(key, c, fee);","title":"Exact-input buys pay the 1-10% fee at the marginal post-trade price: 2x nominal at half the pool, 10x at 90%, 76x on a buy-out, while exact-output pays exactly f"},{"citation":"resolved","description":"Round 3 changed launch() from an exact-fee check to msg.value >= launchFeeWei with the excess refunded (lines 166-169), so a creator who pads msg.value to survive a fee update (the stated reason for the change) now has no upper bound on what they agree to pay: the fee charged is whatever launchFeeWei is when the transaction executes, up to msg.value. The platform (the only caller of setLaunchFee) can raise the fee to msg.value in the same block, or an honest fee update that lands first silently consumes the padding. Before round 3 an unexpected fee simply reverted the launch; now it is paid. The platform is a trusted role, so this is a trust-assumption and griefing note rather than a theft, but it is a regression the fix introduced: the rationale 'a fee update landing before a quoted launch must not revert it' is only safe when the creator can state the maximum they accept. Fix: add uint256 maxFeeWei to LaunchParams, revert WrongFee when launchFeeWei > p.maxFeeWei (keep msg.value >= launchFeeWei and the refund of the difference). The front end passes the quoted fee plus its tolerance.","line":122,"path":"src/CoinFactory.sol","reproduction":"Same fork setUp as test/Regressions.fork.t.sol::test_launch_overpayment_is_refunded (launchFeeWei = 0.01 ether). The creator submits factory.launch{value: 0.5 ether}(params) expecting a 0.49 ETH refund. The platform's setLaunchFee(0.5 ether) is mined first (vm.prank(platform); factory.setLaunchFee(0.5 ether)). Expected (the creator's intent and the pre-round-3 behaviour): revert WrongFee, or pay 0.01 ETH and refund 0.49. Actual: launch succeeds, creator.balance drops by exactly 500000000000000000 wei, the platform receives 0.5 ETH, refund 0. With the exact-fee check that round 3 replaced, the same transaction reverted.","severity":"low","snippet":"if (msg.value < launchFeeWei) revert WrongFee(); // a fee update landing before a quoted launch must not revert it; excess is refunded","title":"launch() refund introduced in round 3 lets a setLaunchFee front-run capture the creator's whole overpayment"}],"hash":"761d33da8e4aaee039c421ea3111caeb5231dbd50f233b3cdb439274b83d2e29","nodeId":"c7d7d501-d7fb-461f-91f9-54ab0fd05ee7","outcome":"completed","summary":"The findings file is written and validated, the scratch tests are removed, and the tree is clean. Here is the report.\n\n## Outcome\n\nTwo findings survived verification and are in `.imd-findings.json`: one Medium, one Low. Nothing Critical or High. All 62 existing tests pass on a Sepolia fork (17 Regressions, 10 CreatorCoin, 9 JIGLaunchHook, 8 Flywheels, 14 unit). Every round-3 fix holds. The swarm phase is not quite clean: the Medium is a magnitude the round-3 Info item #4 understated by two orders of magnitude.\n\n## Findings\n\n**Medium. Exact-input buys pay the fee at the marginal post-trade price** (`src/CreatorFeeHook.sol:365`). The fee coin is sold back from the price the buyer's own trade just set, so the JIG recovered is the cost of the last 1% of coin they bought, not 1% of what they paid. Measured on the fork at the standard launch with a 1% fee, exact-input versus exact-output for the same coin:\n\n| JIG spent | share of pool | exact-input fee | exact-output fee |\n|---|---|---|---|\n| 100k | 9% | 1.10% | 0.99% |\n| 1M | 50% | 2.00% | 0.99% |\n| 10M | 90% | 10.2% | 0.99% |\n| 300M | buy-out | 75.7% | 0.99% |\n\nOn the buy-out, 227M of 300M JIG goes to fee recipients and 73M stays in the pool backing the coin. Routers default to exact-input, so this is the launch-sniper path, and 70% of the inflated fee is credited to the creator. Proposed fix: sell the fee coin exact-output for `f × JIG input` and return only the coin actually used as the hook delta, or move the buy fee to the JIG input side with a before-swap return delta.\n\n**Low. The round-3 launch refund lets a fee front-run capture the whole overpayment** (`src/CoinFactory.sol:122`). A creator sending 0.5 ETH against a 0.01 ETH fee pays 0.5 ETH if the platform raises the fee first. Before round 3 that reverted. Fix: a `maxFeeWei` in the launch params.\n\n## Round-3 fixes, verified\n\n- **seal before seed or skipped:** No. Only the factory calls it, after its own mint in the same transaction. There is no path that registers without sealing.\n- **Factory seed blocked by the lock:** No. The pool is registered, initialised, seeded and sealed atomically, so no one can add liquidity in between.\n- **Launch refund reentrancy or griefing:** No. The call is guarded, runs after all state, and only the overpaying caller can revert it.\n- **firstCredit across epochs:** Correct. An epoch change resets the ledger before the zero-amount check, and a recycle leaves the amount at zero so the next credit restarts the clock.\n- **Hook callable out of order:** No. Seal on an unregistered pool reverts, seal is idempotent, and unregistered pools cannot be initialised.\n\n## Threat model answers\n\n- **(a) LP positions locked:** Yes. No transfer or decrease function exists, and the holders cannot produce EIP-1271 signatures for permit.\n- **(b) PotVault and AirdropClaim exits:** Yes, only through merkle claims or sweeps to the pot.\n- **(c) Supply fixed:** Yes, neither token has a mint path.\n- **(d) Vesting:** Yes, linear with cliff, beneficiary-only, irrevocable.\n- **(e) JIGLaunchHook:** Yes, the proof is checked against tx.origin per swap. Malformed hookData reverts only that swap, so the hook cannot brick the pool.\n- **(f) Privileged powers:** Trust assumptions only, plus the Low above. Owner-set merkle roots, keeper min-out, and market approval are documented.\n- **Scope B:** No swap pays less than the fee. The converting guard has no untrusted callback inside it. Claim accounting holds, with totalClaimable equal to held JIG in every probe including a full buy-out and sell-back. Access control and flag bits are correct and validated in the constructor.\n- **Scope C:** HolderDrip's checkpoints are correct for LoomCollection's 1-based monotone supply. SweepAndBurn, BuybackAndBurn and LPDeepening unlock callbacks are PoolManager-only and settle exact deltas. AirdropClaim is per-coin isolated.\n\n**L2 note for Robinhood Chain:** the 10-second anti-snipe window covers five blocks, 4337 wallets pay 60% in the JIG windo","treeHash":null,"usage":{"cachedInputTokens":2335739,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":69582,"runtime":"claude","turns":41,"wallClockMs":1265311}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e8816d4386532a66","findings":[{"citation":"resolved","description":"Round 3 moved the recycle clock from Earned.lastCredit to Earned.firstCredit. _creditEarned (line 427) only sets firstCredit when e.amount == 0, so once ANY unclaimed credit on a pool is RECYCLE_AFTER old, recycle() takes e.amount in full (line 271), which includes every credit since, down to one made in the same block. Two consequences, both introduced by the round-3 change (merged from two specialists, same root cause, one fix): (1) for an actively traded coin whose flywheel (HolderDrip, SnapshotForwarder, BuybackAndBurn, SweepAndBurn) or creator has not pulled for 7 days, anyone can fold the WHOLE 7 days of earnings through the split, not just the stale part; the README (line 188) says a balance 'untouched for 7 days' is recyclable, which readers take to mean the stale money, not fresh fees. (2) recycle(creator) credits the creator's block to the flywheel via _creditEarned (line 283); since the flywheel's e.amount is already > 0 its firstCredit stays old, so recycle(flywheel) succeeds in the same transaction and the block that was meant to 'feed the flywheel' (README line 106) goes to the pot instead, with the platform/pot/deepening cut taken twice. Under the round-2 lastCredit rule the second call reverted NotStale. Nobody profits except the platform/pot; the losers are the flywheel's beneficiaries (NFT holders, buyback) and the creator. Severity Low: no attacker gain, needs a week of nobody calling the permissionless pull(), but it contradicts the documented recycle semantics and is the only behaviour the round-3 fix set made worse. Fix (keeps the design): track two buckets per Earned entry, e.g. {uint128 stale; uint128 recent; uint64 recentSince}: in _creditEarned, if block.timestamp >= recentSince + RECYCLE_AFTER then stale += recent, recent = 0, recentSince = now; then recent += amount. recycle() rolls the same way, requires stale > 0 and recycles only `stale`. And in recycle() credit the flywheel with _credit(fly, rest) instead of _creditEarned so recycled money is never re-recyclable (or set e.firstCredit = block.timestamp on that inflow). Operational mitigation until then: the keeper pulls every module at least weekly.","line":270,"path":"src/CreatorFeeHook.sol","reproduction":"Sepolia fork (test/scratch/Judge.fork.t.sol::test_F1, same setUp as test/Regressions.fork.t.sol: PoolManager 0xE03A1074c86CFeDd5C142C4F04F1a1536e203543, hook at a 0x2844-flag address, factory fee 0.01 ETH). Launch a coin at 1 JIG = 1000 coin with flywheel = a HolderDrip, feePips 10_000, default split. warp +11s. Buyer exact-input buys 1,000 JIG: claimable(creator) = 5005066100453389722 (5.0 JIG), claimable(drip) = 2002026440181355890 (2.0 JIG). warp +7 days + 1 s. Buyer exact-input buys 1,000,000 JIG in the same block as the recycles: claimable(creator) = 10014807753850665337308 (10,014 JIG), claimable(drip) = 4005923101540266134926 (4,005 JIG). Anyone calls hook.recycle(key, creator): EXPECTED (README 'untouched for 7 days'): ~5.0 JIG recycled, the 10,009 JIG credited this block stays with the creator. ACTUAL: returns 10014807753850665337308, the whole balance including the same-block credit. Then, same block, hook.recycle(key, drip): EXPECTED (README line 106 'a stale creator balance feeds the flywheel', and pre-round-3 behaviour): revert NotStale, drip keeps its 4,005 JIG plus the ~7,010 JIG block just moved to it. ACTUAL: succeeds, returns 11016288529235731871042 (11,016 JIG), claimable(drip) == 0; pot gains 8762956784619332170147 JIG and platform 5257774070771599302087 JIG across the two calls. heldJig() == totalClaimable() holds throughout (accounting is consistent; the defect is who gets the money).","severity":"low","snippet":"        if (block.timestamp < e.firstCredit + RECYCLE_AFTER) revert NotStale(); // from the first unclaimed credit, so a dust buy cannot reset the clock","title":"recycle() staleness runs from firstCredit, which never refreshes while a balance is outstanding: fresh credits are swept with the stale one, and recycle(creator) chains into recycle(flywheel) in the s"},{"citation":"resolved","description":"For an exact-input buy (JIG in, coin out) afterSwap takes f of the coin output and _sellCoinForJig sells that coin back into the same pool, exact-input with the range edge as the only limit, starting from the price the buyer's own trade just left. The JIG recovered is the cost of the LAST f*G coin the buyer bought (the marginal segment of their own price impact), not f of what they paid. On the single-sided launch position every launch-sized buy has real price impact, so the JIG the buyer effectively loses to the fee grows without bound with trade size, while exact-output buys (fee charged on the JIG input, line 351) pay the nominal rate at every size. The buyer receives exactly the quoted (1-f)*G coin, so a router quote shows nothing wrong; the loss shows up as the pool keeping far less JIG than the buyer paid, which is all their coin can ever be sold back for, and as the creator (70%) and platform (25%) being credited many multiples of the stated fee. Round 3 (#4) recorded the direction ('above nominal, by construction') as Info without the magnitude; measured here it makes the stated 1-10% band untrue for any trade above about 10% of the pool. Severity Low: no third party can trigger it, the buyer is not an attacker, and it was accepted as a design property in round 3; reported because the magnitude at realistic launch-sniper sizes (50% of pool -> 2x, 90% -> 10x) was not on the table when it was accepted. Fix (keeps the 'fee in the unspecified currency, held as JIG' design): bound the conversion to the nominal JIG value: target = (|specified JIG input| * feePips) / PIPS; run the nested sale as EXACT-OUTPUT for `target` JIG (amountSpecified = +int256(target), same price limit); read coinUsed from the delta; mint JIG claims for the JIG received; return int128(coinUsed) as the hook delta so the coin not needed stays with the swapper; fall back to pendingCoin only when the price sits at the range edge. Alternative: take the buy fee on the JIG input with BEFORE_SWAP + BEFORE_SWAP_RETURNS_DELTA and drop the nested conversion (new hook flags, redeploy).","line":365,"path":"src/CreatorFeeHook.sol","reproduction":"Sepolia fork (test/scratch/Judge.fork.t.sol::test_F4, standard launch: 1 JIG = 1000 coin, 950M coin seeded single-sided from the start tick to the range end, feePips 10_000, flywheel 0, warp +11s past the snipe window). For each size S the buyer does an exact-input buy of S JIG on a state snapshot, reading spent = buyer JIG delta, feeJig = hook.heldJig() delta; then on the same snapshot an exact-output buy of the same coin amount. EXPECTED (README: the creator's 1% fee): ~100 bps both ways at every size. ACTUAL, exact-input bps / exact-output bps: 1k JIG 100/99; 10k 101/99; 100k 110/99; 1M (about half the pool) 200/99; 10M 1019/99; 50M 3451/99; 100M 5107/99. At 10M JIG exact-input the hook credits 1,019,157 JIG of the 10,000,000 paid (10.19%); buying the same coin exact-output costs 9,071,557 JIG with a 90,715 JIG fee (0.99%). hook.heldJig() == hook.totalClaimable() after every swap.","severity":"low","snippet":"        if (feeIsCoin) jigAmount = _sellCoinForJig(key, c, fee);","title":"Exact-input buys pay the fee at the marginal post-trade price: 2x nominal at half the pool, 10x at 90%, while exact-output buys of the same coin pay exactly f"},{"citation":"resolved","description":"Round 3 changed launch() from an exact-fee check to msg.value >= launchFeeWei with the excess refunded. The stated reason is that a creator pads msg.value to survive a fee update; the effect is that the creator no longer bounds what they agree to pay: the fee charged is whatever launchFeeWei is when the transaction executes, up to msg.value. The platform (the only caller of setLaunchFee) can raise the fee to msg.value in the same block, or an honest fee update that lands first silently consumes the padding. Before round 3 the same transaction reverted WrongFee. The platform is a trusted role, so this is a trust-assumption note under threat-model item (f) rather than a theft, but it is a new privilege the round-3 fix introduced: the platform can now take an arbitrary amount of a creator's ETH, bounded only by what the creator attached. Fix: add uint256 maxFeeWei to LaunchParams and revert WrongFee when launchFeeWei > p.maxFeeWei (keep msg.value >= launchFeeWei and the refund). The front end passes the quoted fee plus its tolerance. While there, cache `uint256 fee = launchFeeWei;` at the top and use it for the check, the payment and the refund (see the Info finding on line 166).","line":122,"path":"src/CoinFactory.sol","reproduction":"Sepolia fork (test/scratch/Judge.fork.t.sol::test_F3, same setUp as test/Regressions.fork.t.sol::test_launch_overpayment_is_refunded, launchFeeWei = 0.01 ether). vm.prank(platform); factory.setLaunchFee(0.5 ether); then vm.prank(creator); factory.launch{value: 0.5 ether}(params). EXPECTED (the creator's intent and pre-round-3 behaviour): revert WrongFee, or pay 0.01 ETH and refund 0.49 ETH. ACTUAL: launch succeeds, creator.balance drops by exactly 500000000000000000 wei, platform.balance rises by 500000000000000000 wei, refund 0.","severity":"low","snippet":"        if (msg.value < launchFeeWei) revert WrongFee(); // a fee update landing before a quoted launch must not revert it; excess is refunded","title":"launch() has no caller-stated maximum fee since the round-3 refund change, so a setLaunchFee mined first takes the creator's whole msg.value"},{"citation":"resolved","description":"The refund path compares msg.value with the storage variable launchFeeWei (lines 164, 166, 167) after an external call to the platform. nonReentrant guards launch() but not setLaunchFee(). If the platform is a contract (a Safe with a module or fallback handler, for example) whose receive() calls setLaunchFee(), the second read differs from the first: with msg.value 0.015 ETH and the fee raised from 0.01 to 0.015 (or anything >= msg.value) during receive, the platform is paid 0.01, `msg.value > launchFeeWei` is false, and 0.005 ETH stays in CoinFactory forever (no withdraw, no receive, nothing can ever move it). Lowering it instead makes the refund larger than the factory holds and the launch reverts. The platform is trusted and gains nothing, so Info (merged from two specialists), but it is the only path by which ETH can be stuck in the factory and the fix is one line: cache `uint256 fee = launchFeeWei;` at the top of launch() and use `fee` for the check, the platform payment and the refund.","line":166,"path":"src/CoinFactory.sol","reproduction":"Sepolia fork (test/scratch/Judge.fork.t.sol::test_F2). Deploy a second hook/factory pair whose platform is a contract with receive() { factory.setLaunchFee(0.015 ether); }, launchFeeWei = 0.01 ether. Creator calls launch{value: 0.015 ether}(params). EXPECTED: platform receives 0.01 ETH, creator refunded 0.005 ETH. ACTUAL: platform receives 10000000000000000 wei, creator paid 15000000000000000 wei, refund 0, address(factory).balance == 5000000000000000 wei permanently.","severity":"info","snippet":"        if (msg.value > launchFeeWei) {","title":"launch() re-reads launchFeeWei from storage after paying the platform, so a fee change inside the platform's receive() strands the creator's refund in the factory"},{"citation":"resolved","description":"Hooks.afterSwap in v4-core (lib/v4-periphery/lib/v4-core/src/libraries/Hooks.sol line 293: `if (msg.sender == address(self)) return (swapDelta, BalanceDeltaLibrary.ZERO_DELTA);`) returns before calling the hook whenever the swap's msg.sender is the hook itself, and the nested conversion in _sellCoinForJig is a poolManager.swap() made by the hook. So afterSwap is never re-entered during the conversion, the branch at line 339 is unreachable, and the double-charge / double-credit protection the README ('guarded by _converting so the nested swap is not charged again', line 193) and the threat model attribute to _converting is actually the PoolManager's self-call rule. Not a vulnerability (the outcome is correct either way); the two storage writes cost about 5k gas per coin-fee swap and the README's reasoning should cite the v4 rule. The same noSelfCall rule means a seal would not bind a modifyLiquidity call made by the hook itself; the hook has no such code path and must not gain one. Fix: delete _converting and its three uses, or keep it as belt-and-braces and correct the comments.","line":339,"path":"src/CreatorFeeHook.sol","reproduction":"Static: lib/v4-periphery/lib/v4-core/src/libraries/Hooks.sol line 293 returns before the hook call when msg.sender == address(self); _sellCoinForJig (line 388) calls poolManager.swap from the hook, so msg.sender in Hooks.afterSwap is the hook. Dynamic: any exact-input buy on a creator pool after launch (test/scratch/Judge.fork.t.sol::test_F4, 1,000 JIG in) enters afterSwap exactly once per outer swap; a `require(!_converting)` placed at line 339 never fires.","severity":"info","snippet":"        if (_converting) return (IHooks.afterSwap.selector, 0);","title":"_converting is dead code: v4 never calls afterSwap for the hook's own nested swap, so the fee-skip protection comes from the PoolManager, not the flag"},{"citation":"resolved","description":"Until setDeepening() runs, deepening == pot, so the BadFlywheel list excludes only the pot. The deploy order is hook, factory, then LPDeepening (DeployDeepening.s.sol binds it afterwards), and the LPDeepening address is a plain CREATE address predictable from the deployer nonce. A launch in that gap with flywheel == the future LPDeepening passes register(), and setDeepening() to that address later succeeds. Consequence: that pool's flywheel slice is credited to LPDeepening and its stale balance is recyclable by anyone to pot/deepening/platform (the flywheel side's remainder goes to the pot), never to the recycler, and LPDeepening.pull() is permissionless, so no value is extractable; it only defeats the stated intent of the exclusion list. Fix if wanted: have CoinFactory.launch refuse launches while hook.deepening() == hook.pot() (one line), or have setDeepening refuse an address already registered as a flywheel.","line":173,"path":"src/CreatorFeeHook.sol","reproduction":"Sepolia fork (test/scratch/Judge.fork.t.sol::test_F6). Deploy hook and factory, do not call setDeepening. launch({... flywheel: X ...}) with X = the address LPDeepening will get. EXPECTED: BadFlywheel. ACTUAL: PoolRegistered with pools[id].flywheel == X; then vm.prank(owner); hook.setDeepening(X) succeeds and hook.deepening() == X, so every swap on that pool credits X both the 2% deepening slice and the flywheel block.","severity":"info","snippet":"            flywheel == platform || flywheel == pot || flywheel == deepening || flywheel == address(this)","title":"register() checks the flywheel against the deepening address bound at registration time, so a coin launched before setDeepening can name the future LPDeepening as its flywheel"},{"citation":"resolved","description":"_split() credits the platform by the band of the fee at swap time; recycle() re-splits the stale creator or flywheel balance by the band of the fee at recycle time. After lowerFee() from the 7-10% band (platform 4000) to the 1% band (platform 2500), a stale balance earned under the old band pays the platform 25% instead of 40% on recycling, with the difference going to the flywheel (creator's stale money) or the pot (flywheel's stale money). The recycler never gains and the creator cannot raise the fee, so the only direction is less to the platform. Accept and document, or store the band with the Earned entry.","line":278,"path":"src/CreatorFeeHook.sol","reproduction":"Sepolia fork (test/scratch/Judge.fork.t.sol::test_F7). Pool registered at feePips 100_000 (platform band 4000). After the snipe window a 1,000 JIG exact-input buy credits the creator 55050656338532086231 (55.05 JIG). Creator calls lowerFee(key, 10_000). warp +7 days + 1 s; anyone calls recycle(key, creator). EXPECTED under the earning band: platform credited 40% = 22.02 JIG. ACTUAL: platform credited 13762664084633021557 = exactly 25% (55.05 * 2500 / 10_000).","severity":"info","snippet":"        uint256 plat = (amount * platformBps(c.feePips)) / 10_000;","title":"recycle() re-splits with the platform band of the current feePips, not the band in force when the balance was earned"},{"citation":"resolved","description":"accPerToken grows by floor(amount*1e18/live) but accounted grows by the full amount (line 84). The remainder, less than `live` wei per pull, is counted as distributed yet is claimable by no token. undistributed() = balance - (accounted - paidOut) excludes it, so it is never re-spread and sits in the contract forever. Magnitude is below the live supply in wei per pull (a 10,000-token collection strands at most 1e4 wei per pull), so no fix is required; if wanted, grow `accounted` by (accDelta * live) / ACC instead of `amount` so the remainder stays undistributed and is picked up by the next pull.","line":83,"path":"src/HolderDrip.sol","reproduction":"Sepolia fork (test/scratch/Judge.fork.t.sol::test_F5). HolderDrip on a collection with 3 live tokens; transfer 10 wei of JIG to it; pull(): accPerToken += 3333333333333333333, accounted += 10. The holder claims ids 1..3: EXPECTED all 10 wei eventually distributable. ACTUAL: claim returns 9, jig.balanceOf(drip) == 1, undistributed() == 0, so no later pull can ever re-spread the 1 wei.","severity":"info","snippet":"        accPerToken += (amount * ACC) / live;","title":"HolderDrip.pull() books the full amount as distributed but the accumulator floors, so up to live-1 wei per pull is owed to nobody and never re-spread"}],"hash":"9f4d241c6db936c4a03d96ac78b6d98bb504e48265536af5dba79aced23751e6","nodeId":"4f32963d-8792-444b-8aad-45f821677199","outcome":"completed","summary":"The findings file validates: eight findings, every snippet matches its line, no tracked file changed (only `lib/`, `test/scratch/` and `.imd-findings.json` were created).\n\n## Verdict\n\nNothing above Low remains. Every round-3 fix holds. I reproduced every kept finding on a Sepolia fork with a scratch harness (`test/scratch/Judge.fork.t.sol`, 8 tests, all pass with the numbers quoted in the findings). The specialists' 12 reports collapsed to 8 findings: 3 Low, 5 Info. One specialist report (the \"round-4 result\" statement) was a summary, not a defect, and the dust-buy rounding note was dropped as behaviour, not a defect.\n\n**Low**\n- `recycle()` clock from `firstCredit` sweeps fresh credits with the stale one and lets `recycle(creator)` chain into `recycle(flywheel)` in the same block. Merged from two specialists. Reproduced: 10,014 JIG credited the same block was swept; the flywheel lost 11,016 JIG to the pot where the README says it should have been fed. This is the one behaviour the round-3 fixes made worse.\n- Exact-input buys pay the fee at the marginal post-trade price. Measured 200 bps at half the pool and 1019 bps at 90%, against 99 bps for exact-output buys of the same coin. Round 3 accepted the direction as Info; I rate the magnitude Low and leave the acceptance to Jj.\n- `launch()` has no caller-stated max fee since the refund change, so a `setLaunchFee` mined first takes the whole `msg.value`. Trusted role, but a new power under threat-model item (f). Reproduced: creator lost 0.5 ETH.\n\n**Info**: `launchFeeWei` re-read after the platform call strands a refund (0.005 ETH stuck); `_converting` is dead code because v4 skips `afterSwap` on self-calls (`Hooks.sol:293`); a pre-`setDeepening` launch can name the future LPDeepening as flywheel; `recycle()` uses the current fee band; HolderDrip strands under `live` wei per pull.\n\n## Round-3 fix verification\n- `seal()` ordering: register, initialize, seed and seal all run inside one `launch()` transaction with no untrusted callback between them. `beforeInitialize` and `beforeAddLiquidity` both require `registered`, which only the factory sets, so the pool cannot be pre-initialised or front-seeded. Confirmed by test.\n- Launch refund: `nonReentrant`, both pushes after all state writes. A reverting `msg.sender` reverts only its own overpaid launch.\n- `firstCredit` across epochs: a claim bumps the epoch and the next credit starts a fresh clock. The repo's own regression test and my test confirm it. The side effect is the Low above.\n- Hook call order: nothing else is order-sensitive.\n\n## Threat model answers\n- A(a) no: JIGLaunch, CoinFactory and LPDeepening only mint, increase, or decrease by zero; no transfer or approve path. A(b) no: PotVault and AirdropClaim move tokens only to proven leaves or the pot. A(c) no: both tokens mint once in the constructor. A(d) no: Vesting is irrevocable and pays only the stored beneficiary. A(e) no: the 1.5% needs a proof for `tx.origin`; the only revert in `beforeSwap` is the swapper's own malformed hookData. A(f) only the new `setLaunchFee` capture above, plus the documented keeper residuals.\n- B(a) no, both directions pay rate f at the nominal level and exact-output sells are refused; the price-impact Low is the caveat. B(b) no, and the real guard is v4's self-call rule. B(c) no: `heldJig == totalClaimable` held after every swap, recycle and claim in my tests, and `claimFor` pays only the credited address. B(d) impact falls on the buyer side, see the Low. B(e) all setters correctly gated and one-shot. B(f) bounded, no overflow. B(g) five slices sum exactly to the fee. B(h) 0x2844 and 0x2880 match the permission structs.\n- C(a) consistent, LP locked, both orderings handled. C(b) correct apart from the dust Info. C(c) keeper bounded by cap, approved market, and the JIG-outflow check; inflow tolerated. C(d) callbacks are PoolManager-only. C(e) owner powers are as documented. C(f) LoomCollection mints `totalSupply + 1`, supply is monotone, no burn: the a","treeHash":null,"usage":{"cachedInputTokens":1299968,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":34359,"runtime":"claude","turns":27,"wallClockMs":576952}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3b9f88f55251764d","findings":[{"citation":"resolved","description":"Plain statement for the swarm-phase decision: after a genuine pass over the whole base with the Math Precision, Boundary and Numerical Gap guides, plus the round-3 regression questions, no defect above Info remains. Verified on a Sepolia fork with a local harness (test contract acting as factory, hook deployed at a 0x2844-flagged address, pool seeded through PoolModifyLiquidityTest, PoolSwapTest swaps): (1) seal ordering: seal() is factory-only, register() must precede it (NotRegistered) and the factory calls it in the same launch() transaction immediately after _seed, so no window exists in which a pool is registered but open; beforeAddLiquidity reverts LiquidityLocked both before register (unregistered id) and after seal, and the factory's own mint passes because it runs between the two; the factory's seed cannot be front-run because the pool id includes the hook and beforeInitialize requires the registered flag, which only the factory can set. (2) Launch refund: launch() is nonReentrant and both ETH pushes happen after all state writes; a reverting msg.sender only reverts its own launch; a reverting platform blocks every launch but the platform is the trusted team wallet. (3) firstCredit across epochs: a second credit at day 3 left firstCredit at the first credit (1792139315), recycle at day 7+ moved 10.0109 JIG as 70% flywheel / 25% platform / 5% pot+deepening; after claim() the epoch moves on, the next credit resets firstCredit to the credit time and recycle reverts NotStale. (4) Hook call order: register before seal, seal before any liquidity, afterSwap requires registered, beforeInitialize requires registered; nothing else is order-sensitive. Fee arithmetic measured at feePips=10_000 after the snipe window, coin as currency0 at tick -69000 with 900M coin single-sided: exact-input buy of 1000 JIG paid 10.0109 JIG (1.0011% because the coin fee is sold at the post-trade price), exact-output buy of the same 980,932 coin cost 1002.17 JIG with 10.0217 JIG fee (f/(1-f) scaling gives the same rate), exact-input sell paid 19.82 of 1962.3 JIG (1.01%). heldJig == totalClaimable held after every swap, recycle and claim, and claimable(creator)+claimable(fly)+claimable(platform)+claimable(pot) == totalClaimable. After a sell drove the price to MIN_SQRT_PRICE+1 (below the range), a buy converted its fee with pendingCoin staying 0. Threat-model answers: A(a) no: JIGLaunch, CoinFactory and LPDeepening only ever call modifyLiquidities with MINT/INCREASE/DECREASE(0)+TAKE_PAIR and hold no approve/transfer path for the ERC-721; A(b) no: PotVault and AirdropClaim move tokens only to proven leaves or the pot; A(c) no: JIG and CreatorCoin mint once in the constructor; A(d) no: Vesting has no revoke or redirect and release pays only the stored beneficiary; A(e) no: the 1.5% needs a proof for tx.origin, the only revert in beforeSwap is the swapper's own malformed hookData; A(f) no new path, the keeper's self-sandwich within minOut and the sweeper buying its own listing at the cap remain the documented residual trust. B(a) no: both directions pay rate f, exact-output sells are refused; B(b) no: _converting is set only around the nested swap and only afterSwap reads it, no other code runs during the nested swap; B(c) no: totalClaimable == heldJig is maintained by _split, recycle and _claim, and claimFor pays only the credited address; B(d) price impact of the conversion is borne in the fee recipients' favour (buyer-side), partial fills are retried from pendingCoin; B(e) all setters are owner/factory/creator gated and one-shot where stated; B(f) currentFee is bounded in [feePips, 600_000] with a divisor of 10, no overflow; B(g) the five slices sum exactly to the fee (flywheel is the remainder) and 10% on a 2^90-scale output is far below int128; B(h) 0x2844 and 0x2880 match the permission structs and validateHookPermissions runs in both constructors. C: the LoomCollection assumptions (ids from totalSupply+1, monotone totalSupply, no burn, transfers ","line":255,"path":"src/CreatorFeeHook.sol","reproduction":"State: Sepolia fork at block 11926200, PoolManager 0xE03A1074c86CFeDd5C142C4F04F1a1536e203543, hook deployed at an address with low bits 0x2844, register(key, creator, fly, 10_000, DEFAULT_SHARE, -69000, 887200), initialize at getSqrtPriceAtTick(-69000), 900M coin seeded via PoolModifyLiquidityTest, seal(key), warp +11s. Expected and actual: lp.modifyLiquidity(+1e18) reverts; exact-input buy of 1000e18 JIG credits 10010911136832768205 JIG; exact-output buy of 980932006614357391994920 coin costs 1002173565597836651018 JIG and credits 10021735655978366510; exact-input sell of all coin pays 19821409188050255163 of 1962319509616975261139 JIG; heldJig() == totalClaimable() after each step. Recycle: credits at t and t+3d, recycle at t+7d+1 returns the full earned amount; after claim() the next credit sets firstCredit == block.timestamp and recycle reverts NotStale.","severity":"info","snippet":"    function seal(PoolKey calldata key) external {","title":"Round-4 result: every round-3 fix holds and nothing above Info was found in the math, boundary and numerical-gap passes"},{"citation":"resolved","description":"Boundary x precision seam. For an exact-input buy the fee is f of the coin output, which is then sold into the same pool. When fee * price < 1 wei of JIG the nested swap consumes the coin (owed > 0) but returns jigDelta == 0, so _split(c, 0) credits nobody and the swapper has still lost the coin. The fee itself is also floored to zero when the output is below PIPS/feePips wei (99 wei at 1%). Both are dust: at 18 decimals the buyer would need to split a trade into sub-1000-wei pieces, and gas makes that worthless. Reported so the behaviour is documented; no fix needed.","line":399,"path":"src/CreatorFeeHook.sol","reproduction":"Same harness as above, after the price is inside the range at tick about -69000 (1 coin = 0.001 JIG): exact-input buy of 50 wei JIG. Expected (naively) a 1% fee credit; actual totalClaimable delta is 0 while the buyer received about 49,500 wei coin instead of 50,000 (the 500 wei coin fee was taken by the hook and either sold for 0 wei JIG or parked as pendingCoin). Observed in the probe log as 'dust buy fee delta 0'.","severity":"info","snippet":"        jigOut = uint256(uint128(jigDelta));","title":"CreatorFeeHook.afterSwap: a sub-wei coin fee is taken from the buyer but converts to zero JIG, so dust buys pay nothing to the recipients while still losing the coin"},{"citation":"resolved","description":"Precision x invariant seam. accPerToken grows by floor(amount*1e18/live) but accounted grows by the full amount, so the remainder amount - floor(amount*1e18/live)*live/1e18 (less than live wei, since ACC = 1e18 only absorbs the division when amount*1e18 is divisible by live) is counted as distributed yet is owed to nobody. undistributed() excludes it, so it is never re-spread and sits in the contract forever. Magnitude is below the live supply in wei per pull, so for a 10,000-token collection a lifetime of 1,000 pulls strands at most 1e7 wei of JIG. No fix required; if wanted, track the distributed amount as (acc delta * live) / ACC instead of amount.","line":83,"path":"src/HolderDrip.sol","reproduction":"HolderDrip with live = 3 and 10 wei of JIG undistributed: pull() sets accPerToken += 3333333333333333333 (floor(10e18/3)), accounted += 10. Each token can claim floor(3333333333333333333/1e18) = 3 wei, 9 wei in total; 1 wei remains in the contract with accounted - paidOut == 1 after all three claims and undistributed() == 0, so no later pull can re-spread it.","severity":"info","snippet":"        accPerToken += (amount * ACC) / live;","title":"HolderDrip.pull strands up to live-1 wei per pull in 'accounted' that no token can ever claim"},{"citation":"resolved","description":"Temporal boundary. Until setDeepening() runs, deepening == pot, so the BadFlywheel check only excludes the pot. The deploy order is hook, factory, then LPDeepening (DeployDeepening.s.sol binds it afterwards), and the LPDeepening address is a plain CREATE address predictable from the deployer nonce. A launch in that gap with flywheel == the future LPDeepening passes register(). Consequence: that pool's flywheel slice is credited to LPDeepening and its stale balance can be recycled by anyone to pot/deepening/platform (the flywheel side's remainder goes to the pot), never to the recycler, and LPDeepening.pull() is permissionless, so no value is extractable. It only defeats the stated intent of the list. Fix if wanted: have the factory refuse launches while hook.deepening() == hook.pot(), or have register() also reject flywheel == factory.platform-style module addresses once bound; cheapest is a one-line check in CoinFactory.launch.","line":173,"path":"src/CreatorFeeHook.sol","reproduction":"Deploy hook and factory, do not call setDeepening. Compute the LPDeepening address the deployer will create next (keccak(rlp(deployer, nonce))). launch({... flywheel: thatAddress ...}) succeeds (expected: BadFlywheel, actual: PoolRegistered with flywheel == thatAddress). Then setDeepening(thatAddress) succeeds and every swap on that pool credits LPDeepening both the 2% deepening slice and the flywheel block.","severity":"info","snippet":"            flywheel == platform || flywheel == pot || flywheel == deepening || flywheel == address(this)","title":"CreatorFeeHook.register checks the flywheel against the deepening address bound at registration time, so a coin launched before setDeepening can name the future LPDeepening as its flywheel"},{"citation":"resolved","description":"Economics x asymmetry seam, tiny. _split() credits the platform by the band of the fee at swap time; recycle() re-splits the stale creator or flywheel balance by the band of the fee at recycle time. After lowerFee() from the 7-10% band (platform 4000) to the 1% band (platform 2500), a stale balance earned under the old band pays the platform 25% instead of 40% on recycling, with the difference going to the flywheel (creator's stale money) or the pot (flywheel's stale money). The recycler never gains and the creator cannot raise the fee, so the only direction is less to the platform. Documented as an accepted asymmetry or fixed by storing the band with the Earned entry.","line":278,"path":"src/CreatorFeeHook.sol","reproduction":"Pool registered at feePips 100_000 (platform 4000). Creator earns 100 JIG on a swap (platform was credited 40 of the 100-fee at swap time). Creator calls lowerFee(key, 10_000). Seven days later anyone calls recycle(key, creator): expected under the earning band plat = 40; actual plat = (100 * 2500) / 10_000 = 25, flywheel receives 100 - 3 - 2 - 25 = 70 instead of 55.","severity":"info","snippet":"        uint256 plat = (amount * platformBps(c.feePips)) / 10_000;","title":"CreatorFeeHook.recycle splits with the platform band of the current feePips, not the band in force when the balance was earned"}],"hash":"ecc3a8d5dc6f7403366ffab65d9decfd43af5960d5e2fbb92499ecf7c2cc29cf","nodeId":"d2ea340f-92e2-4a57-8ca3-eb6cb1b1e631","outcome":"completed","summary":"The review is complete and the findings file is written and validated.\n\n**Result: nothing above Info remains.** Every round-3 fix holds, and the math, boundary and numerical-gap passes over the whole base found only dust-level artefacts. The findings file at `.imd-findings.json` holds five Info entries, each with a concrete input, and no Critical, High, Medium or Low.\n\n**How the round-3 questions came out**\n\n- **seal ordering**: seal is factory-only, needs register first, and the factory calls it in the same launch transaction right after seeding. Liquidity adds revert both before register and after seal. The factory's own mint runs in between, and the pool id includes the hook, so nobody can pre-initialise or front-run the seed.\n- **launch refund**: launch is nonReentrant and both ETH pushes come after all state writes. A reverting caller only breaks its own launch.\n- **firstCredit**: verified on a fork. A second credit at day 3 left the clock at the first credit, recycle at day 7 moved the full earned amount with the 70/25/5 split, and after a claim the next credit restarted the clock and recycle reverted NotStale.\n- **hook call order**: only register before seal and seal before liquidity are order-sensitive, and both are enforced.\n\n**Fee arithmetic measured on a Sepolia fork** at a 1% fee, coin as currency0, 900M coin seeded single-sided:\n\n| Swap | Paid | Fee credited |\n|---|---|---|\n| exact-input buy, 1000 JIG | 980,932 coin out | 10.011 JIG |\n| exact-output buy, same coin | 1002.17 JIG in | 10.022 JIG |\n| exact-input sell, all coin | 1962.3 JIG out | 19.82 JIG |\n\nheldJig equalled totalClaimable after every swap, recycle and claim, and the four claimable balances summed to totalClaimable. A buy from below the range converted its fee with pendingCoin staying at zero.\n\n**The Info items recorded**\n\n- A sub-wei coin fee on a dust buy converts to zero JIG while the buyer still loses the coin.\n- HolderDrip strands fewer than `live` wei per pull in its accounted total.\n- register checks the flywheel against the deepening address bound at that moment, so a launch before setDeepening can name the future LPDeepening. No value is extractable.\n- recycle re-splits with the current platform band rather than the band in force when earned, so after lowerFee the platform receives less on recycling.\n\nThreat-model items A through C are answered yes/no with reasoning inside the first finding's description. The L2 notes from round 3 stand unchanged.\n\n**Housekeeping**: the scratch harness was removed, dependencies live only in the gitignored `lib/`, and the working tree shows no stray files. The local PoolManager would not compile under the project's optimizer settings, so the probe ran against the Sepolia PoolManager on a pinned fork block instead, as the repository's own tests do.","treeHash":null,"usage":{"cachedInputTokens":3112377,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":59515,"runtime":"claude","turns":39,"wallClockMs":1020522}}],"verification":[]}