{"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":"a3e708e2-fb57-43ea-a163-d93b916694a2","kind":"audit","nodes":[{"acceptedSubmissionHash":"35e81b1f74ab316acb96b42dbc65cc9d6e051895660a3b9538639ceea3a79263","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":"2ab9cd5223ddcd25bf490bc86f933df709953e72de575e337f28a179e0346bae","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":"78c90a43bb866b9041081865ac7bbee72d7b65c14bb46fc8fbef02c14730b2e1","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"361c61d81fa7d5a1d5f2b217dc037ebb524f111bd12ae5123dfc2e08e388594c","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":"04218bbe3bfab6a117b2b8ab168eaa52f269f0d2687ea723a3f0588b9ae0bd48","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":"What the contracts are for\n\nPepesFamily is a token launchpad on Robinhood Chain (chain ID 4663), built on Uniswap v4.\n\nAnyone can launch a token with a fixed supply of 1B, paired with ETH or IMD.\nAt launch, the whole supply becomes single-sided liquidity in a new v4 pool. The launchpad contract owns that position and has no way to remove it, so liquidity is locked forever.\nThe launchpad is also the pool's v4 hook. It takes 4% of the quote side of every swap, through any router: 1% to the protocol, and 3% to the token's holders pro rata, which they claim.\nTokens have no mint, no owner (owner() returns 0x0 in v2) and no admin functions. Nothing is upgradeable.\nThe launchpad owner can only change where the protocol fee goes and the starting price for future launches.\nContracts\n\nv2 (current):\nPepesFamily.sol: launcher, hook, fee vault\nPadToken.sol: the launched ERC-20, with holder rewards and EIP-2612 permit\nPepesFamilyRouter.sol\nPepesFamilyEthRouter.sol: trades IMD pairs with ETH via the v4 IMD/ETH pool\nlib/SafeTransfer.sol\ndeploy scripts\nv1 (still live with real liquidity): src/v1/PadTokenV1.sol, plus the v1 launchpad and routers at commit a549093. The main difference from v2: the v1 router can pull tokens without an allowance, but only from its own caller.\nAbout 1,340 lines of Solidity in total. Uniswap v4-core is out of scope.\nWhat to look at hardest\n\nHook fee accounting: delta signs for exact-in and exact-out in both currency orders; the transient-storage handoff between beforeSwap and afterSwap; fees on partially filled swaps.\nFee claims and payouts: fees are held as Uniswap claim balances and paid out by flush and collectProtocolFees. Anyone can call those while the pool manager is mid-transaction for another contract. Can that ever drain the pool manager or break our accounting?\nLaunch liquidity math: rounding, both token orderings, extreme starting prices.\nHolder reward accounting: overflow bounds, the excluded-address list, reentrancy on claim, and that payouts can never exceed what was distributed.\nPermit and the routers: EIP-712 correctness, front-run handling, msg.value and refunds, the two-swap ETH route.\nThe v1 router's approval shortcut: confirm nobody can move another holder's tokens. GoPlus flags v1 tokens as a honeypot because of it, and we believe that's a false positive.\nInvariants to verify\n\nLiquidity can never be removed.\nEvery swap pays exactly 4%.\nFee claims and reward payouts are always fully backed.\nSupply is fixed.\nRouters only spend the caller's funds.\nNobody can block selling or claiming.\nThe full list, the known accepted behaviour and the test instructions (38 tests, including mainnet fork tests) are in AUDIT.md.","parentJobId":null,"planHash":"df3e58132ea84d5d6503afc995abfb4343683c34f04e3f292f0e76bd441907e1","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a3e708e2-fb57-43ea-a163-d93b916694a2","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":"50959","feedbackHash":"00463fc7e8fa26fc6a97f8cb286891e674b8bc943c63740aa72771ca278735ae","nodeKey":"audit_economics","submissionHash":"35e81b1f74ab316acb96b42dbc65cc9d6e051895660a3b9538639ceea3a79263","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51432","feedbackHash":"f58c27f0d3e00003b22a29de7498fba37afce48b7d07a1667d46f1ed31943968","nodeKey":"audit_flow","submissionHash":"2ab9cd5223ddcd25bf490bc86f933df709953e72de575e337f28a179e0346bae","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50955","feedbackHash":"bc88f52ea89d6394e24b6ebee94da31ec3c54e21d8417171bbb5cb60282beb3d","nodeKey":"audit_judge","submissionHash":"78c90a43bb866b9041081865ac7bbee72d7b65c14bb46fc8fbef02c14730b2e1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50957","feedbackHash":"76a3f1493eb63d2a7ca8af8fa6dafed5730b06f0b439555a019c50b2494df1a2","nodeKey":"audit_math","submissionHash":"361c61d81fa7d5a1d5f2b217dc037ebb524f111bd12ae5123dfc2e08e388594c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50974","feedbackHash":"1f43244e6ec32a7db5bc7f85a6f7c4eab5aefaf2e5d4721d8581f4c68ab6ef07","nodeKey":"audit_permissions","submissionHash":"04218bbe3bfab6a117b2b8ab168eaa52f269f0d2687ea723a3f0588b9ae0bd48","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"352e9d7c6b6e23647ffc681716b79b4dc550b9c3a10702ffffdc819b8d7a4ea8","state":"completed","submissions":[{"artifacts":[],"attempt":2,"bundleHash":null,"device":"98b4506bef931d13","findings":[{"citation":"resolved","description":"When the quote is the specified currency (exact-in buy, exact-out sell), `beforeSwap` derives the fee from `params.amountSpecified`, mints claims for it and returns it as the specified-side hook delta. The PoolManager then swaps `amountSpecified - fee` and, in `Hooks.afterSwap`, charges the swapper the full hook delta regardless of how much of the swap executed. If the swap stops at the trader's `sqrtPriceLimitX96` before the requested amount is reached, the trader still pays 4% of the *requested* amount: the effective fee on the executed quote can approach 100%. For exact-out sells, when the pool delivers less quote than the fee, `afterSwap` reverts with an arithmetic underflow at line 350 (`poolQuote - fee`), which is what stops the trader's quote delta from flipping negative. The other two modes (quote unspecified) compute the fee from the pool's actual delta in `afterSwap` and are unaffected. Invariant 2 in AUDIT.md (`exactly 4% of the trader's gross quote amount`) therefore does not hold for partial fills. The overcharge goes to the protocol and holders, not to a third party, and the trader chose the limit, so the impact is confined to the trader's own swap (confirming the brief's belief); third-party routers that pass a tight price limit (not the Universal Router, which uses MIN/MAX) would expose their users to it. Exact-out sells that ask for more quote than the pool holds cannot partially fill in practice: draining the pool needs more tokens than exist outside it (rounding), so they revert with `InsufficientBalance`. Fix: in `afterSwap`, for the quote-specified branch, compare the executed specified amount (from `delta`) with the requested one and revert on a partial fill (minimal fix; a hook cannot hand specified currency back from `afterSwap`). Alternatively recompute the fee as 4% of the executed amount, burn the excess claims minted in `beforeSwap` and refund the difference to the swapper through the unspecified-side return delta.","line":310,"path":"contracts/src/PepesFamily.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\n/// @dev Hook fee on partially filled swaps whose specified currency is the quote.\n///      `beforeSwap` computes the 4% fee on `params.amountSpecified` (the requested amount) and mints claims for it.\n///      When the swap stops at `sqrtPriceLimitX96` before the requested amount is reached, the trader still pays the\n///      full fee, so the fee is far more than 4% of what actually traded. Both tests fail on the current code and\n///      pass once the hook either charges 4% of the executed quote amount or rejects partial fills.\ncontract PartialFillFeeTest is Test {\n    using StateLibrary for IPoolManager;\n\n    address constant FEE_RECIPIENT = 0x3c8A4d94B3219F6633F2cC94094f4765b30c691C;\n    PoolManager pm;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PoolSwapTest extRouter; // third-party router: the trader picks the price limit\n    PoolSwapTest.TestSettings settings = PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address owner = makeAddr(\"owner\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        extRouter = new PoolSwapTest(pm);\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(0xBEEF), // IMD stand-in, unused here\n                owner,\n                FEE_RECIPIENT,\n                DeployLib.startTickForMarketCap(1.5 ether),\n                DeployLib.startTickForMarketCap(100e18),\n                PepesFamily.ImdEthPool(10_000, 100, address(0))\n            )\n        );\n        (bytes32 salt, address expected) = DeployLib.mineSalt(address(this), uint160(0x28CC), initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        require(deployed == expected, \"hook address\");\n        pad = PepesFamily(deployed);\n        router = PepesFamilyRouter(payable(pad.router()));\n        vm.deal(alice, 100 ether);\n        vm.deal(bob, 100 ether);\n    }\n\n    function _launchAndBuy() internal returns (PadToken t, PoolKey memory key) {\n        vm.prank(alice);\n        t = PadToken(payable(pad.launch(\"Test\", \"TST\", \"\", address(0))));\n        vm.prank(bob);\n        router.buy{value: 2 ether}(address(t), 2 ether, 0, block.timestamp);\n        key = pad.poolKey(address(t));\n        vm.prank(bob);\n        t.approve(address(extRouter), type(uint256).max);\n    }\n\n    function _pendingFees(PadToken t) internal view returns (uint256) {\n        return pad.pendingProtocolFees(address(0)) + pad.pendingHolderFees(address(t));\n    }\n\n    /// Exact-in buy of 1 ETH with a price limit 0.1% below the current sqrt price: the pool only takes ~0.0015 ETH\n    /// but the hook charges 0.04 ETH (fee on the requested 1 ETH): ~96% of what the trader paid.\n    function test_exactInBuy_priceLimit_feeIsFourPercentOfExecuted() public {\n        (PadToken t, PoolKey memory key) = _launchAndBuy();\n        (uint160 p,,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n        uint256 ethBefore = bob.balance;\n        uint256 feeBefore = _pendingFees(t);\n        vm.prank(bob);\n        try extRouter.swap{value: 1 ether}(key, SwapParams(true, -1 ether, p - p / 1000), settings, \"\") {\n            uint256 paid = ethBefore - bob.balance; // gross quote the trader spent, fee included\n            uint256 fee = _pendingFees(t) - feeBefore;\n            assertLe(fee, (paid * 4) / 100 + 2, \"fee exceeds 4% of the quote actually paid\");\n        } catch {\n            // rejecting the partial fill is also a valid fix\n        }\n    }\n\n    /// Exact-out sell of 1 ETH with a price limit 5% above the current sqrt price: the pool pays out ~0.164 ETH but\n    /// the hook keeps 0.041667 ETH (fee on the requested 1 ETH): ~25% of what actually traded.\n    function test_exactOutSell_priceLimit_feeIsFourPercentOfExecuted() public {\n        (PadToken t, PoolKey memory key) = _launchAndBuy();\n        (uint160 p,,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n        uint256 ethBefore = bob.balance;\n        uint256 feeBefore = _pendingFees(t);\n        vm.prank(bob);\n        try extRouter.swap(key, SwapParams(false, int256(1 ether), p + p / 20), settings, \"\") {\n            uint256 received = bob.balance - ethBefore;\n            uint256 fee = _pendingFees(t) - feeBefore;\n            uint256 gross = received + fee; // quote the pool actually paid out\n            assertLe(fee, (gross * 4) / 100 + 2, \"fee exceeds 4% of the quote actually received\");\n        } catch {\n            // rejecting the partial fill is also a valid fix\n        }\n    }\n}","reproduction":"Launch an ETH-quoted token; bob buys 2 ETH through PepesFamilyRouter. Then, through a third-party router (v4-core PoolSwapTest), bob swaps exact-in buy `amountSpecified = -1 ether`, `sqrtPriceLimitX96 = p - p/1000` (p = current slot0 price). Actual: the pool takes 0.0015 ETH of input but the hook keeps 0.04 ETH; bob paid 0.0415 ETH, of which 96.3% is fee. Expected: fee 4% of 0.0415 ETH = 0.00166 ETH. Exact-out sell `amountSpecified = +1 ether`, limit `p + p/20`: pool pays out 0.1642 ETH, hook keeps 0.041667 ETH, bob receives 0.1225 ETH (25.4% fee instead of 4%). Exact-out sell with limit `p + p/100`: pool delivers ~0.03 ETH < 0.0417 ETH fee, and the swap reverts with Panic(0x11) inside `afterSwap` (wrapped by the PoolManager as HookCallFailed). See proof test; both tests fail on the current code.","severity":"low","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Hook fee is computed on the requested amount, so partially filled quote-specified swaps pay far more than 4% (or revert)"},{"citation":"resolved","description":"`_setStartTick` only checks spacing alignment and `|tick| <= maxUsableTick - 200`. The liquidity for a launch is `TOTAL_SUPPLY * 2^96 / (sqrtU - sqrtL)` (token is currency1) or the mirror formula (token is currency0); for very negative start ticks the range `[minUsableTick, tick]` becomes so narrow in sqrt-price terms that `liquidity` exceeds `type(uint128).max` (reverts in v4's `toInt128`) or `maxLiquidityPerTick` (v4 `TickLiquidityOverflow`), so every `launch()`/`router.launch()` for that quote reverts until the owner sets another tick. On the positive side the bound allows ticks where the starting market cap rounds to a few wei (tick 600000 gives a market cap of 8 wei, tick 800000 gives 0), i.e. nonsensical pricing. This is reachable only through the owner (`setStartTick`), so it is a trust assumption rather than an exploit, but the brief lists the owner as unable to pause launches, and this is in effect a per-quote pause (or a mispricing of all future launches) that the bounds were meant to prevent. Fix: in `_setStartTick`, compute the launch liquidity for both orientations with the same formula as `_addLaunchLiquidity` and revert if it exceeds `type(uint128).max` or `Pool.tickSpacingToMaxLiquidityPerTick(TICK_SPACING)`; optionally also bound the implied market cap to a sane range.","line":451,"path":"contracts/src/PepesFamily.sol","reproduction":"With the test deployment (ETH start tick for a 1.5 ETH market cap), owner calls `setStartTick(address(0), -(TickMath.maxUsableTick(200) - 200))` = `setStartTick(ETH, -887000)`: accepted. Then any `pad.launch(\"X\",\"X\",\"\",address(0))` reverts (observed in scratch test `test_extremeStartTick_bricksLaunch`). Scanning ticks: -340000 still launches, -360000 and below revert (`test_startTickThreshold`). Expected: `setStartTick` rejects every tick at which a launch cannot succeed.","severity":"low","snippet":"        if (tick % TICK_SPACING != 0 || tick > limit || tick < -limit) revert BadTick();","title":"setStartTick bounds admit ticks for which _addLaunchLiquidity overflows int128 or the per-tick liquidity cap, bricking launches for that quote"},{"citation":"resolved","description":"`PermitHelper.permit` always calls `permit(msg.sender, router, amount, ...)` with `amount == tokenAmount`. The EIP-712 digest includes `value`, so a signature the holder produced for any other value (e.g. a larger or max allowance, as the NatSpec on line 118 invites: \"`value` >= `tokenAmount`\") does not recover to the holder and `permit` reverts with `InvalidSignature`; the catch branch then requires an *existing* allowance >= tokenAmount, which a user relying on permit does not have, so the sale reverts with `PermitFailed`. The front-run tolerance also only works when the front-runner uses the identical signature. Users or integrators who sign a single larger permit (the common pattern) cannot sell; the website happens to sign exactly `amount` so it is unaffected. No funds are at risk. Fix: either take `permitValue` as an explicit parameter (and pass it to `permit`), or fix the NatSpec on PepesFamilyRouter.sol:118 and PepesFamilyEthRouter.sol:99 to say the permit must be signed for exactly `tokenAmount`.","line":42,"path":"contracts/src/PepesFamilyRouter.sol","reproduction":"Holder dan buys 1 ETH of an ETH-quoted token (balance `bal`). dan signs a valid EIP-2612 permit for spender = router, value = 2*bal, nonce 0, deadline = now. dan calls `router.sellWithPermit(token, bal, 1, now, v, r, s)`. Actual: reverts `PermitFailed()` (scratch test `test_permitSignedForLargerValueIsRejected`). Expected per NatSpec: the sale goes through because the signed value is >= tokenAmount.","severity":"low","snippet":"        try IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s) {}","title":"sellWithPermit / sellForEthWithPermit require a permit signed for exactly tokenAmount, contradicting the documented `value >= tokenAmount`; a larger-value permit is rejected with PermitFailed"},{"citation":"resolved","description":"For any swap not sent by `PepesFamilyRouter` (including the project's own `PepesFamilyEthRouter`, Universal Router, aggregators, ERC-4337 bundlers, Safe/EIP-7702 wallets), `trader` is `tx.origin`, which is the bundler, relayer or EOA that signed the outer transaction, not the account whose funds moved. The website's trade list and any analytics built on the event therefore show the wrong address for those trades; a relayer that submits many users' trades appears as one whale. No funds are affected; only the event is wrong. Fix: use `sender` (the locker) when the hookData does not carry a trader, and have `PepesFamilyEthRouter` pass `abi.encode(r.user)` as hookData and be recognised like `router` is.","line":348,"path":"contracts/src/PepesFamily.sol","reproduction":"Swap through v4-core's PoolSwapTest from an EOA-prank (test) or from any contract wallet: the emitted `Trade.trader` is the forge default sender / the EOA that started the transaction (observed in the scratch trace: `trader: DefaultSender 0x1804c8AB...` for a swap made by `bob` through PoolSwapTest). Expected: the account that paid/received the quote (`bob`, or at least the router contract).","severity":"info","snippet":"        address trader = sender == router && hookData.length == 32 ? abi.decode(hookData, (address)) : tx.origin;","title":"Trade event attributes third-party-router swaps to tx.origin, misattributing trades made through contract wallets, bundlers or aggregators"},{"citation":"resolved","description":"Not a defect; recorded because the brief asks for an independent opinion. The only caller that skips the allowance is the immutable v1 `router`. In the v1 PepesFamilyRouter (commit a549093) `transferFrom` is called exactly once, inside `unlockCallback`, as `Currency.unwrap(cIn).transferFrom(d.user, address(poolManager), owed)`, where `d.user` is set to `msg.sender` in `_swap` and the callback is only accepted from the PoolManager, which calls back the address that called `unlock` (the router itself with its own encoded `SwapData`). `cIn` is derived from `pad.poolKey(token)` (reverts for unknown tokens) so the token pulled is always the launched token or IMD, and `launch` and `buy` never pull a PadToken at all. The v1 router makes no calls to attacker-supplied contracts, so there is no confused-deputy path, and `pm.unlock` from an attacker calls the attacker's own callback. Nobody can move another holder's v1 tokens; the pattern is only a trust assumption on the (immutable, non-upgradeable) router code. v2 removed the exemption entirely.","line":105,"path":"contracts/src/v1/PadTokenV1.sol","reproduction":"Attempted paths, all revert or are unreachable: (1) carol calls `v1router.sell(token, bobBalance, ...)` -> `transferFrom(carol, ...)` -> InsufficientBalance; (2) carol calls `token.transferFrom(bob, carol, 1)` -> InsufficientAllowance; (3) carol calls `poolManager.unlock(abi.encode(SwapData{user: bob,...}))` -> the PoolManager calls `carol.unlockCallback`, not the router's; (4) carol calls `v1router.unlockCallback(...)` directly -> NotPoolManager. Expected and actual agree: no transfer of bob's tokens.","severity":"info","snippet":"        if (msg.sender != router) {","title":"v1 router allowance exemption confirmed safe: PadTokenV1.transferFrom can only be exercised by the v1 router on its own msg.sender (GoPlus honeypot flag is a false positive)"}],"hash":"04218bbe3bfab6a117b2b8ab168eaa52f269f0d2687ea723a3f0588b9ae0bd48","nodeId":"967bb5b0-6cbd-4a7e-b456-9dc8ee7fb0f9","outcome":"completed","summary":"The findings file is in place and the tree is otherwise clean. Review complete.\n\n**Outcome:** no critical, high or medium defects found. The findings file holds three low findings and two informational notes, each with a concrete input, and one carries a Foundry proof that fails on the current code.\n\n**Findings written to `.imd-findings.json`:**\n\n- **Low, hook fee on partial fills.** For exact-in buys and exact-out sells the fee is computed on the requested amount in beforeSwap. A trader-chosen price limit yields a 96% effective fee on an exact-in buy and 25% on an exact-out sell. When the delivered quote is below the fee, afterSwap reverts with an arithmetic underflow, which is what prevents the delta from flipping sign. Impact stays with the trader's own swap, as the brief suspected. Proof test included.\n- **Low, start tick bounds.** The owner's setStartTick accepts ticks at which launches revert (ETH ticks at or below -360000 overflow the liquidity cast) or price the launch at a few wei. Owner-only, so a trust assumption, but it is effectively a per-quote pause.\n- **Low, permit value mismatch.** The routers pass exactly tokenAmount to permit, so a signature for a larger value fails and the fallback demands an existing allowance. The NatSpec promises `value >= tokenAmount`.\n- **Info, Trade event trader.** Non-official routers attribute trades to tx.origin, which misattributes bundler and contract-wallet trades.\n- **Info, v1 approval shortcut.** Confirmed safe: the v1 router only pulls from its own caller and makes no calls to attacker-chosen contracts. The GoPlus honeypot flag is a false positive.\n\n**Checked and found sound:** every hook callback and unlockCallback is PoolManager-only; launchFor is router-only; the hook pool key cannot be initialised or given liquidity by anyone else; mint and burn of ERC-6909 claims net to zero for the launchpad, so flush and collectProtocolFees inside a foreign unlock cannot drain the PoolManager (a locker calling flush between its own sync and settle only shortchanges itself); the transient fee slot cannot leak between swaps; claim reentrancy is guarded and state is settled before the ETH send; reward payouts stay bounded by accountedBalance; routers only spend msg.sender's assets; permit domain, nonce and deadline handling are correct. Web rendering escapes creator metadata and whitelists URLs.\n\n**Limits:** no fork tests were run (offline). No stateful invariant fuzzing was added. The scratch tests were removed after use; the proof source lives in the findings file.","treeHash":null,"usage":{"cachedInputTokens":4357079,"inputTokens":898,"model":"claude-fable-5-1","outputTokens":81364,"runtime":"claude","turns":42,"wallClockMs":1259814}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"35c52a5b502e847c","findings":[],"hash":"2a021dcc0a1a5fff1624358a645384babdd8fe3cee05bc0962ade477c4eb7399","nodeId":"967bb5b0-6cbd-4a7e-b456-9dc8ee7fb0f9","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":2,"wallClockMs":149422}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"03f15d1296244279","findings":[{"citation":"resolved","description":"When the quote currency is the specified side (exact-in buy, exact-out sell), beforeSwap computes the fee from params.amountSpecified and returns it as a BeforeSwapDelta before the pool runs. If the swap then stops early at sqrtPriceLimitX96, the PoolManager still debits the swapper the full hook delta (swapDelta = swapDelta - hookDelta) and the hook mints claims for the full fee, so the trader pays 4% of what they asked for rather than 4% of what traded. In the exact-in buy case the trader can pay the whole fee and receive zero tokens. In the exact-out sell case afterSwap reads the beforeSwap fee from FEE_SLOT and computes `poolQuote - fee` for the Trade event (line 350); when the partial fill is smaller than the fee this underflows and the swap reverts with a Panic(0x11) instead of a clean error. Accounting stays consistent (hook credit == minted claims) and no other party is affected, which confirms the brief's belief that the effect is limited to the trader's own swap, but invariant 5.2 ('exactly 4% of the trader's gross quote amount') does not hold for these swaps. Swaps where the quote is the unspecified side (afterSwap path) are unaffected because they use the actual pool delta. Minimal fix: in afterSwap, for the quote-specified branch, compare poolQuote with the amount the pool was asked to swap (exact-in: amount - fee; exact-out: amount + fee) and revert with a dedicated error on a shortfall, so partially filled quote-specified swaps are rejected rather than overcharged; alternatively document that sqrtPriceLimitX96 must be MIN/MAX+-1 on these pools.","line":310,"path":"contracts/src/PepesFamily.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract MockIMD {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function approve(address s, uint256 amt) external returns (bool) { allowance[msg.sender][s] = amt; return true; }\n    function transfer(address to, uint256 amt) external returns (bool) { balanceOf[msg.sender] -= amt; balanceOf[to] += amt; return true; }\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt; balanceOf[to] += amt; return true;\n    }\n}\n\n/// @notice When the quote is the specified currency, beforeSwap charges 4% of the *requested* amount. A swap that\n///         stops early at its sqrtPriceLimitX96 still pays the full fee: here 0.4 ETH for a fill of 1 wei.\ncontract PartialFillFeeTest is Test {\n    using StateLibrary for IPoolManager;\n\n    PoolManager pm;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PoolSwapTest ext;\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address owner = makeAddr(\"owner\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        ext = new PoolSwapTest(pm);\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(pm, address(new MockIMD()), owner, owner, int24(203000), int24(203000),\n                PepesFamily.ImdEthPool(10_000, 100, address(0)))\n        );\n        bytes32 initHash = keccak256(initCode);\n        for (uint256 i; i < 500_000; i++) {\n            address h = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), initHash)))));\n            if (uint160(h) & 0x3FFF == 0x28CC) {\n                address deployed;\n                assembly { deployed := create2(0, add(initCode, 0x20), mload(initCode), i) }\n                require(deployed == h, \"hook address\");\n                pad = PepesFamily(deployed);\n                break;\n            }\n        }\n        router = PepesFamilyRouter(payable(pad.router()));\n        vm.deal(alice, 100 ether);\n        vm.deal(bob, 100 ether);\n    }\n\n    function test_exactInBuyStoppedByPriceLimitPaysFourPercentOfWhatTraded() public {\n        vm.prank(alice);\n        PadToken t = PadToken(payable(pad.launch(\"T\", \"T\", \"\", address(0))));\n        vm.prank(alice);\n        router.buy{value: 1 ether}(address(t), 1 ether, 0, block.timestamp);\n        PoolKey memory key = pad.poolKey(address(t));\n        (uint160 sqrtP,,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n\n        uint256 ethBefore = bob.balance;\n        uint256 feeBefore = pad.pendingProtocolFees(address(0)) + pad.pendingHolderFees(address(t));\n        // exact-in buy of 10 ETH, price limit one unit below the current price: the pool fills ~nothing\n        vm.prank(bob);\n        try ext.swap{value: 10 ether}(key, SwapParams(true, -int256(10 ether), sqrtP - 1), PoolSwapTest.TestSettings(false, false), \"\") {}\n        catch {\n            return; // rejecting the partial fill is an acceptable fix\n        }\n        uint256 paid = ethBefore - bob.balance;\n        uint256 fee = pad.pendingProtocolFees(address(0)) + pad.pendingHolderFees(address(t)) - feeBefore;\n        uint256 traded = paid - fee; // quote that actually reached the pool\n        // Expected (AUDIT.md §5.2): fee == 4% of the gross quote that traded, ±1 wei.\n        // Actual: traded == 1 wei, fee == 0.4 ETH, tokens received == 0.\n        assertLe(fee, (traded * 4) / 100 + 1, \"fee exceeds 4% of the quote that actually traded\");\n    }\n}","reproduction":"Launch an ETH-paired token, buy 1 ETH through PepesFamilyRouter, read slot0 sqrtPriceX96 = P. From a third-party router (PoolSwapTest) submit SwapParams{zeroForOne: true, amountSpecified: -10 ether, sqrtPriceLimitX96: P - 1} with 10 ETH. Expected: fee == 4% of the quote that reached the pool (1 wei -> 0). Actual: trader's ETH balance drops by 400000000000000001 wei, pendingProtocolFees+pendingHolderFees grow by 0.4 ETH, trader receives 0 tokens. Exact-out variant: same pool, SwapParams{zeroForOne: false, amountSpecified: +1 ether, sqrtPriceLimitX96: P + 1} from a holder: fee = 1e18*400/9600 = 41666666666666666, pool pays out ~0, afterSwap reverts in `poolQuote - fee` with Panic(0x11), which the PoolManager surfaces as WrappedError(hook, afterSwap.selector, Panic(0x11), HookCallFailed()) (verified with vm.expectRevert). Run: cd contracts && forge test --match-path test/scratch/PartialFillFee.t.sol (fails on current code: 'fee exceeds 4% of the quote that actually traded: 400000000000000000 > 1').","severity":"low","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Hook charges 4% of the requested amount, not the filled amount, when a quote-specified swap stops at its price limit"},{"citation":"resolved","description":"Launch liquidity sits in [minUsableTick, startTick] (token is currency1) or [startTick, maxUsableTick] (token is currency0) and the pool is initialised exactly at startTick, where the position is not active. While the pool holds no quote (every fresh launch without an initial buy, and any token whose buyers have all sold back), a sell-direction swap finds no liquidity above/below the start tick, so Pool.swap consumes nothing but walks the price all the way to the caller's sqrtPriceLimitX96 (MAX_SQRT_PRICE-1 or MIN_SQRT_PRICE+1). The swapper's delta is 0/0, so the caller needs no tokens and pays nothing but gas. The hook's afterSwap accepts the zero-amount swap (fee 0) and emits Trade(token, tx.origin, false, 0, 0, 0, sqrtPrice-at-limit); the PoolManager emits a Swap event with the same price. Afterwards marketCap() and getTokenInfo().marketCap return 0 for both orientations (supply*Q96^2/sqrtP^2 at MAX, or supply*sqrtP^2/Q96^2 at MIN), the website's listing and USD market cap show 0, and third-party charts that derive price from Swap events show the token collapsing to zero. Trading itself is unaffected: the next buy crosses the start tick, re-activates the liquidity and restores a sane price (confirmed in the test), so no funds are at risk. Minimal fix: in afterSwap revert (e.g. `if (poolQuote == 0 && tokenAmount == 0) revert ZeroFill();`) so swaps that move the price without trading are rejected; optionally also clamp marketCap() to the start price when the pool tick is outside the position range.","line":350,"path":"contracts/src/PepesFamily.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract MockIMD {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function approve(address s, uint256 amt) external returns (bool) { allowance[msg.sender][s] = amt; return true; }\n    function transfer(address to, uint256 amt) external returns (bool) { balanceOf[msg.sender] -= amt; balanceOf[to] += amt; return true; }\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt; balanceOf[to] += amt; return true;\n    }\n}\n\n/// @notice A swap on a pool that holds no quote fills nothing but still moves the pool price to the tick limit.\n///         Anyone with zero tokens can do it; afterwards marketCap() reports 0 until the next buy.\ncontract ZeroCostPriceDisplacementTest is Test {\n    using StateLibrary for IPoolManager;\n\n    PoolManager pm;\n    PepesFamily pad;\n    PoolSwapTest ext;\n    address alice = makeAddr(\"alice\");\n    address griefer = makeAddr(\"griefer\");\n    address owner = makeAddr(\"owner\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        ext = new PoolSwapTest(pm);\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(pm, address(new MockIMD()), owner, owner, int24(203000), int24(203000),\n                PepesFamily.ImdEthPool(10_000, 100, address(0)))\n        );\n        bytes32 initHash = keccak256(initCode);\n        for (uint256 i; i < 500_000; i++) {\n            address h = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), initHash)))));\n            if (uint160(h) & 0x3FFF == 0x28CC) {\n                address deployed;\n                assembly { deployed := create2(0, add(initCode, 0x20), mload(initCode), i) }\n                require(deployed == h, \"hook address\");\n                pad = PepesFamily(deployed);\n                break;\n            }\n        }\n        vm.deal(alice, 100 ether);\n    }\n\n    function test_quoteEmptyPoolPriceCannotBeDisplacedForFree() public {\n        vm.prank(alice);\n        PadToken t = PadToken(payable(pad.launch(\"T\", \"T\", \"\", address(0))));\n        PoolKey memory key = pad.poolKey(address(t));\n        uint256 mcapBefore = pad.marketCap(address(t));\n        (, int24 tickBefore,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n        assertEq(t.balanceOf(griefer), 0);\n\n        // griefer \"sells\" 1 wei of a token they do not hold on a pool that holds no quote\n        vm.prank(griefer);\n        try ext.swap(key, SwapParams(false, -1, TickMath.MAX_SQRT_PRICE - 1), PoolSwapTest.TestSettings(false, false), \"\") {}\n        catch {}\n\n        (, int24 tickAfter,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n        // Expected: a zero-fill swap must not move the pool. Actual: tick jumps to 887271 and marketCap() is 0.\n        assertEq(tickAfter, tickBefore, \"pool tick moved by a zero-fill swap\");\n        assertEq(pad.marketCap(address(t)), mcapBefore, \"marketCap changed by a zero-fill swap\");\n    }\n}","reproduction":"Launch an ETH-paired token with no initial buy (tick 203000, marketCap() = 1528490686780151368 wei). From any account holding zero tokens, call a third-party router (PoolSwapTest) with SwapParams{zeroForOne: false, amountSpecified: -1, sqrtPriceLimitX96: MAX_SQRT_PRICE - 1}. Expected: a swap that fills nothing leaves the pool untouched (or reverts). Actual: the call succeeds, the caller's token and ETH balances are unchanged, slot0.tick becomes 887271, sqrtPriceX96 = 1461446703485210103287273052203988822378723970341, marketCap() returns 0, and a Trade event with all-zero amounts is emitted. A subsequent 1 ETH buy through PepesFamilyRouter succeeds and marketCap() becomes ~4.05e18, showing the displacement is cosmetic but repeatable at zero cost. Run: cd contracts && forge test --match-path test/scratch/ZeroCostPriceDisplacement.t.sol (fails on current code: 'pool tick moved by a zero-fill swap: 887271 != 203000').","severity":"low","snippet":"        emit Trade(token, trader, isBuy, isBuy ? poolQuote + fee : poolQuote - fee, tokenAmount, fee, sqrtPriceX96);","title":"Zero-fill swap on a quote-empty pool moves the price to the tick limit for free; marketCap() then reports 0"},{"citation":"resolved","description":"_setStartTick only checks spacing alignment and |tick| <= maxUsableTick - TICK_SPACING (887000). _addLaunchLiquidity derives liquidity from the full supply over the remaining curve: for the token-is-currency1 orientation L = (TOTAL_SUPPLY - 1e9) * 2^96 / (sqrtPrice(tick) - sqrtPrice(minUsableTick)). As the start tick falls, that denominator shrinks and L grows past the pool's maxLiquidityPerTick (type(uint128).max / 8873 ~= 3.8e34 for tick spacing 200), and past int128 further down. Measured threshold on the ETH side: -349200 still launches, -349400 and below revert in PoolManager.modifyLiquidity with TickLiquidityOverflow(-887200). The same applies mirrored for IMD pairs whose token is currency0 (tick = -startTick). So roughly 60% of the accepted negative range (-349400 .. -887000) is a dead zone in which launch() and PepesFamilyRouter.launch() fail for everyone until the owner sets a new tick. This is owner-only and reversible (no funds at risk; existing pools are unaffected), and the corresponding market caps are absurd (>1.4e24 ETH), so it is a robustness issue rather than an exploit. Minimal fix: tighten the bound in _setStartTick to a range that is known to produce valid liquidity (for example reject tick < -349200 for the quote-is-currency0 orientation and the mirror for the other), or compute the launch liquidity in _setStartTick and revert BadTick if it exceeds maxLiquidityPerTick(TICK_SPACING).","line":451,"path":"contracts/src/PepesFamily.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\n\ncontract MockIMD {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function approve(address s, uint256 amt) external returns (bool) { allowance[msg.sender][s] = amt; return true; }\n    function transfer(address to, uint256 amt) external returns (bool) { balanceOf[msg.sender] -= amt; balanceOf[to] += amt; return true; }\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt; balanceOf[to] += amt; return true;\n    }\n}\n\n/// @notice setStartTick accepts any spacing-aligned tick in [-887000, 887000], but launch liquidity overflows\n///         maxLiquidityPerTick once the ETH start tick is below about -349200: every launch then reverts.\ncontract StartTickBricksLaunchTest is Test {\n    PoolManager pm;\n    PepesFamily pad;\n    address owner = makeAddr(\"owner\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(pm, address(new MockIMD()), owner, owner, int24(203000), int24(203000),\n                PepesFamily.ImdEthPool(10_000, 100, address(0)))\n        );\n        bytes32 initHash = keccak256(initCode);\n        for (uint256 i; i < 500_000; i++) {\n            address h = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), initHash)))));\n            if (uint160(h) & 0x3FFF == 0x28CC) {\n                address deployed;\n                assembly { deployed := create2(0, add(initCode, 0x20), mload(initCode), i) }\n                require(deployed == h, \"hook address\");\n                pad = PepesFamily(deployed);\n                break;\n            }\n        }\n    }\n\n    function test_acceptedStartTickMustAllowLaunches() public {\n        vm.prank(owner);\n        try pad.setStartTick(address(0), -349_400) {}\n        catch {\n            return; // rejecting the tick is the fix\n        }\n        // Expected: a tick the owner was allowed to set still lets anyone launch.\n        // Actual: PoolManager reverts with TickLiquidityOverflow for every launch.\n        pad.launch(\"T\", \"T\", \"\", address(0));\n    }\n}","reproduction":"As owner call setStartTick(address(0), -349400) (aligned to 200, within +-887000, accepted). Then call launch(\"T\", \"T\", \"\", address(0)) from any account. Expected: launch succeeds for any tick the owner is allowed to set. Actual: revert TickLiquidityOverflow(-887200) from PoolManager.modifyLiquidity inside unlockCallback; the same happens for every subsequent launch and for PepesFamilyRouter.launch until the tick is changed. With -349200 the launch succeeds (marketCap() = 1.46e42 wei), so the boundary lies between -349200 and -349400. Run: cd contracts && forge test --match-path test/scratch/StartTickBricksLaunch.t.sol (fails on current code with TickLiquidityOverflow(-887200)).","severity":"low","snippet":"        if (tick % TICK_SPACING != 0 || tick > limit || tick < -limit) revert BadTick();","title":"setStartTick accepts ticks for which every launch reverts with TickLiquidityOverflow"}],"hash":"2ab9cd5223ddcd25bf490bc86f933df709953e72de575e337f28a179e0346bae","nodeId":"5b677900-d004-4b49-a23f-6d0802bb8d04","outcome":"completed","summary":"Review complete. The findings file is at `.imd-findings.json` in the repository root with three substantiated Low findings, each carrying a Foundry proof under `contracts/test/scratch/` that fails on the current code. No source or configuration files were changed.\n\n**Findings**\n\n- **Fee on partial fills** (`contracts/src/PepesFamily.sol:310`). When the quote is the specified currency, `beforeSwap` charges 4% of the requested amount before the pool runs. A swap stopped by its own price limit still pays the full fee. Proof: an exact-in buy of 10 ETH with the limit one unit below spot paid 0.4 ETH and received zero tokens. The exact-out sell variant reverts with a wrapped Panic 0x11 in `afterSwap` when the fill is smaller than the fee. Hook accounting stays balanced and only the trader is affected, which confirms the brief's expectation. Suggested fix is to reject shortfalls in the quote-specified branch of `afterSwap`.\n- **Zero-cost price displacement** (`contracts/src/PepesFamily.sol:350`). On any pool holding no quote, a 1 wei sell from an account with no tokens fills nothing but walks the price to the tick limit. The tick moved from 203000 to 887271, `marketCap()` dropped to 0, and a zero-amount Trade event was emitted. Trading recovers on the next buy, so this is a display and chart griefing vector only. Suggested fix is to revert zero-fill swaps in `afterSwap`.\n- **`setStartTick` dead zone** (`contracts/src/PepesFamily.sol:451`). The bound accepts ticks down to -887000, but below about -349200 the launch liquidity exceeds `maxLiquidityPerTick` and every launch reverts with `TickLiquidityOverflow`. Owner-only and reversible. Suggested fix is a tighter bound or a liquidity check inside `_setStartTick`.\n\n**Checked and found sound**\n\n- Hook delta signs for all four swap modes in both currency orders, the transient `FEE_SLOT` handoff, and claim minting that settles the hook's credit.\n- `flush` and `collectProtocolFees` inside a foreign unlock. Burn plus take nets to zero for the launchpad, does not touch synced reserves, and is always backed by minted claims.\n- Launch liquidity rounding in both orientations stays within the supply buffer.\n- Holder reward math, correction bounds, exclusion handling, and claim reentrancy. Payouts cannot exceed what was distributed.\n- EIP-2612 domain, nonce consumption, and the permit front-run tolerance. The routers only ever pull from `msg.sender`, and the two-swap ETH route nets its IMD legs correctly.\n- The v1 router allowance exemption. The router's `transferFrom` target is always its own caller and `unlockCallback` is reachable only through the router's own unlock, so the GoPlus honeypot flag is a false positive.\n\n**Coverage notes**\n\nNo critical, high, or medium defects were found. The 31 unit tests pass. Fork tests and the website were not run or reviewed. Formal invariant fuzzing was not performed.","treeHash":null,"usage":{"cachedInputTokens":1816021,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":82468,"runtime":"claude","turns":32,"wallClockMs":1250356}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"468e82a89b9bfe18","findings":[{"citation":"resolved","description":"In the two modes where the quote is the specified currency (exact-in buy, exact-out sell) `beforeSwap` computes the fee from `params.amountSpecified`, returns it as the specified hook delta and mints the claims right away. The PoolManager then swaps only `amount - fee` (or `amount + fee`); if the swap stops early at `sqrtPriceLimitX96` (or, for exact-out, when the pool runs out of quote) the trader's specified delta is `executed + fee`, with `fee` still 4% of the full request. Nothing in `afterSwap` compares the executed amount with the request. Measured: an exact-in buy of 100 ETH with a limit 0.01% below spot executes 0.000249 ETH into the pool and pays 4.000 ETH in total, i.e. the fee is 99.99% of what left the trader's wallet; an exact-out sell asking 20 ETH when only ~0.096 ETH can be delivered keeps 0.0127 ETH for the seller while 0.0833 ETH goes to fees (86.8% of gross). This breaks AUDIT.md invariant 2 (\"exactly 4% of the trader's gross quote amount\"). It is not limited to self-inflicted inputs: whoever uses a router that passes a tight price limit and settles whatever filled can be pushed into the partial fill by a front-runner moving spot to the victim's limit, and the resulting 3% holder fee is then collected pro rata by holders, including the front-runner. The sign flip the brief asks about cannot happen, but only by accident: when `fee > poolQuote` on an exact-out sell the `Trade` event expression `poolQuote - fee` at line 350 underflows and the whole swap reverts with panic 0x11. Our routers and the Universal Router always pass MIN/MAX limits, so they only hit the exact-out-sell case at the end of the curve (which reverts) and are otherwise unaffected. Fix that keeps the design: in `afterSwap`, for the quote-specified branch, read the executed specified amount from `delta` and revert with an explicit error when it differs from `amount - fee` (exact-in) or `amount + fee` (exact-out); or compute the overcharge and refund it on the unspecified side. Either way replace the accidental underflow guard at line 350 with an explicit check.","line":310,"path":"contracts/src/PepesFamily.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\n/// @dev Stand-in for a third-party router that passes a price limit and settles whatever filled (partial fill).\ncontract LimitRouter is IUnlockCallback {\n    IPoolManager immutable pm;\n\n    constructor(IPoolManager pm_) {\n        pm = pm_;\n    }\n\n    /// @return paid ETH actually taken from the caller for an exact-in ETH buy stopped at `limit`.\n    function buyEthExactIn(PoolKey memory key, uint256 amountIn, uint160 limit) external payable returns (uint256 paid) {\n        paid = abi.decode(pm.unlock(abi.encode(msg.sender, key, amountIn, limit)), (uint256));\n        if (msg.value > paid) payable(msg.sender).transfer(msg.value - paid);\n    }\n\n    function unlockCallback(bytes calldata raw) external returns (bytes memory) {\n        require(msg.sender == address(pm));\n        (address user, PoolKey memory key, uint256 amountIn, uint160 limit) =\n            abi.decode(raw, (address, PoolKey, uint256, uint160));\n        BalanceDelta d = pm.swap(key, SwapParams(true, -int256(amountIn), limit), \"\");\n        uint256 paid = uint256(int256(-d.amount0()));\n        pm.settle{value: paid}();\n        pm.take(key.currency1, user, uint256(int256(d.amount1())));\n        return abi.encode(paid);\n    }\n}\n\ncontract MockIMD {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @notice Invariant 2 of AUDIT.md: a swap that goes through pays at most 4% (+1 wei) of the trader's gross quote.\n///         Fails on the current code: an exact-in buy of 100 ETH stopped by a price limit after ~0.00025 ETH\n///         still pays the 4 ETH fee computed on the requested amount in `beforeSwap`.\n///         Passes once the hook either reverts on a partial fill in the quote-specified modes or charges on the\n///         executed amount.\ncontract PartialFillFeeTest is Test {\n    using StateLibrary for IPoolManager;\n\n    PoolManager pm;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    LimitRouter limitRouter;\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        MockIMD imd = new MockIMD();\n        limitRouter = new LimitRouter(pm);\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                address(this),\n                address(0xFEE),\n                DeployLib.startTickForMarketCap(1.5 ether),\n                DeployLib.startTickForMarketCap(100e18),\n                PepesFamily.ImdEthPool(10_000, 100, address(0))\n            )\n        );\n        uint160 flags = uint160((1 << 13) | (1 << 11) | (1 << 7) | (1 << 6) | (1 << 3) | (1 << 2));\n        (bytes32 salt, address expected) = DeployLib.mineSalt(address(this), flags, initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        require(deployed == expected, \"hook address\");\n        pad = PepesFamily(deployed);\n        router = PepesFamilyRouter(payable(pad.router()));\n        vm.deal(alice, 100 ether);\n        vm.deal(bob, 1000 ether);\n    }\n\n    function test_partialFillPaysAtMostFourPercent() public {\n        vm.prank(alice);\n        address token = pad.launch(\"Test\", \"TST\", \"\", address(0));\n        vm.prank(alice);\n        router.buy{value: 1 ether}(token, 1 ether, 0, block.timestamp);\n\n        PoolKey memory key = pad.poolKey(token);\n        (uint160 sqrtNow,,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n        uint160 limit = uint160((uint256(sqrtNow) * 9999) / 10000); // stops after a sliver fills\n\n        uint256 protoBefore = pad.pendingProtocolFees(address(0));\n        uint256 holderBefore = pad.pendingHolderFees(token);\n        vm.prank(bob);\n        try limitRouter.buyEthExactIn{value: 100 ether}(key, 100 ether, limit) returns (uint256 paid) {\n            uint256 fee = pad.pendingProtocolFees(address(0)) - protoBefore + pad.pendingHolderFees(token) - holderBefore;\n            assertLe(fee, (paid * 4) / 100 + 1, \"fee exceeds 4% of what the trader actually paid\");\n        } catch {\n            // a hook that refuses partial fills in this mode also satisfies the invariant\n        }\n    }\n}","reproduction":"ETH-paired launch; alice buys 1 ETH through PepesFamilyRouter so the pool holds 0.96 ETH. bob calls PoolManager.swap on the pool with zeroForOne=true, amountSpecified=-100e18, sqrtPriceLimitX96 = current sqrtPrice * 9999/10000 through any router that settles the returned delta (PoolSwapTest or the LimitRouter in the proof). Expected: fee <= 4% of bob's gross (<= 0.16 ETH if 4 ETH had traded, far less here). Actual: pendingProtocolFees+pendingHolderFees grow by exactly 4e18 while bob's balance drops by 4.000248873956073623e18; the pool received 0.000248 ETH. Exact-out variant: alice and bob buy 1 ETH each, record sqrtPrice pMid, bob buys 0.1 ETH more, then bob swaps zeroForOne=false, amountSpecified=+2e18, sqrtPriceLimitX96=pMid: bob receives 12666666666666666 wei and 83333333333333333 wei is charged (8680 bps of gross). Same swap with amountSpecified=+100e18 reverts with panic 0x11 from afterSwap line 350.","severity":"medium","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Quote-specified swaps pay 4% of the requested amount, so a partial fill is charged up to ~100% of what actually traded"},{"citation":"resolved","description":"The launch position covers [minUsableTick, start] (token is currency1) or [start, maxUsableTick] (token is currency0) and the pool is initialised exactly at `start`, so whenever the pool holds no quote (every fresh launch, and any token whose holders have all exited) the current price sits on the edge of the only position and pool liquidity is 0 in the sell direction. A sell-direction swap of any size then walks through empty tick words to the caller's price limit, exchanges nothing, and leaves slot0 at MAX_SQRT_PRICE-1 (or MIN_SQRT_PRICE+1). The hook accepts it: `afterSwap` sees poolQuote = tokenAmount = 0, charges no fee and emits `Trade(..., 0, 0, 0, sqrtPriceX96 = MAX)`. The caller needs no tokens, no quote and pays only gas. Afterwards `marketCap()` returns 0, `getTokenInfo().sqrtPriceX96` is the extreme price, and the website (web/index.html lines 680-681 and 883) shows a market cap of 0 and a price chart point at 0 built from the Trade event. The next real buy walks back through the empty words (~17 extra bitmap reads) and trades at the correct price, so no funds are at risk; this is a free griefing of every price surface the contracts expose and of the home-page ranking. Fix: in `afterSwap` revert when `tokenAmount == 0` (a swap on these pools that moves no tokens is never legitimate), or additionally clamp by rejecting swaps whose resulting tick is outside the launch range.","line":331,"path":"contracts/src/PepesFamily.sol","reproduction":"Launch an ETH-paired token (price 1.5 ETH market cap, tick 203000). From an address holding no tokens and sending no value call PoolSwapTest.swap(key, SwapParams(zeroForOne=false, amountSpecified=-1, sqrtPriceLimitX96=MAX_SQRT_PRICE-1)). Expected: revert or no price change. Actual: the call succeeds, caller balances unchanged, slot0.sqrtPriceX96 == MAX_SQRT_PRICE-1, tick 887271, pad.marketCap(token) == 0 (was 1528490686780151368). Same on an IMD pool with the token as currency0 using zeroForOne=true and MIN_SQRT_PRICE+1: marketCap goes to 0. A following router.buy of 1 ETH still succeeds and marketCap recovers to 4.05 ETH.","severity":"low","snippet":"        uint256 tokenAmount = uint256(int256(t < 0 ? -t : t));","title":"A swap with nothing to trade moves the pool price to the end of the curve for free; marketCap, getTokenInfo and the Trade event then report ~0"},{"citation":"resolved","description":"PadToken is a plain ERC-20 with no transfer hooks, so holders can supply it as liquidity to any other venue: a v4 pool with a different fee/tickSpacing/hook (beforeInitialize only blocks keys whose hook is PepesFamily), or a v2/v3 pool. Swaps there pay nothing to the protocol or to holders, and once such a pool has depth, aggregators will route around the 4%. The brief states invariant 2 for launched pools only, so this is a design boundary rather than a code bug, but it caps the economics the docs advertise (\"4% of every swap\") and should be stated as an accepted limitation. No code fix is possible without changing the token (transfer-level fees), which the design rejects.","line":30,"path":"contracts/src/PepesFamily.sol","reproduction":"ETH-paired launch; bob buys 5 ETH through the router. Anyone initialises PoolKey{currency0: ETH, currency1: token, fee: 3000, tickSpacing: 60, hooks: 0} at the current price (succeeds: no hook is consulted) and bob adds full-range liquidity with PoolModifyLiquidityTest (2 ETH + tokens). carol swaps 0.5 ETH exact-in in that pool through PoolSwapTest. Expected per the docs: 0.02 ETH of fees. Actual: carol receives tokens, pendingProtocolFees(ETH) and pendingHolderFees(token) are unchanged.","severity":"info","snippet":"///         This contract is also the pools' v4 hook. It charges 4% of the quote side of every swap, whichever","title":"The 4% fee and holder rewards only bind swaps in the hooked pool; any second pool for the same token trades fee-free"},{"citation":"resolved","description":"The bound only keeps the tick inside the usable range. The launch liquidity is `amount * Q96 / (sqrtU - sqrtL)` (token is currency1) or the mirrored formula, and v4 caps liquidityGross per tick at type(uint128).max / 8873 ~= 3.8e34 for spacing 200. With the token as currency1 and start tick t, sqrtU - sqrtL ~= 2^96 * 1.0001^(t/2), so for t below about -349,200 (a starting market cap above ~1.4e24 ETH) `modifyLiquidity` reverts with TickLiquidityOverflow, and far below that `int256(liquidity)` wraps negative; the mirrored orientation has the same bound. Launches for that quote stay bricked until the owner resets the tick. Owner-only and reversible, with no unprivileged amplifier, so this is a hardening note that answers the brief's question in section 6.3: an extreme tick cannot misprice an existing pool but can block new launches. Tighten `_setStartTick` to the range where the liquidity formula fits, e.g. require |tick| <= 340_000, or compute the liquidity in the setter and revert if it exceeds the per-tick cap.","line":451,"path":"contracts/src/PepesFamily.sol","reproduction":"owner calls setStartTick(ETH, -400000) (accepted: multiple of 200, within +-887000). Any pad.launch(..., ETH) then reverts (TickLiquidityOverflow from PoolManager.modifyLiquidity). setStartTick(ETH, -340000) followed by the same launch succeeds.","severity":"info","snippet":"        if (tick % TICK_SPACING != 0 || tick > limit || tick < -limit) revert BadTick();","title":"setStartTick accepts ticks beyond about -349,200 for which every launch on that quote reverts"},{"citation":"resolved","description":"Reviewed against the v1 router at commit a549093. The only `transferFrom` the router issues is in `unlockCallback`, with `from = d.user`, and `d.user` is always `msg.sender` of `sell`/`buy`/`launch` because the router builds `SwapData` itself and the PoolManager only calls `unlockCallback` on the contract that called `unlock`, passing that contract's own bytes back. `unlockCallback` checks `msg.sender == poolManager`, there is no fallback or receive, and `launch`/`launchFor` never pull tokens. The pulled currency is `cIn` from `pad.poolKey(token)` for a token the pad launched, so a crafted key cannot redirect it. The v1 ETH router is a separate contract and is not the exempt `router`, so it needs a normal approval. Conclusion: the exemption lets the v1 router spend only the caller's own tokens and only inside the caller's own sell; nobody can move another holder's balance, selling is never blocked, and the GoPlus `owner_change_balance` / `is_honeypot` classification is a false positive of the pattern, not of the behaviour. v2 removed the exemption entirely (PadToken.sol line 155), which is the right call for scanner compatibility.","line":105,"path":"contracts/src/v1/PadTokenV1.sol","reproduction":"With the v1 token: carol (holding nothing) calls v1 router.sell(token, bobBalance, 0, deadline): the router calls token.transferFrom(carol, poolManager, bobBalance) and reverts with InsufficientBalance; bob's balance is unchanged. Calling router.unlockCallback(abi.encode(SwapData{user: bob, ...})) directly reverts with NotPoolManager. There is no other external function on the router that reaches transferFrom.","severity":"info","snippet":"        if (msg.sender != router) {","title":"v1 router allowance exemption: no path lets the v1 router move tokens from anyone but its own msg.sender (GoPlus honeypot flag is a false positive)"}],"hash":"35e81b1f74ab316acb96b42dbc65cc9d6e051895660a3b9538639ceea3a79263","nodeId":"682dfc6e-1ff3-469b-904c-d8f480dec2c1","outcome":"completed","summary":"The review is complete. The findings file is written at the repository root with five entries, every quoted snippet matches its source line, and no tracked file was changed. The only new files are my scratch tests under `contracts/test/scratch/`, which the task discards.\n\n**What I found, ranked**\n\n- **Medium: partial fills in the quote-specified swap modes pay 4% of the requested amount, not of what traded.** `beforeSwap` computes and mints the fee from `amountSpecified` before the pool runs. When a price limit stops the swap early, the fee stays. Measured: an exact-in buy of 100 ETH limited 0.01% below spot moved 0.00025 ETH into the pool and cost the trader 4.0002 ETH, a 99.99% effective fee. An exact-out sell at a limit kept 0.0127 ETH for the seller against 0.0833 ETH of fees. This breaks the brief's invariant 2. The sign flip the brief asks about cannot happen, but only because the `Trade` event's `poolQuote - fee` underflows and reverts the swap when the fee exceeds the output. A front-runner can induce the partial fill for any trader whose router passes a tight price limit, and holders collect the fee. The project's own routers and the Universal Router use MIN/MAX limits and are unaffected. The proof test in the findings fails on the current code.\n- **Low: a swap with nothing to trade moves the pool price to the curve end for free.** With no quote in the pool (every fresh launch, any fully-exited token) a sell-direction swap by an address holding nothing succeeds, exchanges nothing, and leaves `slot0` at the extreme price. `marketCap` drops from 1.53 ETH to 0, and the website's market cap, price card and chart read that value. The next real buy trades correctly, so no funds are at risk.\n- **Info: the 4% fee only binds the hooked pool.** A no-hook v4 pool for the same token trades fee-free; reproduced. A design limitation worth stating in the docs.\n- **Info: `setStartTick` accepts ticks below about -349,200 that make every launch on that quote revert** with a tick-liquidity overflow. Owner-only and reversible, answering the brief's question on extreme ticks.\n- **Info: the v1 router allowance exemption is safe.** The only `transferFrom` the v1 router issues uses its own caller as the source, and only the PoolManager can reach that path with data the router itself encoded. The GoPlus honeypot flag is a false positive.\n\n**Areas checked in depth with no defect found**\n\n- Hook delta signs in all four modes and both currency orders, including the transient fee slot handoff and the claim mint that nets the hook's credit to zero.\n- `flush` and `collectProtocolFees` inside a foreign unlock: burn plus take nets to zero for the pad, the pad only burns claims it minted, and no caller's `sync`/`settle` window is reachable from the hook, so the PoolManager cannot be drained and claim solvency holds.\n- Launch liquidity rounding: owed tokens never exceed supply minus the buffer in either orientation.\n- Reward accounting: corrections and per-share overflow need roughly 1.7e11 ETH of distributed fees to trigger, the exclusion set is static, claim reentrancy is guarded, and payouts cannot exceed what was distributed.\n- Permit domain, nonce, expiry and front-run tolerance, router `msg.value` handling and refunds, and the two-hop ETH route's leftover math.\n\nThe brief's accepted behaviours (dividend sniping, fee timing on third-party routers, early fees waiting) were confirmed as described and not reported.","treeHash":null,"usage":{"cachedInputTokens":2688370,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":74020,"runtime":"claude","turns":40,"wallClockMs":1162133}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"0256823ae36e7900","findings":[{"citation":"resolved","description":"When quote is the specified currency, beforeSwap charges the fee on params.amountSpecified and afterSwap reuses that fee without reconciling the executed quote delta. A price limit can substantially reduce execution while the entire fee is still charged, violating the promised 4% of actual gross quote. Exact-output sells have the same root cause: when actual pool quote output is below the precharged fee, poolQuote - fee at line 350 underflows and the swap reverts. The effect is confined to the affected swap; this does not demonstrate unbacked claims or a PoolManager drain. Calculate the final fee from executed quote and reconcile/refund the unused reservation. If the specified-side hook interface cannot support that reconciliation, explicitly rejecting partial fills is a fallback with a documented loss of partial-fill functionality.","line":310,"path":"contracts/src/PepesFamily.sol","reproduction":"Reproduced with forge test --match-path test/scratch/MathBoundaryReview.t.sol against the real local v4 PoolManager. Launch an ETH pair with startTick=200000, then use a v4 router to call swap with zeroForOne=true, amountSpecified=-1000000000000000000 and sqrtPriceLimitX96=TickMath.getSqrtPriceAtTick(199800)=1726889473955340833779193014885997. The pool consumes only 20734620302640506 wei ETH, but the hook charges 40000000000000000 wei, so actual total input is 60734620302640506 wei and the fee is about 65.86% of actual input. Expected fee invariant: fee is floor(actual gross input*400/10000), within 1 wei; here that is 2429384812105620, not 40000000000000000. For the same executed pool amount, a corrected 4%-of-gross fee would be floor(20734620302640506*400/9600)=863942512610021 wei. Also reproduced from a fresh identical pool: buy 1 ETH using PepesFamilyRouter, approve the external router, then sell exact-output with zeroForOne=false, amountSpecified=10000000000000000 and sqrtPriceLimitX96=currentSqrtPriceX96+1=1190372177540186201276316440155564. The pool returns 0 quote for 1 token wei input, but the reserved fee is 416666666666666 wei; afterSwap reverts with arithmetic panic 0x11 at poolQuote-fee instead of reconciling the partial execution.","severity":"medium","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Partial fills retain the fee on the requested quote amount and overcharge traders"},{"citation":"resolved","description":"_setStartTick validates tick spacing and TickMath range but does not validate the liquidity required for the fixed 1e27 supply against PoolManager maxLiquidityPerTick. Consequently it accepts configurations under which every new launch for that quote reverts. This affects future launches only and requires an owner configuration change; existing pools are unaffected. Validate the calculated liquidity for both currency orderings against the per-tick cap before accepting a starting tick.","line":451,"path":"contracts/src/PepesFamily.sol","reproduction":"With a deployed PepesFamily and the canonical v4 PoolManager, the owner calls setStartTick(address(0), -349400). The call succeeds because -349400 is a multiple of 200 and within the accepted [-887000,887000] interval. Any user then calls launch(\"T\", \"T\", \"\", address(0)). _addLaunchLiquidity calculates liquidity 38614042292128989553118521597990787, above maxLiquidityPerTick(200)=38345995821606768476828330790147420, so modifyLiquidity reverts TickLiquidityOverflow(-887200). Expected: reject the unsupported tick at configuration time, or successfully launch under an accepted configuration. Actual: configuration succeeds but all subsequent ETH launches revert until the owner changes it. The adjacent tick -349200 is below the liquidity cap. Confirmed by a local Foundry test against the real v4 PoolManager: test_acceptedTickAllowsLaunch fails with TickLiquidityOverflow(-887200).","severity":"low","snippet":"        if (tick % TICK_SPACING != 0 || tick > limit || tick < -limit) revert BadTick();","title":"Accepted start ticks can exceed the per-tick liquidity cap and disable new launches"}],"hash":"361c61d81fa7d5a1d5f2b217dc037ebb524f111bd12ae5123dfc2e08e388594c","nodeId":"d5967a4b-f4f7-4abf-bbf2-9fcfd1837433","outcome":"completed","summary":"Wrote two validated findings to [.imd-findings.json](/home/imd-worker/.identitymd/work/a3e708e2-fb57-43ea-a163-d93b916694a2/d5967a4b-f4f7-4abf-bbf2-9fcfd1837433/.imd-findings.json):\n\n- **Medium:** Partial fills overcharge swap fees.\n- **Low:** Accepted extreme starting ticks prevent new launches.\n\nAll 38 existing tests passed. Targeted tests reproduced both defects. Temporary tests were removed; production files remain unchanged.","treeHash":null,"usage":{"cachedInputTokens":1376640,"inputTokens":94238,"model":"gpt-6-astra","outputTokens":8912,"runtime":"codex","turns":5,"wallClockMs":606387}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f6a9bdd601cb99f","findings":[],"hash":"771af6025dbabc3f0abfabc5c819285dd5271e8e8c414f6ca372ce50a5e40759","nodeId":"5b677900-d004-4b49-a23f-6d0802bb8d04","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":3,"wallClockMs":213391}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3c7630b22a73c1fb","findings":[{"citation":"resolved","description":"In the two modes where the quote is the specified currency (exact-in buy, exact-out sell) `beforeSwap` derives the fee from `params.amountSpecified`, mints ERC-6909 claims for it in `_chargeFee` and returns it as the specified-side BeforeSwapDelta. v4 then swaps only `amount - fee` (exact-in) or asks the pool for `amount + fee` (exact-out), and in `Hooks.afterSwap` subtracts the full hook delta from the swapper regardless of how much executed. If the swap stops at the trader's `sqrtPriceLimitX96` before the requested amount is reached, the trader still pays 4% of the *requested* amount. `afterSwap` reads the fee back from FEE_SLOT and never compares the executed delta with the request. Measured: an exact-in buy of 1 ETH with a limit 0.1% below spot puts ~0.0017 ETH into the pool but pays 0.04 ETH of fee (96% of gross); 100 ETH with a limit 0.01% below spot pays 4.000 ETH for a 0.00025 ETH fill; an exact-out sell of 1 ETH with a limit 5% above spot receives 0.164 ETH gross and pays 0.041667 ETH (25%). When the exact-out partial fill is smaller than the pre-charged fee, the `Trade` event expression `poolQuote - fee` at line 350 underflows and the whole swap reverts with Panic(0x11) wrapped in HookCallFailed. That accidental revert is the only thing stopping the seller's quote delta from flipping negative (paying both tokens and quote), so any fix must not simply make line 350 saturating. This breaks AUDIT.md invariant 5.2 (`exactly 4% of the trader's gross quote amount`) and answers section 6.1: the overcharge is confined to the swap's own trader and the claim accounting stays consistent (minted claims == hook credit), but the loss is real for users of any router that passes a tight price limit as slippage protection (a documented v4 pattern): a front-runner who moves spot to the victim's limit forces the victim to pay the full fee for a near-zero fill, and the 3% holder share is then collected pro rata by holders including the front-runner. The project's routers and the Universal Router pass MIN/MAX limits and are only affected by the exact-out-sell revert at the end of the curve. The other two modes compute the fee from the pool's actual delta in `afterSwap` and are correct. Fix that keeps the design: in `afterSwap`, for the quote-specified branch, derive the executed specified amount from `delta` and revert with a dedicated error when it differs from `amount - fee` (exact-in) or `amount + fee` (exact-out), replacing the accidental Panic at line 350 with that explicit check; alternatively recompute the fee as 4% of the executed quote, burn the excess claims minted in `beforeSwap` and refund the difference to the swapper through the unspecified-side return delta (a hook cannot hand back specified currency from afterSwap). Merged from audit_economics 4a304d96, audit_permissions 18038b6d, audit_flow 00e8a3da and audit_math ae6142c5; all four proofs fail on the current code for this reason.","line":310,"path":"contracts/src/PepesFamily.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\n/// @dev Hook fee on partially filled swaps whose specified currency is the quote.\n///      `beforeSwap` computes the 4% fee on `params.amountSpecified` (the requested amount) and mints claims for it.\n///      When the swap stops at `sqrtPriceLimitX96` before the requested amount is reached, the trader still pays the\n///      full fee, so the fee is far more than 4% of what actually traded. Both tests fail on the current code and\n///      pass once the hook either charges 4% of the executed quote amount or rejects partial fills.\ncontract PartialFillFeeTest is Test {\n    using StateLibrary for IPoolManager;\n\n    address constant FEE_RECIPIENT = 0x3c8A4d94B3219F6633F2cC94094f4765b30c691C;\n    PoolManager pm;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PoolSwapTest extRouter; // third-party router: the trader picks the price limit\n    PoolSwapTest.TestSettings settings = PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address owner = makeAddr(\"owner\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        extRouter = new PoolSwapTest(pm);\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(0xBEEF), // IMD stand-in, unused here\n                owner,\n                FEE_RECIPIENT,\n                DeployLib.startTickForMarketCap(1.5 ether),\n                DeployLib.startTickForMarketCap(100e18),\n                PepesFamily.ImdEthPool(10_000, 100, address(0))\n            )\n        );\n        (bytes32 salt, address expected) = DeployLib.mineSalt(address(this), uint160(0x28CC), initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        require(deployed == expected, \"hook address\");\n        pad = PepesFamily(deployed);\n        router = PepesFamilyRouter(payable(pad.router()));\n        vm.deal(alice, 100 ether);\n        vm.deal(bob, 100 ether);\n    }\n\n    function _launchAndBuy() internal returns (PadToken t, PoolKey memory key) {\n        vm.prank(alice);\n        t = PadToken(payable(pad.launch(\"Test\", \"TST\", \"\", address(0))));\n        vm.prank(bob);\n        router.buy{value: 2 ether}(address(t), 2 ether, 0, block.timestamp);\n        key = pad.poolKey(address(t));\n        vm.prank(bob);\n        t.approve(address(extRouter), type(uint256).max);\n    }\n\n    function _pendingFees(PadToken t) internal view returns (uint256) {\n        return pad.pendingProtocolFees(address(0)) + pad.pendingHolderFees(address(t));\n    }\n\n    /// Exact-in buy of 1 ETH with a price limit 0.1% below the current sqrt price: the pool only takes ~0.0015 ETH\n    /// but the hook charges 0.04 ETH (fee on the requested 1 ETH): ~96% of what the trader paid.\n    function test_exactInBuy_priceLimit_feeIsFourPercentOfExecuted() public {\n        (PadToken t, PoolKey memory key) = _launchAndBuy();\n        (uint160 p,,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n        uint256 ethBefore = bob.balance;\n        uint256 feeBefore = _pendingFees(t);\n        vm.prank(bob);\n        try extRouter.swap{value: 1 ether}(key, SwapParams(true, -1 ether, p - p / 1000), settings, \"\") {\n            uint256 paid = ethBefore - bob.balance; // gross quote the trader spent, fee included\n            uint256 fee = _pendingFees(t) - feeBefore;\n            assertLe(fee, (paid * 4) / 100 + 2, \"fee exceeds 4% of the quote actually paid\");\n        } catch {\n            // rejecting the partial fill is also a valid fix\n        }\n    }\n\n    /// Exact-out sell of 1 ETH with a price limit 5% above the current sqrt price: the pool pays out ~0.164 ETH but\n    /// the hook keeps 0.041667 ETH (fee on the requested 1 ETH): ~25% of what actually traded.\n    function test_exactOutSell_priceLimit_feeIsFourPercentOfExecuted() public {\n        (PadToken t, PoolKey memory key) = _launchAndBuy();\n        (uint160 p,,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n        uint256 ethBefore = bob.balance;\n        uint256 feeBefore = _pendingFees(t);\n        vm.prank(bob);\n        try extRouter.swap(key, SwapParams(false, int256(1 ether), p + p / 20), settings, \"\") {\n            uint256 received = bob.balance - ethBefore;\n            uint256 fee = _pendingFees(t) - feeBefore;\n            uint256 gross = received + fee; // quote the pool actually paid out\n            assertLe(fee, (gross * 4) / 100 + 2, \"fee exceeds 4% of the quote actually received\");\n        } catch {\n            // rejecting the partial fill is also a valid fix\n        }\n    }\n}","reproduction":"Launch an ETH-quoted token; bob buys 2 ETH through PepesFamilyRouter; p = slot0.sqrtPriceX96. (a) bob swaps through v4-core PoolSwapTest: SwapParams(zeroForOne=true, amountSpecified=-1 ether, sqrtPriceLimitX96=p - p/1000) with 1 ETH. Expected: fee <= 4% of the ETH bob actually spent (+2 wei). Actual: bob's balance drops by 0.0417 ETH, pendingProtocolFees(ETH)+pendingHolderFees(token) grow by exactly 0.04 ETH: 40000000000000000 > 1738077705176384 (the 4% bound). (b) bob swaps SwapParams(false, +1 ether, p + p/20): pool pays 0.164 ETH gross, fee 41666666666666666 wei charged vs bound 6568553689105052; bob receives 0.1225 ETH (25.4% fee). (c) bob swaps SwapParams(false, +1 ether, p + 1): pool delivers ~0 ETH < fee 0.041667 ETH, afterSwap reverts Panic(0x11) at `poolQuote - fee` (PoolManager surfaces WrappedError(hook, afterSwap.selector, Panic(0x11), HookCallFailed())). (d) with a limit one unit below spot and amountSpecified=-10 ether, the trader pays 400000000000000001 wei, receives 0 tokens, fee 0.4 ETH. Run: cd contracts && forge test --match-path test/scratch/Proof_18038b6d8bb1.t.sol (both tests fail on the current code).","severity":"medium","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Quote-specified swaps are charged 4% of the requested amount, so a partial fill at sqrtPriceLimitX96 pays up to ~100% of what actually traded (or reverts with Panic 0x11)"},{"citation":"resolved","description":"The launch position covers [minUsableTick, start] (token is currency1) or [start, maxUsableTick] (token is currency0) and the pool is initialised exactly at `start`, where the position is not yet active. Whenever the pool holds no quote (every fresh launch without an initial buy, and any token whose buyers have all sold back) a sell-direction swap finds zero liquidity, so Pool.swap exchanges nothing and walks slot0 through empty tick words to the caller's `sqrtPriceLimitX96` (MAX_SQRT_PRICE-1 or MIN_SQRT_PRICE+1). The swapper's delta is 0/0, so the caller needs no tokens and no quote. The hook accepts it: `afterSwap` sees poolQuote == tokenAmount == 0, charges no fee and emits Trade(token, tx.origin, false, 0, 0, 0, sqrtPrice-at-limit); the PoolManager emits a Swap event with the same price. Afterwards `marketCap()` and `getTokenInfo().marketCap` return 0 for both orientations, the website's listing/ranking and chart show 0, and third-party charts built from Swap events show the token collapsing. Funds are not at risk: the next buy crosses the start tick (an initialized tick), re-activates the liquidity and trades at the correct price (confirmed: a following 1 ETH buy succeeds and marketCap recovers to ~4.05e18), so this is a free, repeatable griefing of every price surface the contracts expose. Minimal fix: in `afterSwap` revert when `tokenAmount == 0` (a swap on these pools that moves no tokens is never legitimate), optionally also clamping `marketCap()` to the start price when slot0 is outside the launch range. Merged from audit_economics 2acb80fc and audit_flow 4ef927c4.","line":331,"path":"contracts/src/PepesFamily.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract MockIMD {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function approve(address s, uint256 amt) external returns (bool) { allowance[msg.sender][s] = amt; return true; }\n    function transfer(address to, uint256 amt) external returns (bool) { balanceOf[msg.sender] -= amt; balanceOf[to] += amt; return true; }\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt; balanceOf[to] += amt; return true;\n    }\n}\n\n/// @notice A swap on a pool that holds no quote fills nothing but still moves the pool price to the tick limit.\n///         Anyone with zero tokens can do it; afterwards marketCap() reports 0 until the next buy.\ncontract ZeroCostPriceDisplacementTest is Test {\n    using StateLibrary for IPoolManager;\n\n    PoolManager pm;\n    PepesFamily pad;\n    PoolSwapTest ext;\n    address alice = makeAddr(\"alice\");\n    address griefer = makeAddr(\"griefer\");\n    address owner = makeAddr(\"owner\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        ext = new PoolSwapTest(pm);\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(pm, address(new MockIMD()), owner, owner, int24(203000), int24(203000),\n                PepesFamily.ImdEthPool(10_000, 100, address(0)))\n        );\n        bytes32 initHash = keccak256(initCode);\n        for (uint256 i; i < 500_000; i++) {\n            address h = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), initHash)))));\n            if (uint160(h) & 0x3FFF == 0x28CC) {\n                address deployed;\n                assembly { deployed := create2(0, add(initCode, 0x20), mload(initCode), i) }\n                require(deployed == h, \"hook address\");\n                pad = PepesFamily(deployed);\n                break;\n            }\n        }\n        vm.deal(alice, 100 ether);\n    }\n\n    function test_quoteEmptyPoolPriceCannotBeDisplacedForFree() public {\n        vm.prank(alice);\n        PadToken t = PadToken(payable(pad.launch(\"T\", \"T\", \"\", address(0))));\n        PoolKey memory key = pad.poolKey(address(t));\n        uint256 mcapBefore = pad.marketCap(address(t));\n        (, int24 tickBefore,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n        assertEq(t.balanceOf(griefer), 0);\n\n        // griefer \"sells\" 1 wei of a token they do not hold on a pool that holds no quote\n        vm.prank(griefer);\n        try ext.swap(key, SwapParams(false, -1, TickMath.MAX_SQRT_PRICE - 1), PoolSwapTest.TestSettings(false, false), \"\") {}\n        catch {}\n\n        (, int24 tickAfter,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n        // Expected: a zero-fill swap must not move the pool. Actual: tick jumps to 887271 and marketCap() is 0.\n        assertEq(tickAfter, tickBefore, \"pool tick moved by a zero-fill swap\");\n        assertEq(pad.marketCap(address(t)), mcapBefore, \"marketCap changed by a zero-fill swap\");\n    }\n}","reproduction":"Launch an ETH-paired token with no initial buy (start tick 203000; marketCap() = 1528490686780151368 wei). From an address holding zero tokens and sending no value, call PoolSwapTest.swap(key, SwapParams(zeroForOne=false, amountSpecified=-1, sqrtPriceLimitX96=MAX_SQRT_PRICE-1), settings, \"\"). Expected: revert or no price change. Actual: the call succeeds, caller balances unchanged, slot0.tick == 887271 (was 203000), sqrtPriceX96 == 1461446703485210103287273052203988822378723970341, pad.marketCap(token) == 0, a Trade event with all-zero amounts is emitted. Same on an IMD pool with the token as currency0 using zeroForOne=true and MIN_SQRT_PRICE+1. Run: cd contracts && forge test --match-path test/scratch/Proof_4ef927c4f0ae.t.sol (fails: 'pool tick moved by a zero-fill swap: 887271 != 203000').","severity":"low","snippet":"        uint256 tokenAmount = uint256(int256(t < 0 ? -t : t));","title":"A zero-fill swap on a quote-empty pool moves the price to the end of the curve for free; marketCap(), getTokenInfo() and the Trade/Swap events then report ~0"},{"citation":"resolved","description":"`_setStartTick` only checks spacing alignment and |tick| <= maxUsableTick - 200 (887000). `_addLaunchLiquidity` derives liquidity from the fixed supply over the remaining curve: L = (TOTAL_SUPPLY - 1e9) * 2^96 / (sqrtPrice(tick) - sqrtPrice(minUsableTick)) for the token-is-currency1 orientation, mirrored for currency0. As the start tick falls the denominator shrinks and L passes v4's maxLiquidityPerTick for spacing 200 (type(uint128).max / 8873 ~= 3.83e34), so PoolManager.modifyLiquidity reverts TickLiquidityOverflow(-887200); further down `int256(liquidity)` no longer fits int128 either. Measured boundary: -349200 still launches (liquidity just under the cap, market cap 1.46e42 wei), -349400 computes 38614042292128989553118521597990787 > 38345995821606768476828330790147420 and reverts; everything from -349400 to -887000 (about 60% of the accepted negative range) is a dead zone for both ETH and IMD launches (6 of 6 IMD launches failed at -349400, both orientations). On the positive side the bound admits ticks where the starting market cap rounds to a few wei (tick 600000 gives 8 wei, 800000 gives 0). This is owner-only and reversible (existing pools are unaffected), so it is a trust/robustness issue rather than an exploit, but AUDIT.md section 3 describes the owner's tick as 'bounded' and says the owner cannot pause, while this is in effect a per-quote pause (or a mispricing of all future launches) that the bounds were meant to prevent; it answers section 6.3. Fix: in `_setStartTick` compute the launch liquidity for the relevant orientation(s) with the same formula as `_addLaunchLiquidity` and revert BadTick if it exceeds Pool.tickSpacingToMaxLiquidityPerTick(TICK_SPACING) (or simply require |tick| <= 340_000), optionally also bounding the implied market cap to a sane range. Merged from audit_economics 63aa4d95, audit_permissions d8cc7af0, audit_flow 09f3f2b8 and audit_math 0b7f85e2.","line":451,"path":"contracts/src/PepesFamily.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\ncontract MockIMD {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function approve(address s, uint256 amt) external returns (bool) { allowance[msg.sender][s] = amt; return true; }\n    function transfer(address to, uint256 amt) external returns (bool) { balanceOf[msg.sender] -= amt; balanceOf[to] += amt; return true; }\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt; balanceOf[to] += amt; return true;\n    }\n}\n\n/// @notice `_setStartTick` accepts every spacing-aligned tick inside +-887000, but for start ticks below about\n///         -349200 the launch liquidity exceeds v4's per-tick cap and every launch on that quote reverts with\n///         TickLiquidityOverflow. Fails on the current code (setStartTick(-349400) is accepted, then launch reverts);\n///         passes once setStartTick rejects ticks at which a launch cannot succeed, or launches succeed there.\ncontract StartTickBricksLaunchTest is Test {\n    PoolManager pm;\n    PepesFamily pad;\n    address owner = makeAddr(\"owner\");\n    address carol = makeAddr(\"carol\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(new MockIMD()),\n                owner,\n                owner,\n                DeployLib.startTickForMarketCap(1.5 ether),\n                DeployLib.startTickForMarketCap(100e18),\n                PepesFamily.ImdEthPool(10_000, 100, address(0))\n            )\n        );\n        (bytes32 salt, address expected) = DeployLib.mineSalt(address(this), uint160(0x28CC), initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        require(deployed == expected, \"hook address\");\n        pad = PepesFamily(deployed);\n    }\n\n    function test_acceptedStartTickAllowsLaunch() public {\n        vm.prank(owner);\n        try pad.setStartTick(address(0), -349400) {}\n        catch {\n            return; // rejecting the tick at configuration time is the expected fix\n        }\n        // Expected: a tick the owner may set yields a launchable configuration.\n        // Actual: PoolManager.modifyLiquidity reverts TickLiquidityOverflow(-887200) for every ETH launch.\n        vm.prank(carol);\n        pad.launch(\"T\", \"T\", \"\", address(0));\n    }\n}","reproduction":"Owner calls setStartTick(address(0), -349400): accepted (multiple of 200, within +-887000). Any account then calls pad.launch(\"T\",\"T\",\"\",address(0)) or PepesFamilyRouter.launch. Expected: a tick the owner is allowed to set yields a launchable configuration. Actual: revert TickLiquidityOverflow(-887200) from PoolManager.modifyLiquidity inside unlockCallback, for every launch until the tick is changed; setStartTick(address(0), -349200) followed by the same launch succeeds. setStartTick(address(imd), -349400) bricks IMD launches the same way. setStartTick(address(0), 600000) is accepted and the next launch reports marketCap() == 8 wei. Run: cd contracts && forge test --match-path test/scratch/StartTickBricksLaunch.t.sol (fails with TickLiquidityOverflow(-887200)).","severity":"low","snippet":"        if (tick % TICK_SPACING != 0 || tick > limit || tick < -limit) revert BadTick();","title":"setStartTick accepts ticks below about -349,200 for which every launch on that quote reverts with TickLiquidityOverflow (and far-positive ticks that price launches at a few wei)"},{"citation":"resolved","description":"`PermitHelper.permit` always calls `permit(msg.sender, address(this), amount, ...)` with `amount == tokenAmount`. The EIP-712 digest includes `value`, so a signature the holder produced for any other value (a larger or max allowance, as the NatSpec on PepesFamilyRouter.sol:118 invites with '`value` >= `tokenAmount`') does not recover to the holder and PadToken.permit reverts InvalidSignature; the catch branch then requires an *existing* allowance >= tokenAmount, which a user relying on permit does not have, so the sale reverts PermitFailed. The front-run tolerance likewise only works when the front-runner replays the identical signature. Users or integrators who sign one larger permit (the common pattern) cannot sell through the permit entry points; the website signs exactly `amount` so it is unaffected, and no funds are at risk. Fix: either add a `permitValue` parameter and pass it to `permit` (keeping the `allowance >= tokenAmount` fallback), or correct the NatSpec on PepesFamilyRouter.sol:118 and PepesFamilyEthRouter.sol:99 to say the permit must be signed for exactly `tokenAmount`. From audit_permissions eb8d0da8.","line":42,"path":"contracts/src/PepesFamilyRouter.sol","reproduction":"Holder dan buys 1 ETH of an ETH-quoted token (balance bal). dan signs a valid EIP-2612 permit for spender = router, value = 2*bal, nonce 0, deadline = now. dan calls router.sellWithPermit(token, bal, 1, now, v, r, s). Expected per NatSpec: the sale goes through because the signed value >= tokenAmount. Actual: reverts PermitFailed() (test_permitLargerValueRejected in test/scratch/JudgeChecks.t.sol, expectRevert(PermitHelper.PermitFailed.selector) passes).","severity":"low","snippet":"        try IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s) {}","title":"sellWithPermit / sellForEthWithPermit only accept a permit signed for exactly tokenAmount, contradicting the documented `value >= tokenAmount`"},{"citation":"resolved","description":"For any swap not sent by `PepesFamilyRouter` with a 32-byte hookData (including the project's own PepesFamilyEthRouter, which passes empty hookData, Universal Router, aggregators, ERC-4337 bundlers and Safe/EIP-7702 wallets) `trader` is `tx.origin`: the relayer or EOA that signed the outer transaction, not the account whose funds moved. The website's trade list and any analytics built on the event therefore show the wrong address for those trades; a relayer submitting many users' trades appears as one whale. No funds are affected; only the event is wrong. Fix: fall back to `sender` (the locker) when hookData carries no trader, and have PepesFamilyEthRouter pass abi.encode(r.user) as hookData and be recognised like `router`. From audit_permissions 58e4b03b.","line":348,"path":"contracts/src/PepesFamily.sol","reproduction":"vm.prank(bob, carol) (msg.sender bob, tx.origin carol); bob swaps 1 ETH exact-in through PoolSwapTest on an ETH-quoted pool. Expected: Trade.trader == bob (or the router contract). Actual: Trade.trader == carol (test_tradeEventTxOrigin in test/scratch/JudgeChecks.t.sol: expectEmit with trader = carol passes).","severity":"info","snippet":"        address trader = sender == router && hookData.length == 32 ? abi.decode(hookData, (address)) : tx.origin;","title":"Trade event attributes third-party-router swaps to tx.origin, misattributing trades made through contract wallets, bundlers, aggregators and the project's own ETH router"},{"citation":"resolved","description":"`beforeInitialize` only blocks pool keys whose hook is PepesFamily; PadToken is a plain ERC-20 with no transfer hooks, so holders can supply it as liquidity to any other venue: a v4 pool with a different fee/tickSpacing/hook, or a v2/v3 pool. Swaps there pay nothing to the protocol or to holders, and once such a pool has depth aggregators will route around the 4%. AUDIT.md invariant 5.2 is stated for launched pools only, so this is a design boundary rather than a code bug, but the docs advertise '4% of every swap, through any router' and this caps those economics. No code fix is possible without changing the token (transfer-level fees), which the design rejects; recommend listing it under section 8 as accepted behaviour. From audit_economics 48737709.","line":521,"path":"contracts/src/PepesFamily.sol","reproduction":"ETH-paired launch; bob buys 5 ETH through the router. Anyone initialises PoolKey{currency0: ETH, currency1: token, fee: 3000, tickSpacing: 60, hooks: 0} at the current price (succeeds: no hook is consulted) and bob adds full-range liquidity with PoolModifyLiquidityTest (2 ETH + tokens). carol swaps 0.5 ETH exact-in in that pool through PoolSwapTest. Expected per the docs: 0.02 ETH of fees. Actual: carol receives tokens; pendingProtocolFees(ETH) and pendingHolderFees(token) are unchanged (test_secondPoolNoFee in test/scratch/JudgeChecks.t.sol passes).","severity":"info","snippet":"    function beforeInitialize(address, PoolKey calldata, uint160) external pure returns (bytes4) {","title":"The 4% fee and holder rewards only bind swaps in the hooked pool; any second pool for the same token trades fee-free (design boundary, should be stated as accepted)"},{"citation":"resolved","description":"Not a defect; recorded because AUDIT.md section 6.6 asks for an independent opinion. Reviewed against the v1 PepesFamilyRouter at commit a549093. The only `transferFrom` the router issues is in `unlockCallback`: `Currency.unwrap(cIn).transferFrom(d.user, address(poolManager), owed)`, and `d.user` is always `msg.sender` of `sell`/`buy`/`launch` because the router builds `SwapData` itself in `_swap` and the PoolManager only calls `unlockCallback` on the contract that called `unlock`, passing that contract's own bytes back. `unlockCallback` checks `msg.sender == poolManager`; the router has no fallback or receive and makes no calls to attacker-supplied contracts, so there is no confused-deputy path; `launch`/`launchFor` and `buy` never pull a PadToken. The pulled currency is `cIn` from `pad.poolKey(token)` (reverts UnknownToken for anything not launched), so a crafted key cannot redirect it. The v1 ETH router is a separate contract, is not the exempt `router`, and needs a normal approval. Conclusion: the exemption lets the v1 router spend only the caller's own tokens, only inside the caller's own sell; nobody can move another holder's balance and selling is never blocked, so GoPlus's `owner_change_balance` / `is_honeypot` classification matches the pattern, not the behaviour. v2 removed the exemption (PadToken.sol:155-157), which is the right call for scanner compatibility. Merged from audit_economics 2e0ad245 and audit_permissions d2227b6e.","line":105,"path":"contracts/src/v1/PadTokenV1.sol","reproduction":"PadTokenV1 deployed with router = R: carol.transferFrom(bob, carol, 1) reverts InsufficientAllowance; only msg.sender == R skips the check (test_v1Exemption in test/scratch/JudgeChecks.t.sol). Against the a549093 router: carol calls router.sell(token, bobBalance, 0, deadline) -> transferFrom(carol, poolManager, bobBalance) -> InsufficientBalance, bob unchanged; carol calls router.unlockCallback(abi.encode(SwapData{user: bob,...})) directly -> NotPoolManager; carol calls poolManager.unlock(...) -> the PoolManager calls carol's own unlockCallback, not the router's. No other external function on the router reaches transferFrom.","severity":"info","snippet":"        if (msg.sender != router) {","title":"v1 router allowance exemption confirmed safe: no path lets the v1 router move tokens from anyone but its own msg.sender (GoPlus honeypot flag is a false positive)"}],"hash":"78c90a43bb866b9041081865ac7bbee72d7b65c14bb46fc8fbef02c14730b2e1","nodeId":"7e264b34-c7ee-45c8-bf72-ca71331f7cd5","outcome":"completed","summary":"Review complete. The findings are in `.imd-findings.json` at the repository root. Only that file and `contracts/test/scratch/` were added; no source or config files changed.\n\n**What I did.** I rebuilt the project (31 unit tests pass), ran all four specialist proofs from `test/scratch/` (all five tests fail on the current code for the stated reasons), and wrote my own scratch test to reproduce the remaining claims: the exact-out partial-fill panic, the start-tick brick on both quotes and orientations, the permit value mismatch, the tx.origin attribution, the hookless second pool, and the v1 allowance exemption. I also read the v1 router at commit a549093 to confirm the exemption analysis, and checked that the web page escapes creator metadata.\n\n**Result: 15 specialist findings merged into 7, none dropped as non-reproducing.**\n\n| Severity | Finding | Proof |\n|---|---|---|\n| medium | Quote-specified swaps pay 4% of the requested amount, so a partial fill at a price limit pays up to ~100% of what traded, or reverts with Panic 0x11 (merged from 4 specialists) | yes |\n| low | Zero-fill swap on a quote-empty pool moves the price to the curve end for free; marketCap reports 0 (merged from 2) | yes |\n| low | setStartTick accepts ticks below about -349,200 that brick every launch on that quote (merged from 4) | yes |\n| low | sellWithPermit only accepts a permit signed for exactly tokenAmount, contradicting the NatSpec | no |\n| info | Trade event attributes third-party swaps to tx.origin | no |\n| info | A second, hookless pool trades fee-free (design boundary to document) | no |\n| info | v1 router exemption confirmed safe; GoPlus flag is a false positive (merged from 2) | no |\n\n**Judgment calls worth knowing.** I rated the partial-fill issue medium rather than low because it directly breaks the stated 4% invariant with real user loss, and a front-runner can force it on anyone whose router uses a price limit as slippage protection. The Panic at line 350 is the only thing stopping an exact-out seller's quote delta from going negative, so the description warns that the fix must not simply make that subtraction saturating. The start-tick finding is owner-only and reversible, and I say so, but kept it at low because the brief explicitly asked whether an extreme tick can brick launches and describes the bound as protective.\n\n**Questions in the brief that produced no finding.** Calling flush or collectProtocolFees inside a foreign unlock cannot drain the PoolManager: the burn and take net to zero for the launchpad and the claims are always backed by real currency. The FEE_SLOT handoff is safe because nothing can run between beforeSwap and afterSwap of the same swap. The routers only spend the caller's funds, and the holder-reward arithmetic cannot overflow at any reachable balance.","treeHash":null,"usage":{"cachedInputTokens":1144130,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":40184,"runtime":"claude","turns":30,"wallClockMs":562867}}],"verification":[]}