{"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":"46a0c47b-d3ad-4c50-973e-369203a488ce","kind":"audit","nodes":[{"acceptedSubmissionHash":"2c59f2edb919ee29db889e1cb03c23bec5951da4ca4d9ddee67ec624e4089f54","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":"cf46254c9800cd72d467e153272e253f0c35f213529fc39f6861e530fb08966d","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":"b6eebc8c6ac6367a6b3ba803aeac1c8713a1772a85d4126ab47ede177f21c6fd","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":"c03ca6e6f0afa28082b5f9b1e827c686460b2d35f6e978bcf294a398188d4e89","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":"7ce0aa559082e89fc219f7014c7619df1467487ebf7051e216deb60ce5fa6c00","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":"Audit the Pepes token (0xE2C46c7068566740A33A4C93f5445B07BCfE5644, Robinhood Chain), an instance of contracts/src/PadToken.sol launched by the PepesFamily v1 launchpad (0x2d7689E48Fd71D9A0f225C673D7b8F8A693368CC). This branch is the v1 code exactly as deployed and verified.\n\nWhat it is: a fixed-supply (1B) token. The whole supply is locked as Uniswap v4 liquidity that can't be removed, and the launchpad's v4 hook takes 4% of every swap (1% protocol, 3% to holders). No mint, no owner, no admin functions.\n\nGoPlus/GMGN show three warnings. For each, tell us if it's a real risk or a false positive:\n\nPossible honeypot: can anyone block selling?\nOwner can change balance: can any privileged address change or move holders' balances?\nHas suspicious function: does any non-standard function give someone special power?\nLook hardest at:\n(1) PadToken.transferFrom skipping the allowance when msg.sender is the immutable v1 router. We think this triggers warnings 1 and 2. Confirm every router path (buy, sell, launch, unlockCallback) can only pull from its own caller.\n(2) Every non-standard token function (claim, distribute, isExcluded, the reward getters, receive). Confirm none is privileged, and identify which one scanners likely flag.\n(3) Whether any owner action or third-party call can make sells or claims revert.\n(4) Reward accounting never paying out more than was distributed.\n\nFull list of functions and details are in AUDIT.md.\n\nWhat's new in the branch's AUDIT.md:\n\nAll three warnings listed, each with a request for a \"real risk or false positive\" verdict.\nEvery non-standard function the token has, grouped and explained, with a request to confirm none is privileged and to say which one scanners likely flag:\nHolder rewards: claim, distribute and the read-only reward getters.\nInfo: isExcluded, metadata, pad, router, poolManager, quote, creator.\nreceive().\nA note that we believe one pattern triggers both the balance warning and the honeypot warning: the router's approval shortcut.\nThe branch is now at commit 8c1869c. If the audit tool reads it at check time, it picks up this version automatically.","parentJobId":null,"planHash":"8b1cd9c8b0572a98346e7546b03fc69d36f5ba44bb408118b798e6cebec4102f","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"46a0c47b-d3ad-4c50-973e-369203a488ce","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":"51432","feedbackHash":"aea83d6a2c86b6d00f6f9085821d3f9073ac844c6ef928a17798e289c5af5181","nodeKey":"audit_economics","submissionHash":"2c59f2edb919ee29db889e1cb03c23bec5951da4ca4d9ddee67ec624e4089f54","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51018","feedbackHash":"58b5b78bb596b28d1893d2157be7c26a9d4e1d73ec275c997a9191a64d9c8106","nodeKey":"audit_flow","submissionHash":"cf46254c9800cd72d467e153272e253f0c35f213529fc39f6861e530fb08966d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50939","feedbackHash":"c287345620b8c85c968253721b0511d8a4b8197427ed08effd737e8a96558a41","nodeKey":"audit_judge","submissionHash":"b6eebc8c6ac6367a6b3ba803aeac1c8713a1772a85d4126ab47ede177f21c6fd","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50959","feedbackHash":"3a97a7061b080b30074102aaf012a81b0b8a4366f18fe99cacd0c2e79bf903c0","nodeKey":"audit_math","submissionHash":"c03ca6e6f0afa28082b5f9b1e827c686460b2d35f6e978bcf294a398188d4e89","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50955","feedbackHash":"3c6128f343becd910718482d4e854bd4b8e23ce8410c95cf81c798d280067f27","nodeKey":"audit_permissions","submissionHash":"7ce0aa559082e89fc219f7014c7619df1467487ebf7051e216deb60ce5fa6c00","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"6e804d7f32577ab4d396a7199861b3eaec222231399e8e484828c30096bce310","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"03f15d1296244279","findings":[{"citation":"resolved","description":"When the quote currency is the swap's specified currency (exact-in buy, exact-out sell) the fee is computed in beforeSwap from params.amountSpecified and immediately locked in by _chargeFee (claims minted, pendingProtocolFees/pendingHolderFees incremented) and by the BeforeSwapDelta. Uniswap v4 does not revert a swap that stops at sqrtPriceLimitX96 or runs out of liquidity; it partially fills. The Pepes pools are single-sided, so below the launch price there is no liquidity and an exact-output sell that asks for more quote than the pool holds, or that hits the seller's price limit, delivers less than requested. afterSwap reads the fee back from the transient slot without comparing it to the delivered amount, so the seller pays 400/9600 of the REQUESTED gross while receiving only the delivered gross minus that fee. The overcharge accrues to holders (3/4) and the protocol (1/4). This only affects exact-output swaps from third-party routers (PepesFamilyRouter and PepesFamilyEthRouter only do exact-input), and the seller chooses the parameters, so it is a pricing defect rather than an attack vector. It is also the only path found where the hook's realised fee rate differs from the documented 4%. Minimal fix that preserves the design: in afterSwap, for the quote-specified branch, compute the delivered gross (poolQuote) and revert (e.g. PartialFill()) when poolQuote != amountToSwap, i.e. when the pool delivered less than requested + fee; alternatively, document that exact-output swaps must be sent with a minimum-output check (Universal Router TAKE_ALL minAmount) so partial fills revert.","line":294,"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 {FullMath} from \"v4-core/src/libraries/FullMath.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\n/// @notice An exact-output sell through a third-party router (Universal Router / PoolSwapTest) that asks for more\n///         quote than the pool can deliver is only partially filled, but `PepesFamily.beforeSwap` has already\n///         charged 4% of the REQUESTED gross. The seller is charged a fee on quote they never receive.\ncontract ExactOutPartialFillFeeTest is Test {\n    address constant FEE_RECIPIENT = 0x3c8A4d94B3219F6633F2cC94094f4765b30c691C;\n    uint256 constant SUPPLY = 1_000_000_000e18;\n\n    PoolManager pm;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PoolSwapTest extRouter;\n    PoolSwapTest.TestSettings settings = PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n    address owner = makeAddr(\"owner\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        extRouter = new PoolSwapTest(pm);\n\n        int24 ethTick = _startTickForMarketCap(1.5 ether);\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(pm, address(0xBEEF), owner, FEE_RECIPIENT, ethTick, ethTick)\n        );\n        uint160 flags = uint160((1 << 13) | (1 << 11) | (1 << 7) | (1 << 6) | (1 << 3) | (1 << 2));\n        (bytes32 salt,) = _mineSalt(address(this), flags, initCode);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        require(deployed != address(0), \"deploy\");\n        pad = PepesFamily(deployed);\n        router = PepesFamilyRouter(payable(pad.router()));\n\n        vm.deal(alice, 100 ether);\n        vm.deal(bob, 100 ether);\n        vm.deal(carol, 100 ether);\n    }\n\n    function test_exactOutSell_partialFill_chargesFeeOnUndeliveredQuote() public {\n        vm.prank(alice);\n        PadToken t = PadToken(payable(pad.launch(\"Test\", \"TST\", \"\", address(0))));\n\n        // bob buys 1 ETH: the pool now holds 0.96 ETH (0.04 ETH went to the hook).\n        vm.prank(bob);\n        router.buy{value: 1 ether}(address(t), 1 ether, 0, block.timestamp);\n        // carol buys a little and hands bob her tokens so bob can drain the whole pool despite rounding.\n        vm.prank(carol);\n        router.buy{value: 0.001 ether}(address(t), 0.001 ether, 0, block.timestamp);\n        uint256 carolBal = t.balanceOf(carol);\n        vm.prank(carol);\n        t.transfer(bob, carolBal);\n\n        // Real ETH backing the pool: PoolManager balance minus the protocol-fee claims still held there\n        // (the holder fees were already flushed to the token contract by the router).\n        uint256 poolQuote = address(pm).balance - pad.pendingProtocolFees(address(0));\n        assertApproxEqAbs(poolQuote, 0.96096 ether, 1e9);\n\n        PoolKey memory key = pad.poolKey(address(t));\n        (,,,, bool quoteIs0) = pad.launches(address(t));\n        bool zeroForOne = !quoteIs0; // selling the token\n        vm.prank(bob);\n        t.approve(address(extRouter), type(uint256).max);\n\n        uint256 protoBefore = pad.pendingProtocolFees(address(0));\n        uint256 holderBefore = pad.pendingHolderFees(address(t));\n        uint256 bobEthBefore = bob.balance;\n\n        // bob asks for 2 ETH exact-out; only ~0.961 ETH exists in the pool, so the swap partially fills.\n        // The price limit stops 200 ticks (~2%) short of the launch price so the fill is partial but settleable.\n        int24 startTick = pad.startTick(address(0));\n        uint160 limit = TickMath.getSqrtPriceAtTick(quoteIs0 ? startTick - 200 : -startTick + 200);\n        vm.prank(bob);\n        (bool ok,) = address(extRouter).call(\n            abi.encodeWithSelector(\n                PoolSwapTest.swap.selector,\n                key,\n                SwapParams({zeroForOne: zeroForOne, amountSpecified: int256(2 ether), sqrtPriceLimitX96: limit}),\n                settings,\n                \"\"\n            )\n        );\n        if (!ok) return; // a hook that refuses partial fills is an acceptable fix\n\n        uint256 received = bob.balance - bobEthBefore;\n        uint256 fee = (pad.pendingProtocolFees(address(0)) - protoBefore) + (pad.pendingHolderFees(address(t)) - holderBefore);\n        uint256 gross = received + fee; // what the pool actually paid out\n\n        // Expected: 4% of the gross the pool actually paid (~0.0384 ETH).\n        // Actual on this code: 2 ether * 400 / 9600 = 0.08333 ETH, i.e. ~8.7% of the real gross.\n        assertLe(fee, (gross * 4) / 100 + 2, \"fee exceeds 4% of the quote the pool actually paid\");\n    }\n\n    // ------------------------------------------------------------ helpers (inlined from script/DeployLib.sol)\n\n    function _startTickForMarketCap(uint256 marketCap) internal pure returns (int24 tick) {\n        uint256 sqrtPriceX96 = _sqrt(FullMath.mulDiv(SUPPLY, 1 << 192, marketCap));\n        tick = TickMath.getTickAtSqrtPrice(uint160(sqrtPriceX96));\n        int24 rem = tick % 200;\n        tick -= rem < 0 ? rem + 200 : rem;\n    }\n\n    function _mineSalt(address deployer, uint160 flags, bytes memory initCode)\n        internal\n        pure\n        returns (bytes32 salt, address hook)\n    {\n        bytes32 h = keccak256(initCode);\n        for (uint256 i = 0; i < 500_000; i++) {\n            hook = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), deployer, bytes32(i), h)))));\n            if (uint160(hook) & 0x3FFF == flags) return (bytes32(i), hook);\n        }\n        revert(\"no salt\");\n    }\n\n    function _sqrt(uint256 x) internal pure returns (uint256 z) {\n        if (x == 0) return 0;\n        z = x;\n        uint256 y = (x >> 1) + 1;\n        while (y < z) {\n            z = y;\n            y = (x / y + y) >> 1;\n        }\n    }\n}","reproduction":"Setup (ETH-quoted launch, same as test/PepesFamily.t.sol): alice launches; bob buys 1 ETH via PepesFamilyRouter (pool receives 0.96 ETH); carol buys 0.001 ETH and transfers her tokens to bob. Bob then swaps through a third-party router (PoolSwapTest, as Universal Router would) with amountSpecified = +2 ether (exact-out sell of the token) and a price limit 200 ticks short of the launch price. Expected: fee = 4% of the quote the pool actually paid, i.e. ~0.0378 ETH on a 0.9456 ETH gross, seller receives ~0.9078 ETH. Actual (from the trace): beforeSwap mints claims for 83333333333333333 wei (2 ETH * 400 / 9600), the pool pays out 945599185085750639 wei, the Trade event records quoteAmount 862265851752417306 to the seller: the hook took 8.8% of the real gross and the seller lost 0.0455 ETH more than the documented fee. The same happens with the default MIN/MAX price limit whenever the request exceeds the pool's quote reserve (trace showed fee 0.0833 ETH on a 0.961 ETH payout; that run only failed at token settlement because draining the pool to the last wei needs 2 wei more tokens than exist outside it). Run: cd contracts && forge test --match-path test/scratch/ExactOutPartialFillFee.t.sol","severity":"low","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Exact-output sells charge the 4% fee on the requested quote amount, not on what the pool actually pays, so a partially filled sell is overcharged"},{"citation":"resolved","description":"GoPlus-style scanners flag transferFrom because one hard-coded address (router, immutable, set at construction to PepesFamily.router which PepesFamily deploys in its own constructor) can move any holder's balance without allowance. Traced every path that reaches PadToken.transferFrom with msg.sender == router: the router calls SafeTransfer.transferFrom exactly once, in PepesFamilyRouter.unlockCallback (line 142), with from = d.user. d is decoded from the bytes the PoolManager passes back to unlock's caller, and the router only ever calls poolManager.unlock from _swap (line 112) with user = msg.sender of buy/sell/launch. The PoolManager will not call router.unlockCallback for anyone else (unlock reverts AlreadyUnlocked if nested, and the callback target is always the unlock caller), unlockCallback itself checks msg.sender == poolManager, and the router has no owner, no delegatecall, no arbitrary call and no other transferFrom site. The currency pulled is cIn, which for a sell is the token itself and for a buy is the quote (ETH via msg.value or IMD with a normal allowance). Crafted pool keys are impossible: the key comes from pad.poolKey(token), which reverts UnknownToken for unregistered tokens. The owner of PepesFamily (setFeeRecipient, setStartTick, transferOwnership) has no path into the token at all. Verdict: false positive. The design cost is that if PepesFamilyRouter ever had a bug it would be a universal spender; the router is 150 lines, immutable and non-upgradeable, so this is a trust assumption on code that is fully in scope here, not a privileged actor.","line":105,"path":"contracts/src/PadToken.sol","reproduction":"State: bob holds tokens, carol holds none. carol calls router.sell(token, bobBalance, 0, deadline): the router pulls from carol (d.user = carol) and PadToken reverts InsufficientBalance; bob's balance is unchanged. carol calls token.transferFrom(bob, carol, 1) directly: reverts InsufficientAllowance. carol calls router.unlockCallback(abi.encode(SwapData(bob, ...))) directly: reverts NotPoolManager. The owner has no function that reaches PadToken._transfer. Covered by test/PepesFamily.t.sol test_attack_cannotSellSomeoneElsesTokens and test_attack_cannotCallHookOrCallbacksDirectly, and re-checked in test/scratch/Probe.t.sol test_routerBypassUnreachableByThirdParty (passes).","severity":"info","snippet":"        if (msg.sender != router) {","title":"Scanner warning 'owner can change balance' / honeypot: the router's allowance bypass is a false positive; the only privileged spender can pull only from its own caller"},{"citation":"resolved","description":"Checked every non-ERC-20 external function on the deployed token. claim(): pays only msg.sender's withdrawableDividendOf, after calling the immutable pad's flush; state (withdrawnDividends, accountedBalance) is updated before the payout and a mutex blocks re-entry; an excluded address gets 0. distribute(): permissionless, only moves already-received quote into the per-share accumulator; it cannot pay anyone and cannot be used to steal (a caller only accelerates a distribution that any trade would trigger). isExcluded: pure fixed list, no setter. pad/router/poolManager/quote/creator are immutable; name/symbol/metadata are set once in the constructor. receive(): accepts ETH only when quote is ETH; for Pepes (IMD quote) it reverts, so ETH cannot even be donated. Reward getters are views. There is no mint, burn, pause, blacklist, fee setter, owner or proxy. The function a heuristic scanner most plausibly marks 'suspicious' is transferFrom (a branch on a hard-coded msg.sender that skips allowance), followed by claim (external call to another contract plus a value transfer out of the token contract, which pattern-matches 'hidden withdraw'). Verdict: false positive; no non-standard function is privileged.","line":176,"path":"contracts/src/PadToken.sol","reproduction":"For each function: call as an arbitrary address X with the deployed Pepes state (eligibleSupply 4.72e26, accountedBalance 67.5 IMD == IMD.balanceOf(token)). claim() by X with no tokens returns 0 and moves nothing; claim() by a holder pays exactly withdrawableDividendOf(holder) and reduces accountedBalance by the same amount; distribute() by X returns 0 unless the token's IMD balance exceeds accountedBalance, in which case the surplus is credited pro rata to all eligible holders (X gains only their own share); isExcluded has no write path; receive() with 1 wei reverts EthNotAccepted. Expected == actual in every case.","severity":"info","snippet":"        IPadFlush(pad).flush(address(this));","title":"Scanner warning 'has suspicious function': claim()/distribute() and the allowance-free transferFrom are what gets flagged; none grants anyone power over other holders"},{"citation":"resolved","description":"Enumerated what a sell depends on. Through any router the swap needs only PoolManager.swap plus the hook: beforeSwap returns a zero delta for exact-in sells, afterSwap charges 4% of the actual quote output via poolManager.mint (always possible while unlocked) and a checked int128 cast (reverts only above 1.7e38 wei). No owner function touches these: setFeeRecipient only affects collectProtocolFees, which is not on the sell path; setStartTick only affects future launches; fee constants are immutable; there is no pause and the hook rejects outside pools/liquidity. PadToken._transfer can revert with Overflow only if magnifiedDividendPerShare * amount > 2^255, which needs roughly 1.7e11 ETH/IMD of cumulative distributions per eligible token; IMD's live total supply is 3.675e22 wei (36,751 IMD), so this is unreachable. distribute() cannot revert on ETH-quoted tokens; amount * 2^128 overflows only for amount >= 2^128 wei, again unreachable for IMD. Sells and buys through PepesFamilyRouter/PepesFamilyEthRouter additionally call pad.flush, which burns claims and calls PoolManager.take(IMD, token, amount) then token.distribute() (IMD.balanceOf). The pad's ERC-6909 claim balance always equals pendingProtocolFees + sum(pendingHolderFees) because mint and burn amounts match exactly and nobody else can burn the pad's claims, so burn cannot fail. Therefore the only external condition that blocks router sells and claim() for IMD-quoted tokens is IMD.transfer or IMD.balanceOf reverting. The deployed IMD bytecode has no pause/paused/blacklist/freeze/mint/burn selectors (checked 0x8456cb59, 0x5c975abb, 0xfe575a87, 0xf9f92be4, 0x859840b6 and others; only standard ERC-20 + LayerZero OFT selectors are present), and even if it did, sells through a third-party router would still work because the hook never flushes. Verdict: false positive. Trust assumption to document: IMD is an owner-configured OFT; a future IMD upgrade is outside these contracts.","line":148,"path":"contracts/src/PepesFamilyRouter.sol","reproduction":"State at the live deployment: Pepes eligibleSupply 472,495,612 tokens, accountedBalance 67.505 IMD equal to IMD.balanceOf(token). A holder calling router.sell(token, amount, 0, deadline) executes swap -> afterSwap (fee minted) -> transferFrom(holder -> PoolManager) -> settle -> flush -> take(IMD -> holder) and succeeds for any amount <= balance that yields nonzero output. Owner calls setFeeRecipient(revertingContract) and setStartTick(IMD, x): subsequent router.sell still succeeds (collectProtocolFees is not on the path). A third party sends IMD to the token or to the pad, or calls flush/collectProtocolFees mid-unlock: sells still succeed (pad deltas net to zero; distribute only adds). Expected: sell succeeds; actual: sell succeeds. Covered by test_everyoneCanExit, test_attack_distributeNeverBlocksTradesOrClaims and the 256-run fuzz in test/scratch/Probe.t.sol testFuzz_rewardSolvency, where random router/external buys, sells, transfers, claims and flushes never revert and always keep sum(withdrawable) <= accountedBalance <= balance and eligibleSupply == sum of non-excluded balances.","severity":"info","snippet":"        pad.flush(d.token);","title":"Scanner warning 'possible honeypot': no owner or third party can block sells; the only revert path for router sells and claims is the quote asset (IMD) itself reverting"},{"citation":"resolved","description":"The router flushes before the buyer receives tokens so that the buyer does not share in their own fee. For the first buy of a token eligibleSupply is 0 at flush time, so distribute() returns 0 and the 3% waits in the token contract; the buyer then receives tokens and is the only eligible holder, so the very next flush (triggered by anyone's trade, or by the buyer calling claim()) credits the entire waiting amount to them. The effective fee on the first buy is therefore 1%, not 4%. The same applies whenever all holders have exited and the next buyer is alone. Nobody is harmed (there are no other holders to dilute), so this is an economic edge case and a documentation gap, not a defect to fix at severity; if the intent is strict, the waiting balance could be marked as 'carry-over' and only distributed once eligibleSupply contains at least one other address, but that is a design change. The rounding dust left by floor division in distribute() and accumulativeDividendOf stays permanently in accountedBalance; it is below 1 wei per holder per distribution.","line":152,"path":"contracts/src/PadToken.sol","reproduction":"alice calls router.launch{value: 1 ether}(\"A\",\"A\",\"\", address(0), 1 ether, 1). Expected per README: alice earns nothing from her own trade. Actual: token.balance == 0.03 ether with withdrawableDividendOf(alice) == 0; after bob buys 0.1 ETH, withdrawableDividendOf(alice) == 0.033 ether (32999999999999999 wei): her own 0.03 plus 3% of bob's buy. Shown by test/scratch/Probe.t.sol test_firstBuyerRecoversOwnHolderFee (passes, logs the value).","severity":"info","snippet":"        if (bal <= accountedBalance || eligible < MIN_ELIGIBLE_SUPPLY) return 0;","title":"First buyer of a launch (normally the creator via launch(initialBuy)) gets their own 3% holder fee back at the next flush, contrary to the 'a trader never earns from their own trade' statement"}],"hash":"2c59f2edb919ee29db889e1cb03c23bec5951da4ca4d9ddee67ec624e4089f54","nodeId":"447071dd-4224-48c2-900c-ca34529f5f49","outcome":"completed","summary":"The review is complete. The findings file is written at the repository root with five entries, one low-severity defect with a failing Foundry proof and four info-level verdicts the brief asked for.\n\n**Verdicts on the three scanner warnings**\n\n- **Owner can change balance: false positive.** The only address that skips the allowance in `transferFrom` is the immutable router, and the router calls `transferFrom` from exactly one place with `from` set to its own caller. I traced buy, sell, launch and the unlock callback. The PoolManager only calls back the contract that opened the unlock, nested unlocks revert, and crafted pool keys revert with `UnknownToken`. The launchpad owner has no path into the token at all.\n- **Possible honeypot: false positive.** No owner function sits on the sell path. Changing the fee recipient to a reverting contract only breaks protocol-fee collection, not sells. The `Overflow` guard in the token's transfer needs roughly 170 billion IMD of cumulative distributions per token to trigger, and IMD's live supply is 36,751. The only external condition that could stop router sells and claims is IMD itself reverting. I scanned the deployed IMD bytecode and found no pause, blacklist, freeze, mint or burn selectors, and sells through third-party routers would still work regardless because the hook never flushes.\n- **Has suspicious function: false positive.** Every non-standard function is unprivileged. Scanners most plausibly flag `transferFrom` (a branch on a hard-coded sender) and `claim` (an external call plus a value transfer out of the token).\n\n**Reward accounting** holds. A 256-run fuzz over random router buys, external buys, sells, transfers, claims and flushes never broke the three invariants: total withdrawable never exceeds the accounted balance, the accounted balance never exceeds the real balance, and eligible supply always equals the sum of non-excluded balances. The live Pepes contract is consistent, with its accounted balance exactly equal to its IMD balance.\n\n**The one defect found (low).** For exact-output sells through third-party routers, the hook computes the 4% fee on the requested amount in `beforeSwap` and never reconciles it with what the pool actually delivered. Because the pools are single-sided, a request larger than the pool's quote reserve, or one that hits the seller's price limit, partially fills. In my test the pool paid 0.9456 ETH, the hook kept 0.0833 ETH (8.8% of the real gross), and the seller lost about 0.0455 ETH beyond the documented fee. It is seller-controlled and Universal Router's minimum-output check would normally revert it, so it is a pricing defect rather than an attack vector. The proof test fails on the current code and accepts either a fairer fee or a revert on partial fills as the fix.\n\n**One economic edge (info).** The first buyer of a launch, normally the creator using the initial-buy option, gets their own 3% holder fee back at the next flush because nobody was eligible when it was flushed. Nobody else is harmed, but it contradicts the README's statement that a trader never earns from their own trade.\n\nScope limits: this ran the Economic Security, Invariant and Flow Gap passes plus access-control enumeration over all four contracts. The fork tests were not run, and the Uniswap v4 PoolManager and IMD were treated as out-of-scope dependencies, verified only by on-chain reads.","treeHash":null,"usage":{"cachedInputTokens":2540543,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":63984,"runtime":"claude","turns":45,"wallClockMs":1110385}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3c7630b22a73c1fb","findings":[{"citation":"resolved","description":"distribute() spreads whatever quote has arrived since the last call over eligibleSupply as it stands at that instant, and it can be reached while the Uniswap v4 PoolManager is unlocked by an untrusted party: directly (it is public), via PepesFamily.flush() (contracts/src/PepesFamily.sol:354 runs _flush() inline for any caller when poolManager.isUnlocked()), or via PadToken.claim() (line 176 calls pad.flush). The PoolManager is on the exclusion list, but v4 flash accounting lets any unlocker call poolManager.take(token, self, X) for the pool's whole token balance and return it before the unlock ends. The take is a PoolManager -> attacker ERC20 transfer, so _transfer() adds X to eligibleSupply and the attacker becomes a 'holder' of up to ~99% of the supply for the duration of one call, with no swap, no 4% fee and no capital. Calling claim() in that window flushes the pending holder fees (and any quote already sitting in the token contract waiting for eligibleSupply >= MIN_ELIGIBLE_SUPPLY), distributes them mostly to the attacker, and pays them out in IMD. The tokens are then transferred back and settle() closes the unlock. Who loses: every real holder, for every swap made through a router other than PepesFamilyRouter/PepesFamilyEthRouter (Universal Router, aggregators, the Uniswap app), since those leave the 3% holder share pending until the next flush and a bot can back-run each one. The same trick also lets any trader using their own router recover essentially their entire 3% holder fee inside their own buy transaction (effective fee ~1% instead of 4%), which contradicts the README guarantee that 'a trader never earns from their own trade' and the documented cost of dividend sniping ('it costs 4% in and 4% out'). This is a trust gap between access (flush/distribute/claim are permissionless and allowed mid-unlock), economics (distribution is a balance snapshot) and asymmetry (PoolManager balance is excluded, but PoolManager balance can be flash-taken by anyone). It is not an owner power and it cannot block sells or move anyone's tokens; it only redirects holder rewards. Fix direction (author's call on the exact shape): refuse to distribute while the PoolManager is unlocked by anyone other than the trusted router(s), e.g. PepesFamily.flush() only runs _flush() inline when msg.sender == router (otherwise leave the fees pending), and PadToken.distribute() returns 0 when msg.sender != pad && poolManager.isUnlocked(); the pending fees are then flushed by the next router trade or by a claim()/flush() made outside an unlock. The attached test passes with exactly that patch. A snapshot/time-weighted distribution would also work but changes the economics more.","line":148,"path":"contracts/src/PadToken.sol","reproduction":"State: a launched IMD-paired PadToken; bob bought 10 IMD through PepesFamilyRouter (only real holder); carol then bought 100 IMD through a third-party v4 router (PoolSwapTest stand-in for Universal Router), leaving pendingHolderFees[token] == 3e18 and 0.3e18 IMD already waiting in the token contract. Attacker contract holds 0 tokens and 0 IMD. Input: attacker calls poolManager.unlock(); in unlockCallback it (1) poolManager.take(token, attacker, token.balanceOf(poolManager)) (2) token.claim() (3) poolManager.sync(token); token.transfer(poolManager, same); poolManager.settle(). Expected: attacker receives 0 IMD, bob+carol later claim 3.3e18 between them. Actual (forge run of the attached test, 100 IMD start cap so the pool held ~54% of supply): attacker receives 1,620,643,338,137,512,075 wei IMD (~1.62 of 3.3 IMD) and keeps 0 tokens; on the live Pepes token at review time (cast call, Robinhood Chain) the PoolManager held 527,504,387,937,404,483,192,800,599 wei of the 1e27 supply against eligibleSupply 472,495,612,062,595,515,807,199,098, so a flash-take captures ~53% of each pending fee; pad.pendingHolderFees(Pepes) was 3,684,312,982,292,562,907 wei (3.68 IMD) unflushed at that moment, i.e. ~1.95 IMD capturable in one call today. Second input: attacker buys 100 IMD in its own unlock, takes its tokens, flash-takes the pool balance, claims, returns the flash tokens. Expected: 0 IMD back in that tx. Actual: 3,015,894,911,396,891,595 wei IMD (its whole 3 IMD holder fee plus bob's waiting 0.3) comes straight back. Run: cd contracts && forge test --match-path test/scratch/FlashDividendSnipe.t.sol","severity":"high","snippet":"    function distribute() public returns (uint256 amount) {\n        uint256 bal = quote.balanceOf(address(this));\n        uint256 eligible = eligibleSupply;\n        // Never revert: trades and claims call this, so an odd quote balance must not block them.\n        if (bal <= accountedBalance || eligible < MIN_ELIGIBLE_SUPPLY) return 0;\n        amount = bal - accountedBalance;\n        magnifiedDividendPerShare += (amount * MAGNITUDE) / eligible;","title":"Flash-borrowed pool tokens let anyone capture pending holder rewards at zero cost"},{"citation":"resolved","description":"beforeSwap charges the exact-out sell fee on params.amountSpecified (what the seller asked for) and returns it as a hook delta on the specified currency, while afterSwap (line 326) charges exact-in sells on the delta the pool actually produced. The two branches are only equivalent when the swap fills completely. If the swap stops early at the seller's sqrtPriceLimitX96 (their own slippage bound, or because another trade moved the price toward it first), the pool delivers D < amountSpecified + fee but the hook still takes fee = amountSpecified*400/9600. The seller receives D - fee: the effective fee is fee/D instead of 4%, and the excess goes to holders and the protocol. If D < fee the seller's quote delta becomes negative, and before that can even settle the Trade event arithmetic `poolQuote - fee` at line 334 underflows (panic 0x11), so the whole swap reverts with HookCallFailed. Exact-in buys have the same shape (fee on the requested input) but the buy side of the curve cannot run out in practice, so only exact-out sells are affected. PepesFamilyRouter never uses exact-out, so this reaches users of third-party routers (Universal Router exact-output sells with a price limit). Fix: in beforeSwap charge nothing for exact-out and compute the exact-out sell fee in afterSwap from the delivered quote amount (fee = delivered*400/9600 taken from the unspecified side is not possible since quote is the specified side; alternatively charge the exact-out fee in afterSwap as a specified-currency delta computed from the actual delta), and guard the event math so poolQuote - fee cannot underflow.","line":294,"path":"contracts/src/PepesFamily.sol","reproduction":"State: launched IMD-paired token, bob bought 100 IMD via the router (pool holds 96 IMD net). Input: bob sells exact-out through PoolSwapTest with amountSpecified = +50e18 (50 IMD out) and sqrtPriceLimitX96 set 5% from the current sqrt price (a normal slippage bound). Expected: 4% of what the pool paid out (0.377 IMD on 9.42 IMD) and 9.05 IMD to bob. Actual (forge log): pool paid out 9,424,192,115,430,418,159 wei, fee charged 2,083,333,333,333,333,333 wei (22.1% of the payout), bob received 7,340,858,782,097,084,826 wei. With the limit at 0.05% instead, the delivered amount is below the 2.083 IMD fee and the swap reverts with WrappedError(hook, afterSwap selector, Panic(0x11), HookCallFailed) from `poolQuote - fee` at PepesFamily.sol:334.","severity":"low","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Exact-out sell fee is computed on the requested amount, so a partial fill overcharges or reverts"},{"citation":"resolved","description":"Owner-only, so a trust assumption rather than an exploit: the brief and README describe setStartTick as 'sets the starting market cap for future launches', but the accepted range also includes values at which no launch can succeed. With |tick| = limit = maxUsableTick(200) - 200 the launch range in _addLaunchLiquidity is a single 200-tick band at the end of the curve; sqrtU - sqrtL is tiny, so the computed liquidity (~1e48) exceeds int128 and poolManager.modifyLiquidity reverts via SafeCast. Every launch() / launchFor() for that quote reverts until the owner sets a sane tick again. No effect on already-launched tokens such as Pepes, their pools, holders or rewards. If the owner role is meant to be unable to halt launches, bound the tick to a range that yields liquidity <= type(int128).max (or a sane market-cap window).","line":435,"path":"contracts/src/PepesFamily.sol","reproduction":"Input: owner calls setStartTick(IMD, -887000) (passes: multiple of 200 and |tick| <= 887000). Then anyone calls launch(\"X\",\"X\",\"\",IMD). Expected per docs: a launch at an extreme starting market cap. Actual: revert inside poolManager.unlock -> unlockCallback -> _addLaunchLiquidity (liquidity does not fit int128). Verified in forge (test_startTickEdgeBricksLaunch in test/scratch/Checks.t.sol).","severity":"info","snippet":"        if (tick % TICK_SPACING != 0 || tick > limit || tick < -limit) revert BadTick();","title":"setStartTick accepts edge ticks that make every later launch for that quote revert"}],"hash":"7ce0aa559082e89fc219f7014c7619df1467487ebf7051e216deb60ce5fa6c00","nodeId":"dd59e032-ca40-4e0c-a9e3-786c10cd849d","outcome":"completed","summary":"Review complete. Findings are in `.imd-findings.json` at the repository root. No source files were changed. The only new files are my scratch tests under `contracts/test/scratch/`.\n\n**Verdicts on the three scanner warnings**\n\n- **Possible honeypot: false positive.** Nothing can block selling. The hook's `beforeSwap` and `afterSwap` only compute a fee and mint ERC-6909 claims. `distribute()` cannot revert, `_flush` only burns claims the launchpad provably holds, and the owner's three setters do not touch any sell path. The live IMD contract answers no pause or blocklist selectors. GoPlus most likely flags it because its simulator finds no v2 or v3 pair to sell into, and because of the router allowance bypass below.\n- **Owner can change balance: false positive.** The only non-standard transfer path is `transferFrom` skipping the allowance when the caller is the immutable router. The router calls `transferFrom` in exactly one place, with `from` set to the account that called `buy`, `sell` or `launch`. Its `unlockCallback` only accepts the PoolManager, and the PoolManager only calls it back when the router itself unlocked. No crafted key, token or quote changes that, since the key comes from the launchpad's own `poolKey`. The live token's `router`, `pad`, `poolManager` and `quote` immutables match the brief.\n- **Has suspicious function: false positive as to privilege, but it points at a real defect.** No function on the token is privileged. `claim`, `distribute`, the getters, `isExcluded` and `receive` all act on the caller or on fixed addresses. The function scanners flag is almost certainly `transferFrom` with its `msg.sender == router` branch, with `claim()` second because it makes an external call and sends the quote asset out.\n\n**Findings**\n\n1. **High. Flash-borrowed pool tokens let anyone capture pending holder rewards at zero cost.** `distribute()` snapshots `eligibleSupply` at call time and can run while an untrusted party holds the PoolManager unlock. Anyone can `take` the pool's whole token balance (flash accounting, no fee, no capital), call `claim()`, and give the tokens back. That captures the 3% holder share of every swap made through a third-party router, plus any quote waiting below the eligibility floor, and lets custom-router traders recover their own holder fee for an effective 1% fee. Live numbers: 53% of each pending fee is capturable today, and 3.68 IMD was pending unflushed at review time. The proof test fails on this code and passes under a minimal fix that only lets the trusted router flush mid-unlock.\n2. **Low. Exact-out sell fee is computed on the requested amount.** A partial fill at the seller's price limit charged 2.08 IMD on a 9.42 IMD payout, and a smaller fill underflows the `Trade` event math and reverts the swap. Only reachable through third-party exact-output sells.\n3. **Info. `setStartTick` accepts edge ticks that make every later launch revert.** Owner-only, no effect on Pepes or its holders.\n\n**Reward accounting** never pays out more than distributed: per-share increments round down, so the sum of all claimable amounts is bounded by `totalDividendsDistributed`, and overflow of the magnified math needs about 1.7e29 wei of IMD against a live supply near 3.7e22.\n\n**Coverage and limits.** All four source files were reviewed with the access-control, trust-gap, asymmetry and entry-point passes, with the economic, boundary and invariant passes applied to the fee and dividend flows. The project's 25 unit tests pass offline. The two fork suites skip without an RPC and were not run. No Slither.","treeHash":null,"usage":{"cachedInputTokens":2857802,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":73316,"runtime":"claude","turns":47,"wallClockMs":990840}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"72b617d4b615473a","findings":[{"citation":"resolved","description":"distribute() spreads newly arrived quote over eligibleSupply as it stands at that instant (PadToken.sol:150-154). The PoolManager is on the exclusion list (line 141), so the tokens locked in the pool are meant to earn nothing. But Uniswap v4 flash accounting lets any address that calls poolManager.unlock() call poolManager.take(token, self, X) for the PoolManager's whole token balance, provided it is returned before the unlock ends. That take is a PoolManager -> caller ERC-20 transfer, so _transfer() adds X to eligibleSupply (line 132) and the caller is a dividend-eligible \"holder\" of roughly half the supply for the length of one call, with no swap, no 4% fee and no capital. A distribution can be triggered in that window by anyone, three ways: PadToken.distribute() is public; PepesFamily.flush() runs _flush() inline for any caller when poolManager.isUnlocked() (PepesFamily.sol:354); PadToken.claim() calls pad.flush() first (PadToken.sol:176) and then pays the caller. The caller then transfers the tokens back, settles, and keeps the quote. Merged from the audit_permissions specialist finding (a851bb53); no proof was attached there, the one here is mine.\n\nWhat can be taken: (a) pendingHolderFees[token], i.e. the 3% holder share of every swap that did not go through PepesFamilyRouter/PepesFamilyEthRouter (Universal Router, aggregators, trading bots); AUDIT.md lists \"fees from trades through other routers reach holders at the next flush\" as known behaviour, and this is what makes that window exploitable; (b) any quote sitting in the token contract above accountedBalance (first-buy fee waiting for eligibleSupply >= 1 token, donations); (c) a trader's own fee: a trader who swaps inside its own unlock gets most of its own 3% holder fee straight back, so the effective fee is about 1.5% instead of 4%, against the README statement (line 22) that a trader does not earn from their own trade and against AUDIT.md's \"dividend sniping costs 4% in and 4% out\". Who loses: every real holder, pro rata. What is not affected: token balances, the ability to sell, already-accrued (accounted) rewards (the receiver's correction at line 131 cancels past accrual), and the protocol 1%. It is not an owner or privileged power; it is permissionless. Live state read with cast call at about Robinhood Chain block 77197460: the PoolManager held 481,186,191.98 Pepes against eligibleSupply 518,813,808.02, so a flash-take gets 48.1% of whatever is distributed, and pad.pendingHolderFees(Pepes) was 2.454178224066759594 IMD un-flushed, i.e. about 1.18 IMD capturable in a single call at that moment, recurring with every third-party-router trade. I did not run the attack against the chain or a fork; the reproduction is local.\n\nFix that keeps the design: do not let a distribution happen while the PoolManager is unlocked by an untrusted caller. For example, PepesFamily.flush() runs _flush() inline only when msg.sender is a trusted router and otherwise leaves the fees pending, and PadToken.distribute() returns 0 when msg.sender != pad and the PoolManager is unlocked. I applied exactly that as a temporary local patch: both proof tests and the 25 existing tests pass with it (sources restored afterwards). PepesFamilyEthRouter would need to be on the trusted list to keep its inline flush. The deployed v1 token and pad are immutable, so for Pepes itself the only mitigation is operational: keep pendingHolderFees at zero by calling pad.flush(token) outside an unlock promptly after third-party trades (anyone can), which leaves nothing to capture; that does not close variant (c).","line":148,"path":"contracts/src/PadToken.sol","reproduction":"Local PoolManager, IMD-quoted launch (start cap 100 IMD), run from contracts/: forge test --match-path test/scratch/FlashDividendSnipe.t.sol -vv. Test 1: bob buys 10 IMD twice through PepesFamilyRouter (only real holder, nothing waiting); carol buys 100 IMD through PoolSwapTest (stand-in for a third-party v4 router), so pad.pendingHolderFees(token) == 3e18. Attacker contract holds 0 tokens and 0 IMD. It calls poolManager.unlock(); in unlockCallback: poolManager.take(token, attacker, token.balanceOf(poolManager)); token.claim(); poolManager.sync(token); token.transfer(poolManager, same amount); poolManager.settle(). Expected: attacker ends with 0 IMD and bob+carol's withdrawable rises by 3e18. Actual: attacker ends with 1408165773704161705 wei IMD (1.408 of the 3 IMD, 47%) and still 0 tokens; the test fails with \"a non-holder with no capital captured holder rewards: 1408165773704161705 != 0\". Test 2: bob makes one 10 IMD router buy, so 0.3e18 IMD waits un-accounted in the token; the attacker does take -> token.distribute() -> return inside its unlock, then token.claim() afterwards. Expected 0; actual 274172264672444690 wei IMD (91% of the 0.3 IMD that belonged to bob). Variant (c), separate scratch test: a contract buys 100 IMD inside its own unlock, takes the PoolManager's whole token balance, calls claim(), returns the flash-borrowed part: it gets 2524391587445605893 wei IMD back in the same transaction (84% of its own 3 IMD holder fee); bob, the only prior holder, receives 475608412554394107 wei instead of 3e18.","severity":"high","snippet":"    function distribute() public returns (uint256 amount) {","title":"Anyone can flash-take the pool's tokens inside a PoolManager unlock and collect holder rewards without holding or paying anything"},{"citation":"resolved","description":"Merged from three specialist findings on this line (audit_math c29230d5, audit_permissions 1d1e2d36, audit_economics 616601b3); both attached proofs were run and fail for the stated reason. When the quote asset is the swap's specified currency, beforeSwap computes the fee from params.amountSpecified, books it immediately (_chargeFee mints claims and raises pendingProtocolFees/pendingHolderFees) and returns it as the BeforeSwapDelta. Uniswap v4 does not revert a swap that stops at sqrtPriceLimitX96 or runs out of liquidity; it fills what it can, and the full hook delta is still applied to the trader. afterSwap reads the fee back from the transient slot (lines 318-322) without comparing it with the quote that actually moved, so the trader pays requested*400/10000 (buy) or requested*400/9600 (sell) on quote that never changed hands. If the fee exceeds the delivered quote on an exact-out sell, `poolQuote - fee` at line 334 underflows and the swap reverts. The documented rule (4% of the quote side of every swap) and the Trade event are wrong for these swaps. Reach: swaps through any router that passes a real price limit or an exact-out request larger than the pool's quote reserve (PoolSwapTest in this repo, custom v4 integrations). PepesFamilyRouter and PepesFamilyEthRouter only do exact-in with the extreme limits, so the app's own paths are affected only in the unreachable case of a buy that runs to the end of the curve. The excess goes to holders (3/4) and the protocol (1/4), not to an attacker directly, although whoever moves the price up to a victim's limit first would hold tokens and share in the excess (reasoned, not tested). It does not block ordinary sells: exact-in sells are charged in afterSwap from the real output. Severity medium: direct loss for the trader, up to most of the proceeds, but only on trader-chosen parameters outside the project's routers. Fix that keeps the design: in afterSwap, in the quote-specified branch, require the actual quote delta to equal what beforeSwap assumed (requested - fee for exact-in, requested + fee for exact-out) and revert otherwise (e.g. PartialFill()). I applied that locally as a temporary patch: all three proof tests pass and the 25 existing tests still pass.","line":294,"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 {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {FullMath} from \"v4-core/src/libraries/FullMath.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\n/// @notice When the quote asset is the swap's *specified* currency (exact-in buy, exact-out sell) the hook computes\n///         its fee in `beforeSwap` from `params.amountSpecified`, the amount the trader *asked* for. If the pool\n///         fills only part of that amount (price limit reached, or the pool's quote reserve exhausted) the fee is\n///         still taken in full, so the trader pays far more than 4% of the quote that actually moved.\n///         Both tests fail on the current code. They pass once the hook either charges 4% of the actual gross or\n///         rejects partially filled swaps of these two kinds.\ncontract ExactOutSellFeeTest is Test {\n    PoolManager pm;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PoolSwapTest extRouter;\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    address feeRecipient = makeAddr(\"feeRecipient\");\n    address imd = makeAddr(\"imd\"); // never touched: the pool under test is ETH-quoted\n\n    PadToken t;\n    PoolKey key;\n    uint160 sqrtStart; // launch price = top of the pool's single-sided range (quote is currency0)\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        extRouter = new PoolSwapTest(pm);\n        int24 ethStartTick = _startTickForMarketCap(1.5 ether);\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(pm, imd, owner, feeRecipient, ethStartTick, _startTickForMarketCap(100e18))\n        );\n        (bytes32 salt, address expected) = _mineSalt(address(this), _flags(), initCode);\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(bob, 100 ether);\n\n        vm.prank(alice);\n        t = PadToken(payable(pad.launch(\"Test\", \"TST\", \"\", address(0))));\n        key = pad.poolKey(address(t));\n        sqrtStart = TickMath.getSqrtPriceAtTick(ethStartTick); // ETH is currency0, so the tick is not flipped\n\n        // bob buys with 1 ETH through the native router: 0.96 ETH enters the pool, 0.04 ETH is fee.\n        vm.prank(bob);\n        router.buy{value: 1 ether}(address(t), 1 ether, 0, block.timestamp);\n        vm.prank(bob);\n        t.approve(address(extRouter), type(uint256).max);\n    }\n\n    function _flags() internal pure returns (uint160) {\n        return uint160((1 << 13) | (1 << 11) | (1 << 7) | (1 << 6) | (1 << 3) | (1 << 2));\n    }\n\n    function _fees() internal view returns (uint256) {\n        return pad.pendingProtocolFees(address(0)) + pad.pendingHolderFees(address(t));\n    }\n\n    /// @dev A price limit half-way back to the launch price: the pool can return roughly half of its 0.96 ETH.\n    function _halfWayLimit() internal view returns (uint160) {\n        uint160 sqrtP = pad.getTokenInfo(address(t)).sqrtPriceX96;\n        return sqrtP + (sqrtStart - sqrtP) / 2;\n    }\n\n    /// @notice Exact-output sell: bob asks for 10 ETH, the pool stops at the price limit after paying ~0.5 ETH.\n    ///         `beforeSwap` charged 10e18 * 400 / 9600 = 0.4167 ETH, so bob keeps ~0.08 ETH of the ~0.5 ETH gross.\n    function test_exactOutSell_partialFill_feeIsFourPercentOfActualGross() public {\n        uint160 limit = _halfWayLimit();\n        uint256 ethBefore = bob.balance;\n        uint256 feeBefore = _fees();\n        vm.prank(bob);\n        try extRouter.swap(key, SwapParams(false, int256(10 ether), limit), settings, \"\") {\n            uint256 received = bob.balance - ethBefore;\n            uint256 fee = _fees() - feeBefore;\n            uint256 grossPaidByPool = received + fee;\n            emit log_named_uint(\"gross the pool paid out (wei)\", grossPaidByPool);\n            emit log_named_uint(\"fee taken (wei)\", fee);\n            emit log_named_uint(\"seller received (wei)\", received);\n            assertLe(fee, (grossPaidByPool * 4) / 100 + 1, \"fee exceeds 4% of the gross the pool actually paid\");\n        } catch {\n            // Rejecting the partially filled swap is an acceptable fix: the 4% rule is then never broken.\n        }\n    }\n\n    /// @notice Exact-input buy: bob offers 10 ETH with a price limit; the pool takes only ~1.1 ETH more but\n    ///         `beforeSwap` charged 10e18 * 400 / 10000 = 0.4 ETH, so bob pays ~1.5 ETH for ~1.1 ETH of tokens.\n    function test_exactInBuy_partialFill_feeIsFourPercentOfActualGross() public {\n        // Limit: a price a little further along the curve than the current one (buying moves the price down).\n        uint160 sqrtP = pad.getTokenInfo(address(t)).sqrtPriceX96;\n        uint160 limit = sqrtP - (sqrtStart - sqrtP) / 2;\n        uint256 ethBefore = bob.balance;\n        uint256 feeBefore = _fees();\n        vm.prank(bob);\n        try extRouter.swap{value: 10 ether}(key, SwapParams(true, -int256(10 ether), limit), settings, \"\") {\n            uint256 paid = ethBefore - bob.balance; // PoolSwapTest refunds what the pool did not take\n            uint256 fee = _fees() - feeBefore;\n            emit log_named_uint(\"gross the buyer paid (wei)\", paid);\n            emit log_named_uint(\"fee taken (wei)\", fee);\n            assertLe(fee, (paid * 4) / 100 + 1, \"fee exceeds 4% of the gross the buyer actually paid\");\n        } catch {\n            // Rejecting the partially filled swap is an acceptable fix.\n        }\n    }\n\n    // ------------------------------------------------------------ helpers (inlined copies of the deploy-script helpers)\n\n    function _startTickForMarketCap(uint256 marketCap) internal pure returns (int24 tick) {\n        uint256 sqrtPriceX96 = _sqrt(FullMath.mulDiv(1_000_000_000e18, 1 << 192, marketCap));\n        tick = TickMath.getTickAtSqrtPrice(uint160(sqrtPriceX96));\n        int24 rem = tick % 200;\n        tick -= rem < 0 ? rem + 200 : rem;\n    }\n\n    function _mineSalt(address deployer, uint160 flags, bytes memory initCode)\n        internal\n        pure\n        returns (bytes32 salt, address hook)\n    {\n        bytes32 h = keccak256(initCode);\n        for (uint256 i = 0; i < 500_000; i++) {\n            hook = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), deployer, bytes32(i), h)))));\n            if (uint160(hook) & 0x3FFF == flags) return (bytes32(i), hook);\n        }\n        revert(\"no salt\");\n    }\n\n    function _sqrt(uint256 x) internal pure returns (uint256 z) {\n        if (x == 0) return 0;\n        z = x;\n        uint256 y = (x >> 1) + 1;\n        while (y < z) {\n            z = y;\n            y = (x / y + y) >> 1;\n        }\n    }\n}","reproduction":"ETH-quoted launch at the test start tick; bob buys 1 ETH through PepesFamilyRouter (pool holds 0.96 ETH). Run from contracts/: forge test --match-path test/scratch/PartialFillFee.t.sol -vv (the audit_math proof with the DeployLib helpers inlined so the file is self-contained; logic unchanged). Case A, exact-out sell via PoolSwapTest: zeroForOne=false, amountSpecified=+10e18, sqrtPriceLimitX96 half-way back to the launch price. Pool pays out 594713003907107236 wei. Expected fee: 4% of that = 23788520156284289 wei. Actual fee: 416666666666666666 wei (10e18*400/9600, 70% of the gross); bob receives 178046337240440570 wei. Test fails: \"fee exceeds 4% of the gross the pool actually paid: 416666666666666666 > 23788520156284290\". Case B, exact-in buy: zeroForOne=true, amountSpecified=-10e18 with 10 ETH attached and a limit a little past the current price. Bob pays 1539233323399973647 wei in total. Expected fee: 61569332935998946 wei. Actual: 400000000000000000 wei (26%). Test fails: \"fee exceeds 4% of the gross the buyer actually paid: 400000000000000000 > 61569332935998946\". The audit_economics proof (2 ETH exact-out, limit 200 ticks short of launch) also fails as stated: fee 83333333333333333 > 37823967403430027.","severity":"medium","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Hook fee on quote-specified swaps (exact-in buy, exact-out sell) is computed from the requested amount, so a partially filled swap pays far more than 4% or reverts"},{"citation":"resolved","description":"claim() relies on PepesFamily.flush(token) to reach distribute(), but flush returns at once when pendingHolderFees[token] == 0 (PepesFamily.sol:353) and claim() never calls distribute() itself. Quote already in the token contract above accountedBalance is therefore not credited by a claim. That state arises in the normal flow: the router flushes before the first buyer receives tokens, eligibleSupply is 0 < MIN_ELIGIBLE_SUPPLY, distribute() returns 0 and the 3% waits; the same holds for donations. Nothing is lost: the next flush with pending fees, or anyone calling distribute(), credits it to the holders at that moment. Effect: withdrawableDividendOf and claim() under-report until then, and the waiting amount is exposed to the flash-take in the high finding for longer. Fix: call distribute() in claim() after the flush (subject to the guard the high finding needs).","line":176,"path":"contracts/src/PadToken.sol","reproduction":"Run in a scratch test on this tree: ETH-quoted launch; bob buys 1 ETH through PepesFamilyRouter. After it, token balance minus accountedBalance = 30000000000000000 wei and pad.pendingHolderFees(token) == 0. Send 1 ETH to the token. bob calls claim(). Expected: 1.03 ETH (he is the only holder). Actual: returns 0. Anyone calls distribute(); bob calls claim() again: returns 1029999999999999999 wei.","severity":"low","snippet":"        IPadFlush(pad).flush(address(this));","title":"claim() does not distribute quote already waiting in the token when no hook fees are pending"},{"citation":"resolved","description":"_transfer rejects to == address(0) but not from == address(0), and with amount == 0 the allowance check in transferFrom passes for any caller (0 < 0 is false, line 108). Anyone can call transferFrom(address(0), X, 0) and the token emits Transfer(0x0, X, 0), which indexers and scanners read as a mint event on a token that advertises no mint. No balance, supply or reward value changes (all deltas are 0), so there is no fund impact. Zero-value transferFrom between two real addresses without allowance also succeeds, but that matches ERC-20 and OpenZeppelin behaviour and is not a defect. Fix: `if (from == address(0)) revert` in _transfer (OpenZeppelin's ERC20InvalidSender); no effect on fees, rewards or the router shortcut. Deployed tokens cannot be changed; relevant for v2.","line":119,"path":"contracts/src/PadToken.sol","reproduction":"Scratch test on this tree: carol (no tokens, no allowance) calls token.transferFrom(address(0), bob, 0). Expected: revert. Actual: returns true and emits Transfer(from=0x0000000000000000000000000000000000000000, to=bob, value=0) from the token (checked with vm.expectEmit). Control: token.transferFrom(bob, carol, 1) by carol reverts InsufficientAllowance.","severity":"low","snippet":"        if (to == address(0)) revert InvalidRecipient();","title":"Zero-value transferFrom from the zero address succeeds and emits a mint-shaped Transfer event"},{"citation":"resolved","description":"Both arguments of the assertion are the same view call evaluated against the same state, so it passes for any pool price. This is the suite's only check of the state where every holder has left and the price is back at the edge of the single-sided range. Fix: read `uint256 mc0 = pad.marketCap(address(t));` before the buys and assert the post-exit value against mc0 with a small tolerance. Edges the suite does not cover at all and that this review exercised with throwaway tests: distribution triggered while pool tokens are flash-taken (the high finding), partially filled quote-specified swaps (the medium finding), re-entry from a seller during the router's ETH payout, a feeRecipient that rejects ETH, zero-value transferFrom from the zero address.","line":361,"path":"contracts/test/PepesFamily.t.sol","reproduction":"Read contracts/test/PepesFamily.t.sol:361: assertApproxEqRel(pad.marketCap(address(t)), pad.marketCap(address(t)), 0). Left and right are the same expression, so for any price P the assertion evaluates P == P. Expected: a check that fails if the exits leave the price away from the launch price. Actual: cannot fail; the test passes on this tree and would pass with any afterSwap or liquidity change that shifted the final price.","severity":"low","snippet":"        assertApproxEqRel(pad.marketCap(address(t)), pad.marketCap(address(t)), 0);","title":"test_everyoneCanExit compares marketCap with itself, so the sell-out price check can never fail"},{"citation":"resolved","description":"Merged from audit_flow 34ed83a8 and audit_economics 6bc72e1c. The token has no owner. The only way a balance moves without the holder's own call or allowance is transferFrom when msg.sender == router (immutable; on chain Pepes.router() returns 0xA73604EA3C393B47573986ff9Ce5A9EAb61883dC, equal to pad.router()). Router paths: buy, sell and launch all reach _swap, which sets SwapData.user = msg.sender (PepesFamilyRouter.sol:105) and passes it to poolManager.unlock (line 112). The router has exactly one transferFrom site (line 142) and it pulls from d.user. unlockCallback is gated to the PoolManager (line 119), and the PoolManager only calls back the address that called unlock with the bytes that address supplied, so d.user is always the router's own caller. A nested unlock reverts, so re-entry during the ETH payout cannot start a second swap. The currency pulled comes from pad.poolKey(token), which reverts UnknownToken for anything the launchpad did not deploy, so a crafted token or key cannot redirect the pull. launch() passes msg.sender as creator and then uses the same _swap. The router has no owner, no setter, no delegatecall and no arbitrary call. The launchpad, its owner, the PoolManager, the creator and PepesFamilyEthRouter (line 137 there) all need a normal allowance. This is very likely what triggers both the balance warning and the honeypot warning: a transferFrom branch keyed on a fixed address that skips the allowance. Trust assumption to state publicly: the router bytecode is a permanent allowance-free spender for every PadToken; it is 150 lines, immutable and reviewed here, and nobody can change it.","line":105,"path":"contracts/src/PadToken.sol","reproduction":"Scratch test on this tree (test_routerCanOnlyPullFromItsCaller): bob and carol hold tokens. (a) alice, with no tokens, calls router.sell(token, bobBalance, 0, deadline): reverts, bob's balance unchanged. (b) vm.prank as the pad, the PoolManager, the pad owner and the token creator, each calling token.transferFrom(bob, self, 1): all revert InsufficientAllowance. (c) router.unlockCallback called directly: reverts NotPoolManager. (d) a seller contract that, while receiving ETH inside the router's unlock, calls router.unlockCallback(SwapData{user: bob,...}), poolManager.unlock(...) and router.sell(...): all three revert, bob's balance unchanged, the seller's own sale completes. Expected: nobody but the holder moves the holder's tokens without allowance. Actual: same. Existing test_attack_cannotSellSomeoneElsesTokens also passes.","severity":"info","snippet":"        if (msg.sender != router) {","title":"Verdict, \"owner can change balance\": false positive. The only allowance-free spender is the immutable router, and it can only pull from its own caller"},{"citation":"resolved","description":"Merged from audit_flow 6eaffad9 and audit_economics 79ceaf98. Sell path dependencies: PadToken._transfer checks only to != 0 and balance; there is no pause, blacklist, max-tx or cooldown and the exclusion list has no setter. The hook charges 4% of the real quote output of an exact-in sell in afterSwap; its only revert is a SafeCast on a fee above 2^127. Router sells then call pad.flush (this line), which burns the pad's own ERC-6909 claims (mint and burn amounts match one to one, nobody else can move them), takes quote to the token and calls distribute(), which returns 0 instead of reverting when the balance is at or below accountedBalance or eligibleSupply is under 1 token. Arithmetic bounds: `magnifiedDividendPerShare * amount` in _transfer reverts only above 2^255 = 5.79e76; on chain Pepes has magnifiedDividendPerShare = 8.687e31, so a transfer of the whole 1e27 supply gives 8.7e58; reaching the bound needs about 1.7e29 wei of cumulative quote distributed at the 1-token eligibility floor, against an IMD totalSupply on this chain of 3.657e22 wei. Owner powers (setFeeRecipient, setStartTick, two-step ownership) are not on the sell or claim path; a feeRecipient that rejects ETH only makes collectProtocolFees revert. Sells through other v4 routers never call flush. Why scanners flag it: the only liquidity is a Uniswap v4 pool behind a hook, which a honeypot simulator generally cannot route a sell through, plus the router branch in transferFrom. Caveats that are not honeypot behaviour: router sells with zero quote output revert Slippage by design; the fix for the high finding must keep flush non-reverting on the router path; IMD is an external LayerZero OFT with an owner, and its deployed bytecode contains none of the pause/paused/unpause/blacklist selectors I searched for (0x8456cb59, 0x5c975abb, 0x3f4ba83a, 0xf9f92be4, 0xfe575a87), so IMD transfers cannot be frozen by the code as deployed today, but IMD is out of scope here.","line":148,"path":"contracts/src/PepesFamilyRouter.sol","reproduction":"Scratch test on this tree (test_ownerAndThirdPartiesCannotBlockSellOrClaim), one ETH-quoted and one IMD-quoted token, bob and carol holding both: the owner sets feeRecipient to a contract that reverts on ETH, sets both start ticks to extreme values and starts an ownership transfer; a third party sends ETH to the ETH-quoted token, IMD to the IMD-quoted token and to the pad, and calls distribute() and flush() on both. Then bob and carol each claim on both tokens and sell 100% of both through PepesFamilyRouter. Expected: all sells and claims succeed. Actual: all succeed, final balances 0; only collectProtocolFees(ETH) reverts while the bad feeRecipient is set. Existing test_everyoneCanExit and test_attack_distributeNeverBlocksTradesOrClaims pass. Live values quoted above were read with cast call at about block 77197460.","severity":"info","snippet":"        pad.flush(d.token);","title":"Verdict, \"possible honeypot\": false positive. No owner action, third-party call or reachable state makes a sell or a claim revert"},{"citation":"resolved","description":"Merged from audit_flow d9e40c99 and audit_economics 70df85b0. State-changing externals on the token: transfer, approve, transferFrom, distribute, claim, receive. The only msg.sender comparison in the contract is the router check in transferFrom (line 105); there is no owner, role, initializer, setter, delegatecall, selfdestruct or proxy slot. claim() pays only msg.sender's own withdrawableDividendOf, debits withdrawnDividends and accountedBalance before the transfer and is guarded by _locked. distribute() is permissionless and only converts quote already in the contract into per-share accrual. isExcluded is a comparison against six fixed addresses with no storage. pad, router, poolManager, quote, creator are immutable; name, symbol, metadata are written once in the constructor. receive() reverts for Pepes because its quote is IMD. The getters are views. Most likely flagged: claim(), a non-ERC-20 external function that calls another contract and sends ETH or a token out to the caller (it pattern-matches a hidden withdraw); distribute() second; the transferFrom router branch is covered by the balance warning. Reward accounting (brief item 4): the sum of holders' withdrawable amounts never exceeded accountedBalance, which never exceeded the quote balance, and total claims never exceeded totalDividendsDistributed. One qualification: \"not privileged\" does not mean \"not abusable\". Because claim() and distribute() are callable by anyone while the PoolManager is unlocked, a non-holder can redirect rewards that are waiting to be distributed (the high finding). That moves rewards between addresses; it never pays out more than was distributed and never touches balances.","line":173,"path":"contracts/src/PadToken.sol","reproduction":"Scratch tests on this tree: a stranger with no tokens calls claim(): returns 0, nothing moves. IMD-quoted token: sending 1 wei of ETH reverts (EthNotAccepted). testFuzz_claimsNeverExceedDistributed, 256 runs of three random buys, a random partial transfer and a full sell: eligibleSupply == sum of holder balances; sum(withdrawableDividendOf) <= accountedBalance <= token quote balance; the three claims pay exactly the sum of withdrawable and no more than totalDividendsDistributed. Existing testFuzz_buySellFeesAndSolvency (256 runs) passes. Expected: no special power behind any non-standard function and no over-payment. Actual: same.","severity":"info","snippet":"    function claim() external returns (uint256 amount) {","title":"Verdict, \"has suspicious function\": false positive as to privilege. No non-standard function gives any address special power; claim() is the one scanners most likely flag"},{"citation":"resolved","description":"The router flushes before the buyer receives tokens so a trader does not share in their own fee. On the first buy eligibleSupply is 0, distribute() returns here and the 3% waits in the token; the buyer is then the only eligible holder and the next distribution credits the whole waiting amount to them. Effective fee on the first buy (normally the creator's initial buy through router.launch) is 1%, not 4%; the same applies whenever every holder has exited. Nobody is diluted, so this is a documentation gap against the README and the router comment at PepesFamilyRouter.sol:147, not a loss. Changing it is a design decision (for example routing the waiting amount to the protocol or holding it until a second holder exists).","line":152,"path":"contracts/src/PadToken.sol","reproduction":"Scratch test on this tree: alice calls router.launch{value: 1 ether}(\"A\",\"A\",\"\", address(0), 1 ether, 1). Expected per README line 22 (\"a trader doesn't earn from their own trade\"): nothing from her own trade. Actual: the token holds 30000000000000000 wei with withdrawableDividendOf(alice) == 0; after bob buys 0.1 ETH through the router, withdrawableDividendOf(alice) == 32999999999999999 wei: her own 0.03 ETH plus 3% of bob's buy.","severity":"info","snippet":"        if (bal <= accountedBalance || eligible < MIN_ELIGIBLE_SUPPLY) return 0;","title":"First buyer of a launch gets their own 3% holder fee back at the next distribution"},{"citation":"resolved","description":"Trust assumption, not an exploit. The accepted range includes the negative edge value, where the launch range in _addLaunchLiquidity is a single 200-tick band at the end of the curve and the computed liquidity does not fit what the PoolManager accepts, so launch() and launchFor() for that quote revert until the owner sets a workable tick. Already-launched tokens such as Pepes, their pools, holders and rewards are unaffected: startTick is read only in _launch. The positive edge (+887000) launches normally. If the owner role is meant to be unable to halt launches, bound the tick to a range where the liquidity fits (or to a market-cap window).","line":435,"path":"contracts/src/PepesFamily.sol","reproduction":"Scratch test on this tree: owner calls setStartTick(IMD, -887000) (accepted: multiple of 200, within the limit); anyone calls pad.launch(\"X\",\"X\",\"\",IMD): reverts. Same for setStartTick(ETH, -887000) and an ETH launch. Owner sets the IMD tick back to the 100 IMD market-cap tick: launch succeeds. Expected per the docs (\"sets the starting market cap for future launches\"): a launch at an extreme price. Actual: launches blocked while the value is set.","severity":"info","snippet":"        if (tick % TICK_SPACING != 0 || tick > limit || tick < -limit) revert BadTick();","title":"setStartTick accepts -887000, at which every later launch for that quote reverts (owner-only, future launches only)"},{"citation":"resolved","description":"Both fee formulas round down (here and at line 326), so a swap whose quote side is under 25 wei pays no fee. Not exploitable: each such swap moves a few wei of value and costs a full transaction. Recorded only as the one place besides the medium finding where the 4% rule is not exact; no change recommended.","line":294,"path":"contracts/src/PepesFamily.sol","reproduction":"Scratch test on this tree: ETH-quoted launch, bob has bought. bob swaps through PoolSwapTest with zeroForOne=true, amountSpecified=-24 and 24 wei attached. Expected under a strict 4% rule: a non-zero fee. Actual: pendingProtocolFees(ETH) and pendingHolderFees(token) unchanged; bob receives 5923821984 token wei.","severity":"info","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Fee rounds down to zero for quote amounts below 25 wei (exact-in) or 24 wei (exact-out)"}],"hash":"b6eebc8c6ac6367a6b3ba803aeac1c8713a1772a85d4126ab47ede177f21c6fd","nodeId":"4edc9bbf-812d-474c-a899-f1b4cdafd2bf","outcome":"completed","summary":"The review is written to `.imd-findings.json` in the repository root: 11 findings, all reproduced against this tree. All three scanner warnings are false positives, but the review confirmed one real high-severity defect that lets anyone take holders' pending rewards.\n\n## Verdicts on the three warnings\n\n| Warning | Verdict | Why |\n| --- | --- | --- |\n| Possible honeypot | False positive | No owner action, third-party call or reachable state makes a sell or claim revert. |\n| Owner can change balance | False positive | The only allowance-free spender is the immutable router, and every path (buy, sell, launch, unlockCallback) pulls only from its own caller. |\n| Has suspicious function | False positive as to privilege | No non-standard function gives any address special power. `claim()` is the one scanners most likely flag. |\n\nThe router's allowance shortcut in `transferFrom` is very likely what triggers the first two warnings, as you suspected.\n\n## Defects\n\n- **High — flash-taken pool tokens capture holder rewards** (`PadToken.sol:148`). Inside its own PoolManager unlock, anyone can `take` the pool's token balance, trigger a distribution via `claim()`, `distribute()` or `flush()`, and return the tokens.\n  - In the local test a contract with no tokens and no IMD took 1.408 of 3 pending IMD, and 91% of a waiting first-buy fee.\n  - A trader swapping inside its own unlock got 84% of its own 3% holder fee back.\n  - On live Pepes (read at about block 77197460) the pool held 48.1% of supply and 2.454 IMD was pending, so about 1.18 IMD was capturable in one call. I did not run this against the chain or a fork.\n  - Balances, sells and already-accrued rewards are not affected. The deployed v1 contracts are immutable, so for Pepes the only mitigation is flushing promptly after third-party trades.\n- **Medium — fee charged on the requested amount, not the filled amount** (`PepesFamily.sol:294`). Three specialists reported this; merged into one. A partially filled exact-out sell paid 70% of its gross as fee and an exact-in buy paid 26%. A request whose fee exceeds the output should revert at line 334; I did not run that case. Your own routers are not affected in practice.\n- **Low — `claim()` skips waiting quote** (`PadToken.sol:176`). When no hook fees are pending, quote already sitting in the token is not credited until someone calls `distribute()`.\n- **Low — mint-shaped event** (`PadToken.sol:119`). `transferFrom(address(0), X, 0)` succeeds and emits `Transfer(0x0, X, 0)`.\n- **Low — tautological test** (`PepesFamily.t.sol:361`). `test_everyoneCanExit` compares `marketCap` with itself and cannot fail.\n- **Info** — three items:\n  - The first buyer gets their own 3% back at the next distribution.\n  - `setStartTick(-887000)` blocks future launches for that quote (owner-only).\n  - The fee rounds to zero below 25 wei.\n\n## Verification\n\n- Both proofs fail on the current code for the stated reason and pass under a temporary local fix; the 25 existing tests also pass with that fix. Sources were restored afterwards.\n- The high proof is mine, since the specialist attached none. The medium proof is the `audit_math` specialist's with the `DeployLib` helpers inlined so the file is self-contained.\n- Nothing was dropped as non-reproducing. Specialist figures I did not re-measure are left out: the exact-out underflow revert, the IMD-quoted partial-fill numbers, and the before/after market cap behind the tautological test.\n- The two fork test files were not run. IMD and the PoolManager are out of scope; I only checked that IMD's deployed bytecode has no pause or blacklist selectors.\n- No repository files were changed; `test/scratch/` was removed after use.","treeHash":null,"usage":{"cachedInputTokens":1336222,"inputTokens":24,"model":"claude-fable-5-1","outputTokens":49388,"runtime":"claude","turns":17,"wallClockMs":581584}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"468e82a89b9bfe18","findings":[{"citation":"resolved","description":"PepesFamily.beforeSwap charges the fee for an exact-in buy and an exact-out sell (the two swap kinds where the quote asset is the specified currency) from params.amountSpecified, i.e. the amount the trader asked for, and returns it as the BeforeSwapDelta. Uniswap v4 then settles whatever part of amountToSwap the pool can actually fill and subtracts the full hook delta from the trader regardless. If the swap stops early, because the trader's sqrtPriceLimitX96 is reached or because the pool's quote reserve is exhausted (the pool only ever holds the quote that buyers deposited), the trader still pays fee = requested*400/10000 (buy) or requested*400/9600 (sell) on quote that never moved. The documented rule (4% of every swap, AUDIT.md, README, the natspec on beforeSwap) and the afterSwap Trade event are both wrong for these swaps. afterSwap cannot correct it: its return delta only applies to the unspecified currency, and for an exact-out sell whose fee exceeds the delivered quote the `poolQuote - fee` subtraction at line 334 underflows and reverts the whole swap, so the trader cannot even see the overcharge in a dry run. Who is exposed: any seller or buyer routing through an integrator that passes a price limit (PoolSwapTest in this repo, custom v4 routers, limit-order style integrations), and any exact-out seller whose request exceeds the pool reserve. PepesFamilyRouter and PepesFamilyEthRouter use exact-in with extreme limits and are not affected. The overcharge goes to pendingProtocolFees and pendingHolderFees, so no attacker captures it directly, but the trader loses up to the whole delivered amount. Fix that preserves the design: in afterSwap, when (exactIn == zeroForOne) == quoteIs0, compare the actual quote delta with the amount beforeSwap assumed (`requested - fee` for exact-in, `requested + fee` for exact-out) and revert with a PartialFill error on mismatch; the trader can then use exact-in sell / exact-out buy, which are charged correctly from the actual delta in afterSwap. I applied exactly that two-line change locally: the proof passes and the 25 existing tests still pass. Alternatively compute an upper-bound fee in beforeSwap and refund the excess via ERC-6909 in afterSwap, but that changes settlement semantics for integrators.","line":294,"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 {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.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/// @notice When the quote asset is the swap's *specified* currency (exact-in buy, exact-out sell) the hook computes\n///         its fee in `beforeSwap` from `params.amountSpecified`, the amount the trader *asked* for. If the pool\n///         fills only part of that amount (price limit reached, or the pool's quote reserve exhausted) the fee is\n///         still taken in full, so the trader pays far more than 4% of the quote that actually moved.\n///         Both tests fail on the current code. They pass once the hook either charges 4% of the actual gross or\n///         rejects partially filled swaps of these two kinds.\ncontract ExactOutSellFeeTest is Test {\n    PoolManager pm;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PoolSwapTest extRouter;\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    address feeRecipient = makeAddr(\"feeRecipient\");\n    address imd = makeAddr(\"imd\"); // never touched: the pool under test is ETH-quoted\n\n    PadToken t;\n    PoolKey key;\n    uint160 sqrtStart; // launch price = top of the pool's single-sided range (quote is currency0)\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        extRouter = new PoolSwapTest(pm);\n        int24 ethStartTick = DeployLib.startTickForMarketCap(1.5 ether);\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(pm, imd, owner, feeRecipient, ethStartTick, DeployLib.startTickForMarketCap(100e18))\n        );\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(bob, 100 ether);\n\n        vm.prank(alice);\n        t = PadToken(payable(pad.launch(\"Test\", \"TST\", \"\", address(0))));\n        key = pad.poolKey(address(t));\n        sqrtStart = TickMath.getSqrtPriceAtTick(ethStartTick); // ETH is currency0, so the tick is not flipped\n\n        // bob buys with 1 ETH through the native router: 0.96 ETH enters the pool, 0.04 ETH is fee.\n        vm.prank(bob);\n        router.buy{value: 1 ether}(address(t), 1 ether, 0, block.timestamp);\n        vm.prank(bob);\n        t.approve(address(extRouter), type(uint256).max);\n    }\n\n    function _flags() internal pure returns (uint160) {\n        return uint160((1 << 13) | (1 << 11) | (1 << 7) | (1 << 6) | (1 << 3) | (1 << 2));\n    }\n\n    function _fees() internal view returns (uint256) {\n        return pad.pendingProtocolFees(address(0)) + pad.pendingHolderFees(address(t));\n    }\n\n    /// @dev A price limit half-way back to the launch price: the pool can return roughly half of its 0.96 ETH.\n    function _halfWayLimit() internal view returns (uint160) {\n        uint160 sqrtP = pad.getTokenInfo(address(t)).sqrtPriceX96;\n        return sqrtP + (sqrtStart - sqrtP) / 2;\n    }\n\n    /// @notice Exact-output sell: bob asks for 10 ETH, the pool stops at the price limit after paying ~0.5 ETH.\n    ///         `beforeSwap` charged 10e18 * 400 / 9600 = 0.4167 ETH, so bob keeps ~0.08 ETH of the ~0.5 ETH gross.\n    function test_exactOutSell_partialFill_feeIsFourPercentOfActualGross() public {\n        uint160 limit = _halfWayLimit();\n        uint256 ethBefore = bob.balance;\n        uint256 feeBefore = _fees();\n        vm.prank(bob);\n        try extRouter.swap(key, SwapParams(false, int256(10 ether), limit), settings, \"\") {\n            uint256 received = bob.balance - ethBefore;\n            uint256 fee = _fees() - feeBefore;\n            uint256 grossPaidByPool = received + fee;\n            emit log_named_uint(\"gross the pool paid out (wei)\", grossPaidByPool);\n            emit log_named_uint(\"fee taken (wei)\", fee);\n            emit log_named_uint(\"seller received (wei)\", received);\n            assertLe(fee, (grossPaidByPool * 4) / 100 + 1, \"fee exceeds 4% of the gross the pool actually paid\");\n        } catch {\n            // Rejecting the partially filled swap is an acceptable fix: the 4% rule is then never broken.\n        }\n    }\n\n    /// @notice Exact-input buy: bob offers 10 ETH with a price limit; the pool takes only ~1.1 ETH more but\n    ///         `beforeSwap` charged 10e18 * 400 / 10000 = 0.4 ETH, so bob pays ~1.5 ETH for ~1.1 ETH of tokens.\n    function test_exactInBuy_partialFill_feeIsFourPercentOfActualGross() public {\n        // Limit: a price a little further along the curve than the current one (buying moves the price down).\n        uint160 sqrtP = pad.getTokenInfo(address(t)).sqrtPriceX96;\n        uint160 limit = sqrtP - (sqrtStart - sqrtP) / 2;\n        uint256 ethBefore = bob.balance;\n        uint256 feeBefore = _fees();\n        vm.prank(bob);\n        try extRouter.swap{value: 10 ether}(key, SwapParams(true, -int256(10 ether), limit), settings, \"\") {\n            uint256 paid = ethBefore - bob.balance; // PoolSwapTest refunds what the pool did not take\n            uint256 fee = _fees() - feeBefore;\n            emit log_named_uint(\"gross the buyer paid (wei)\", paid);\n            emit log_named_uint(\"fee taken (wei)\", fee);\n            assertLe(fee, (paid * 4) / 100 + 1, \"fee exceeds 4% of the gross the buyer actually paid\");\n        } catch {\n            // Rejecting the partially filled swap is an acceptable fix.\n        }\n    }\n}","reproduction":"Setup (ETH-quoted launch at the deployed start tick, same as test/PepesFamily.t.sol): bob buys with 1 ETH through PepesFamilyRouter, so the pool holds 0.96 ETH. Case A, exact-out sell: bob swaps zeroForOne=false, amountSpecified=+10e18, sqrtPriceLimitX96 half-way back to the launch price, through PoolSwapTest. beforeSwap computes fee = 10e18*400/9600 = 416666666666666666 wei. The pool stops at the limit after paying 594713003907107236 wei. Expected: fee = 4% of 594713003907107236 = 23788520156284289 wei and bob receives ~570924483750822947 wei. Actual: fee = 416666666666666666 wei (70% of the gross), bob receives 178046337240440570 wei. Case B, exact-in buy: bob swaps zeroForOne=true, amountSpecified=-10e18 with 10 ETH attached and a price limit a little past the current price. beforeSwap computes fee = 10e18*400/10000 = 400000000000000000 wei; the pool takes 1139233323399973647 wei, bob is charged 1539233323399973647 wei in total. Expected fee: 61569332935998946 wei (4%). Actual: 400000000000000000 wei (26%). Case C, same as A but amountSpecified=+100e18 (fee 4.1667 ETH > any possible output): afterSwap reverts with an arithmetic underflow at `poolQuote - fee`, so the swap fails instead of charging a negative net. Run: forge test --match-path test/scratch/ExactOutSellFee.t.sol -vv from contracts/. Both tests fail on this code and pass with the afterSwap PartialFill check described above.","severity":"medium","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Hook fee on quote-specified swaps is computed from the requested amount, so a partial fill is charged far more than 4%"},{"citation":"resolved","description":"claim() relies on PepesFamily.flush(token) to trigger PadToken.distribute(), but flush returns immediately when pendingHolderFees[token] == 0 (PepesFamily.sol:353) and claim never calls distribute() itself. Quote that is already in the token contract but above accountedBalance is therefore not credited by a claim. This state arises in the normal launch flow: PepesFamilyRouter flushes before the buyer receives tokens, so on the first buy eligibleSupply is 0 < MIN_ELIGIBLE_SUPPLY, distribute() returns 0 and the 3% holder fee stays in the balance un-accounted. The same happens for any direct quote donation and for fees received while all holders together own less than one token. No funds are lost: the next flush with pending fees, or anyone calling the public distribute(), credits them to whoever holds tokens at that moment. The effect is that withdrawableDividendOf and claim() under-report until then, and the eventual recipients are the holders at distribution time rather than at receipt time. Fix: have claim() call distribute() after flush (it is already non-reverting and O(1)), or have flush() call distribute() even when pendingHolderFees is zero.","line":176,"path":"contracts/src/PadToken.sol","reproduction":"ETH-quoted launch; bob buys with 1 ETH through PepesFamilyRouter (the 0.03 ETH holder fee is flushed to the token while eligibleSupply is still 0, so it stays un-accounted; pendingHolderFees == 0 afterwards). Send a further 1 ETH donation to the token. bob calls claim(): expected 1.03 ETH (he is the only holder and the contract holds 1.03 ETH above accountedBalance), actual 0. Anyone calls distribute(), then bob calls claim(): actual 1029999999999999999 wei. Reproduced in test/scratch/MinorObservations.t.sol::test_claimSkipsDistributeWhenNothingPending (the log shows 0 then 1029999999999999999).","severity":"low","snippet":"        IPadFlush(pad).flush(address(this));","title":"claim() does not distribute quote already held by the token when no hook fees are pending, so the first buy's holder fee waits for an unrelated trade"},{"citation":"resolved","description":"Both fee formulas round down, so a swap whose quote side is below 25 wei (amount*400/10000) or 24 wei (amount*400/9600, and the same thresholds in afterSwap at line 326) pays no fee at all. Not exploitable: at the deployed start price 24 wei of quote buys about 5.9e9 token wei, worth under 1 wei of quote, and each such swap costs a full transaction of gas, so the saving can never exceed 1 wei per swap. Recorded only because the brief asks for every place the 4% rule is not exact; no change recommended.","line":294,"path":"contracts/src/PepesFamily.sol","reproduction":"ETH-quoted launch, bob holds tokens. bob swaps through PoolSwapTest with zeroForOne=true, amountSpecified=-24 and 24 wei attached. Expected under the documented rule: fee 0.96 wei rounded somewhere; actual: pendingProtocolFees and pendingHolderFees unchanged (fee 0), bob receives 5923821984 token wei. Reproduced in test/scratch/MinorObservations.t.sol::test_dustSwapPaysZeroFee.","severity":"info","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Fee truncates to zero for quote amounts below 25 wei (exact-in) or 24 wei (exact-out)"}],"hash":"c03ca6e6f0afa28082b5f9b1e827c686460b2d35f6e978bcf294a398188d4e89","nodeId":"a93deecd-1830-430a-8622-121e8f69efeb","outcome":"completed","summary":"The review is complete. Findings are in `.imd-findings.json` at the repository root; no source or test file was changed, only scratch tests under `contracts/test/scratch/`.\n\n**Verdict on the math.** The dividend accounting is sound. I verified by hand and with a 256-run scratch fuzz that `eligibleSupply` always equals the sum of non-excluded balances, that the sum of withdrawable rewards never exceeds `accountedBalance` or the contract's quote balance, and that payouts plus outstanding claims never exceed `totalDividendsDistributed`, with rounding dust bounded at one wei per holder. The overflow guards in `_toInt` and `distribute` are unreachable: IMD's on-chain total supply is about 36,751 tokens, so even a donation of the entire supply with one token eligible leaves the magnified accumulator six orders of magnitude below the int256 boundary. Nothing in the arithmetic lets an owner or third party make transfers, sells or claims revert, which supports treating the honeypot and balance-change warnings as false positives from the math side. The launch liquidity rounding is covered by the 1e9 buffer (303 wei of real dust on mainnet).\n\n**Three findings, one of them material:**\n\n- **Medium, `contracts/src/PepesFamily.sol:294`.** For exact-input buys and exact-output sells the fee is computed in `beforeSwap` from the requested amount, and v4 deducts it in full even when the pool fills only part of the swap. With a price limit or an exhausted quote reserve, a seller asking for 10 ETH out was charged 0.4167 ETH on a 0.595 ETH gross (70%), and a buyer offering 10 ETH in was charged 0.4 ETH on 1.54 ETH (26%). The native routers are not affected. A proof test is embedded; it fails now and passes with a two-line partial-fill revert in `afterSwap`, which I verified locally and then reverted.\n- **Low, `contracts/src/PadToken.sol:176`.** `claim()` only distributes via `flush`, which returns early when nothing is pending. The first buy's 0.03 ETH holder fee therefore sits un-credited until a later trade or an explicit `distribute()`. No loss, just delayed and reassigned credit.\n- **Info, `contracts/src/PepesFamily.sol:294`.** Quote amounts under 25 wei pay zero fee. Not farmable.\n\n**Not covered here:** the access-control and scanner-warning verdicts outside the arithmetic (router allowance bypass paths, non-standard function inventory) were read for context but the assigned area was the math guides, so those belong to the companion review passes.","treeHash":null,"usage":{"cachedInputTokens":2668779,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":65414,"runtime":"claude","turns":36,"wallClockMs":1203781}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"30a6c1a419ef4f9c","findings":[{"citation":"resolved","description":"Verdict: false positive. The token has no owner. The only way a balance moves without the holder's own call or allowance is transferFrom when msg.sender == router (immutable, set in the constructor by the launchpad to 0xA73604EA3C393B47573986ff9Ce5A9EAb61883dC). Every router path was traced: (1) buy/sell/launch all go through _swap, which builds SwapData with user = msg.sender (PepesFamilyRouter.sol:105) and passes it to poolManager.unlock; the PoolManager calls unlockCallback on the unlock caller only, so the router's unlockCallback (gated to the PoolManager at line 119) can only ever receive data the router itself encoded. (2) The single transferFrom call site in the router (PepesFamilyRouter.sol:142) is Currency.unwrap(cIn).transferFrom(d.user, poolManager, owed), where cIn is one of the two currencies of pad.poolKey(d.token); poolKey reverts UnknownToken for anything the launchpad did not deploy, so no crafted token or key can redirect the pull. (3) launch calls launchFor(msg.sender, ...) then the same _swap. (4) The router has no owner, no admin function and no arbitrary-call function, so nobody can make it call transferFrom with another from. (5) The launchpad, the PoolManager and PepesFamilyEthRouter are not exempt: they need an allowance like anyone else (PepesFamilyEthRouter.sol:137 pulls with the user's approval). Residual trust assumption to state in the verdict: the router bytecode is a privileged spender for every PadToken forever; a bug in that 150-line contract would be a bug in every token's approval model. It is immutable, so this cannot change after the fact.","line":105,"path":"contracts/src/PadToken.sol","reproduction":"Scanner pattern: a transferFrom branch keyed on a hardcoded/immutable address that skips the allowance read. Concrete checks run on this tree: (a) carol (no tokens) calls router.sell(token, bobBalance, 0, deadline) -> reverts TransferFailed wrapping PadToken.InsufficientBalance; bob's balance unchanged. (b) vm.prank(pad) or vm.prank(poolManager) then token.transferFrom(bob, carol, 1) -> reverts InsufficientAllowance. (c) carol calls token.transferFrom(bob, carol, 1) -> reverts InsufficientAllowance (existing test test_attack_cannotSellSomeoneElsesTokens). Expected: no address other than the holder can move the holder's tokens without allowance. Actual: matches expected.","severity":"info","snippet":"        if (msg.sender != router) {","title":"Warning 'owner can change balance' is a false positive: the only allowance-free spender is the immutable router, and it can only pull from its own caller"},{"citation":"resolved","description":"Verdict: false positive. Every revert source on the sell path was enumerated. Token: _transfer only checks to != 0 and balance; no pause, blacklist, max-tx or cooldown exists and isExcluded is a fixed list with no setter. Hook: beforeSwap/afterSwap compute a 4% fee on the quote side and mint ERC-6909 claims to the hook itself; the only revert is SafeCast toInt128 on a fee >= 2^127, which only the trader's own amount can cause. Router sell path: after settling the seller's tokens it calls pad.flush (this line), which burns the hook's own claims (no other address can move them: the hook never sets an operator or allowance on the PoolManager), takes the quote to the token contract and calls distribute(). distribute() returns 0 instead of reverting when the quote balance is below accountedBalance or eligible supply is under 1 token (PadToken.sol:152); its only arithmetic that can revert is amount * 2^128, which needs >= 3.4e38 wei of quote in one distribution: IMD's on-chain totalSupply is 3.675e22 wei and 3.4e38 wei of ETH does not exist. The int256 cast in _transfer (PadToken.sol:129) reverts only if magnifiedDividendPerShare * amount > 2^255; the per-share value is bounded by (total quote ever distributed) * 2^128 / 1e18, which for IMD caps at 1.25e43, times the whole 1e27 supply is 1.25e70, under 5.79e76. Pepes on chain today: magnifiedDividendPerShare = 8.3e31, eligibleSupply = 4.72e26, so even a transfer of the whole supply is 8.3e58, far from the bound. Owner powers (setFeeRecipient, setStartTick, ownership transfer) do not sit on the sell or claim path; a feeRecipient contract that rejects ETH only makes collectProtocolFees revert and is repairable. Sells through other v4 routers never call flush at all. Reentrancy: a seller contract that re-enters flush/distribute/claim while receiving ETH mid-unlock gets exactly what it was owed before its sale and nothing from its own fee. Why scanners flag it: the token's only liquidity is a Uniswap v4 pool with a hook, which GoPlus's honeypot simulator cannot route a sell through, and the router-only transferFrom branch (PadToken.sol:105) is a classic honeypot signature. Remaining dependency, not a defect: IMD is a LayerZero OFT owned by 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7; it exposes no paused() today, but any future transfer restriction on IMD would stop all IMD-paired trading in the PoolManager regardless of this code.","line":148,"path":"contracts/src/PepesFamilyRouter.sol","reproduction":"Scenarios executed against this tree with a local PoolManager: (1) three holders buy 3/5/2 ETH then sell 100% each: all succeed, pool returns to the exact launch price (marketCap 1528490686780151368 before and after). (2) A contract seller whose receive() calls pad.flush, token.distribute and token.claim during the ETH payout of its own sell: sell succeeds, claimed 12284479864844177 wei = exactly its pre-sale entitlement, other holders' sum of withdrawable <= accountedBalance <= balance. (3) A third party calls pad.flush(token) and pad.collectProtocolFees(ETH) from inside its own poolManager.unlock: pending amounts reach the token and feeRecipient, next sell succeeds. (4) Owner sets feeRecipient to a contract that reverts on ETH: collectProtocolFees reverts, bob's sell and alice's claim still succeed, owner resets and collects. (5) 400 fuzz runs of random buy/sell/transfer/claim sequences: sum(withdrawableDividendOf) <= accountedBalance <= quote balance and eligibleSupply == sum of non-excluded balances at every step. Expected: no path blocks a sell or claim. Actual: none found.","severity":"info","snippet":"        pad.flush(d.token);","title":"Warning 'possible honeypot' is a false positive: no owner action, third-party call or reachable state makes a sell or a claim revert"},{"citation":"resolved","description":"Verdict: false positive. Entry-point inventory of the token: state-changing externals are transfer, approve, transferFrom, distribute, claim and receive(); everything else is view. The only msg.sender comparison in the whole contract is msg.sender != router in transferFrom; there is no owner, role, initializer, setter, selfdestruct, delegatecall or proxy slot. Per function: claim() pays only msg.sender's own withdrawable amount, debits withdrawnDividends and accountedBalance before the external transfer, is guarded by _locked, and flushes pending hook fees first; nobody can claim for another address or redirect a payout. distribute() is permissionless and idempotent: it only converts quote already sitting in the contract into per-share accrual, cannot touch token balances, and the MIN_ELIGIBLE_SUPPLY floor bounds per-share growth. accumulativeDividendOf, withdrawableDividendOf, withdrawnDividends, magnifiedDividendPerShare, eligibleSupply, accountedBalance, totalDividendsDistributed, MIN_ELIGIBLE_SUPPLY are view/constant. isExcluded is a pure comparison against six fixed addresses (PoolManager, launchpad, router, the token, 0x0, 0xdEaD) with no storage and no setter. pad, router, poolManager, quote, creator are immutable; name, symbol, metadata are written once in the constructor. receive() reverts for Pepes because quote is IMD, so the token cannot even accept stray ETH. Which one scanners flag: claim() is the most likely, because it is a non-ERC-20 external function that sends ETH or an ERC-20 out of the contract to the caller (it pattern-matches a withdraw function); distribute() is the second candidate (external function writing a global accrual variable). The transferFrom hardcoded-address branch is the other signature, but that one is already covered by the balance warning. Reward accounting (brief item 4): sum over holders of accumulativeDividendOf never exceeds totalDividendsDistributed because each distribution adds floor(amount * 2^128 / eligible) per share and each holder rounds down individually; transfers move corrections so a holder's accumulative value is unchanged by sending or receiving tokens; accountedBalance is debited on every claim so it cannot underflow.","line":173,"path":"contracts/src/PadToken.sol","reproduction":"Inventory check: grep for msg.sender in contracts/src/PadToken.sol yields exactly one authorization comparison (line 105); no function reads an owner or role. Concrete: alice holds tokens and bob calls token.claim() with zero entitlement -> returns 0, alice's withdrawableDividendOf unchanged; bob calls token.distribute() with no new quote -> returns 0, no state change. Fuzz of 400 random sequences: sum of withdrawable over all holders <= accountedBalance <= contract quote balance after every operation, and every holder can claim at the end. Expected: no special power behind any non-standard function. Actual: matches.","severity":"info","snippet":"    function claim() external returns (uint256 amount) {","title":"Warning 'has suspicious function' is a false positive: no non-standard function is privileged; claim() is the one scanners most likely flag"},{"citation":"resolved","description":"With amount == 0 the allowance check passes for any spender (0 < 0 is false) and _transfer does not reject from == address(0), so anyone can call transferFrom(address(0), anyAddress, 0) and the token emits Transfer(0x0, anyAddress, 0). The same holds for transferFrom(anyHolder, anyAddress, 0) without an allowance, emitting a transfer between two unrelated addresses. No balance or accrual changes (eligibleSupply += 0), so there is no fund impact, but the events are indistinguishable from mints and from real transfers to indexers, explorers and the very scanners the brief is about: a token that advertises 'not mintable' can be made to show post-deployment Transfer-from-zero events on demand, and a holder can be shown 'sending' to arbitrary addresses. OpenZeppelin's ERC-20 rejects from == address(0) (ERC20InvalidSender). Fix: in _transfer add `if (from == address(0)) revert InvalidSender();` (optionally also reject amount == 0 in transferFrom when the caller has no allowance). This is a token-side change only and does not alter fees, rewards or the router shortcut.","line":108,"path":"contracts/src/PadToken.sol","reproduction":"Input: carol (no tokens, no allowance) calls token.transferFrom(address(0), bob, 0). Expected: revert. Actual: returns true and emits Transfer(from=0x0000000000000000000000000000000000000000, to=bob, value=0) from the token (verified with vm.expectEmit on this tree). Second input: after bob buys, carol calls token.transferFrom(bob, carol, 0) with allowance[bob][carol] == 0. Expected: revert InsufficientAllowance. Actual: succeeds and emits Transfer(bob, carol, 0).","severity":"low","snippet":"                if (allowed < amount) revert InsufficientAllowance();","title":"Zero-value transferFrom from the zero address succeeds and emits a mint-shaped Transfer event"},{"citation":"resolved","description":"Both operands are the same expression evaluated in the same call, so the assertion is a tautology and passes for any pool state. The test is the suite's only check of the sell-out edge (every holder leaving), which is also where the pool crosses its upper position tick with zero liquidity above it, so it is exactly the state worth asserting. The property the test was presumably written for does hold on this code: with 3/5/2 ETH bought and all sold back, marketCap is 1528490686780151368 wei both at launch and after the exits. Fix: capture `uint256 mc0 = pad.marketCap(address(t));` before the buys and assert `assertApproxEqRel(pad.marketCap(address(t)), mc0, 1e12)` after the sells. Other edges the suite does not exercise and that this review covered with throwaway tests: a seller contract re-entering claim/distribute/flush during its ETH payout, a third party calling flush/collectProtocolFees inside its own unlock, a feeRecipient that rejects ETH, and zero-value transferFrom from the zero address.","line":361,"path":"contracts/test/PepesFamily.t.sol","reproduction":"Replace the pool price with any value (for example, call the assertion after corrupting slot0 via a direct extRouter swap that leaves the price at an extreme tick) and the line still passes because left and right are the same read. Expected: a test that fails when the sell-out leaves the price away from the launch tick. Actual: cannot fail.","severity":"low","snippet":"        assertApproxEqRel(pad.marketCap(address(t)), pad.marketCap(address(t)), 0);","title":"test_everyoneCanExit compares marketCap to itself, so the intended 'price returns to launch' check never runs"}],"hash":"cf46254c9800cd72d467e153272e253f0c35f213529fc39f6861e530fb08966d","nodeId":"8c877af2-121c-4894-b9ff-237795aeb19e","outcome":"completed","summary":"The review is complete. The findings file is at the repository root, the scratch tests are removed, and no tracked file was changed.\n\n**Verdict on the three scanner warnings: all three are false positives.** Each is written up as an `info` entry in `.imd-findings.json` with the trace and the concrete checks behind it.\n\n- **Owner can change balance.** The only allowance-free spender is the immutable router. Its single `transferFrom` call site pulls from the user recorded as `msg.sender` of `buy`, `sell` or `launch`, and that record can only reach the router's own `unlockCallback` because the PoolManager calls back the unlock caller. Crafted tokens fail at `poolKey` (UnknownToken). The launchpad, PoolManager and the ETH router all need a normal allowance. The residual trust assumption is the router bytecode itself, which is immutable.\n- **Possible honeypot.** I enumerated every revert source on the sell and claim paths. The hook only mints claims to itself, `flush` burns claims nobody else can move, and `distribute` returns early rather than reverting. The two arithmetic reverts need more quote than exists: IMD's on-chain supply is 3.675e22 wei against a 2^128 threshold, and the per-share cast bound for Pepes today is 8.3e58 against 5.8e76. No owner action sits on the path. A fee recipient rejecting ETH only blocks protocol-fee collection. The scanner most likely fails because it cannot simulate a sell through a v4 hooked pool, combined with the router-only `transferFrom` branch.\n- **Suspicious function.** No non-standard function reads an owner or role. The only authorization check in the token is the router comparison. `claim()` is the most likely flag since it sends ETH or IMD out to the caller, with `distribute()` second. Reward accounting never exceeds what was distributed. A 400-run fuzz over random buys, sells, transfers and claims held the conservation invariant at every step.\n\n**Two low findings** came out of the control-flow pass:\n\n- Zero-value `transferFrom` from the zero address succeeds and emits a mint-shaped `Transfer` event. Anyone can also emit a zero-value transfer between two unrelated holders. No fund impact, but it spoofs events for indexers and scanners. Fix is a one-line sender check in `_transfer`.\n- `test_everyoneCanExit` compares `marketCap` to itself, so the sell-out check is a tautology. The intended property does hold on this code.\n\nScratch scenarios I ran and dismissed: a seller contract re-entering `claim`, `distribute` and `flush` during its ETH payout gained nothing beyond its pre-sale entitlement; a third party flushing fees mid-unlock caused no harm; after every holder exits, the pool returns exactly to the launch tick, so the extreme-price lead I was chasing is not real. The fork tests were not run since they need a live RPC and are outside the unit profile.","treeHash":null,"usage":{"cachedInputTokens":2483651,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":60071,"runtime":"claude","turns":32,"wallClockMs":916907}}],"verification":[]}