{"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":"b803125e-4ee8-464e-9328-ef7c6e1b9a9d","kind":"audit","nodes":[{"acceptedSubmissionHash":"7db544583dd7f39435f24842bdb347e679da900eb59f46177738422f231e3ed5","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":"9dba96e6bc81725c5dbde410e6e81c60032c59b6c2f6ad120c8c97b93bcd63c1","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":"2609af5a3f9b907084d0269338f6b2fbaef2d8a1d76071f49d03cd3c74f87943","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":"e8041970019620cd1adfc92ef2e07d406689b83f6f21a26bc9a807c0b6ebb479","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":"289eff1626e4cd587c3f2a85b58cb69b32ba6e50953a0115a67f70b7609f76d2","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":"Project: PepesFamily launchpad v4, re-check after audit ec4e3ea7\nRepo: github.com/0xtenang/PepesFamily (commit 2097cf2)\nScope: contracts/src/PepesFamily.sol, contracts/src/PadToken.sol, contracts/src/PepesBuyback.sol\nTests: contracts/test/PepesFamily.t.sol, contracts/test/PepesBuyback.t.sol, contracts/test/Fork.t.sol\n\nChanges since ec4e3ea7\n\nFinding 1: PepesBuyback adds a price guard. It records the pool sqrtPrice after each buyback (at deployment before the first) and only buys while the $PEPES price is ≤ 2% above it + 2% per day elapsed (priceRiseBps, allowedPriceRiseBps). Your proof scenario is test_pacedFrontRunStallsTheBuyback.\nFinding 2: burns the whole $PEPES balance.\nFinding 3: the hook calls PadToken.markActive(trader) on every buy. trader is the router-reported user when sender == router, otherwise tx.origin. The pad is the only caller allowed. A transfer the recipient didn't start counts as activity only if amount × 10 ≥ recipient's prior balance. ACTIVITY_MIN is removed.\nFinding 5: the buyback constructor resolves the $PEPES pool through pepesRouter.pad().poolKey(pepes) and requires an initialised IMD/$PEPES pair. Otherwise it reverts with BadWiring.\nFindings 4, 6, 7: comments and README corrected, and tests added.\nPlease check\n\nCan the price guard be gamed? For example: pushing the price down before a buyback to set a low reference, stalling buybacks forever (griefing), or a profitable series within the 2%/day allowance.\nIs using tx.origin in markActive safe? Can anyone make a buy mark another wallet active? Any problem with markActive being called during afterSwap?\nDoes the 1/10-of-balance gift rule have edge cases (self-transfers, transfers from excluded accounts, first receipts)?\nDid any fix break v3 guarantees or the earlier findings?","parentJobId":null,"planHash":"889fcd0bab3b69fb26bce351cb784e2dd138b59bd8561b8f74b9b12484f4b382","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"b803125e-4ee8-464e-9328-ef7c6e1b9a9d","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":"52017","feedbackHash":"d6bc5611e159afef381126670511726891e058b6868111e40c0fd7944607cea4","nodeKey":"audit_economics","submissionHash":"7db544583dd7f39435f24842bdb347e679da900eb59f46177738422f231e3ed5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51347","feedbackHash":"8423cec1625482cdb42e4c4d5964380402d1366bb30a67df1c754ab6f359a02e","nodeKey":"audit_flow","submissionHash":"9dba96e6bc81725c5dbde410e6e81c60032c59b6c2f6ad120c8c97b93bcd63c1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51557","feedbackHash":"1bd829bc905679134992f1e8c0b17b53615663241bf924ed7c71b1560b9783b6","nodeKey":"audit_judge","submissionHash":"2609af5a3f9b907084d0269338f6b2fbaef2d8a1d76071f49d03cd3c74f87943","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51488","feedbackHash":"e5395e5471858c95fb2b76fa86d8d285ef09983b8fe8547ef3b151e57122881c","nodeKey":"audit_math","submissionHash":"e8041970019620cd1adfc92ef2e07d406689b83f6f21a26bc9a807c0b6ebb479","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51891","feedbackHash":"207fadeb78485464e0c4dc067b69877fdb9c4cf319bacd3ca69d4a67ffb26265","nodeKey":"audit_permissions","submissionHash":"289eff1626e4cd587c3f2a85b58cb69b32ba6e50953a0115a67f70b7609f76d2","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"c98b026a4866b2b66282cd4adf9b290bfdba8101450ea1a583008211469d9be0","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"559cfaaab2c0d013","findings":[{"citation":"resolved","description":"Merged from audit_permissions 26b528a9 (a), audit_flow 667249a1, audit_math 408f0b6c and audit_economics 29baf4b5; all four reproduce.\n\nAfter every successful buyback, lines 132-133 set `refSqrtPrice` to the pool's spot price AFTER the buyback and restart `refTime`. The guard at line 118 therefore bounds the rise since the PREVIOUS buyback (200 bps + 8 bps for the hour), not the rise over the series. Any third-party buy that stays under that per-interval allowance is folded into the next reference in full, together with the buyback's own ~1.9% impact. The notice (lines 33-36) and the README say a pump stalls the series; in fact the guard tolerates about 2% of third-party rise per hour (about 60%/day compounded).\n\nAttack: before each hourly `buybackAndBurnPepes` the attacker buys `maxBuyback()` (1% of depth, +1.93% price, inside the 208 bps allowed after one hour), calls the buyback in the same transaction, and repeats while the reserve lasts; then sells everything into the price the series built. Each buyback injects 0.96% of depth and lifts the price ~1.93%; a round trip costs 8.2% in hook fees, so break-even is around 5-8 buybacks and everything beyond is taken out of the IMD the buybacks spend. A passive variant also pays: one buy of 1% of depth before an 8-buyback series ends +0.78 IMD (audit_flow proof), because the rider never trades again and `priceRiseBps()` reads 0 at every call; that residual is inherent to any flat 2% band around a public buy series and only a slower pace removes it, but the paced form above is what the re-anchoring adds.\n\nWho loses: the expired holder rewards the buyback is meant to spend on burning $PEPES. Measured on the repository's own guard-test pool (depth 1,061.9 IMD): 24 of 24 hourly buybacks run, 337.1 IMD spent, attacker +44.9 IMD (13.3% of the spend); audit_permissions measured +16.5 IMD on 17 buybacks with a depth/5 reserve; audit_economics reports +210.4 IMD on a local fork of the live pool (depth 4,974.6 IMD). A reserve of a third of depth is realistic: recycle() is permissionless, so the attacker can let expired rewards pile up and recycle them in one go.\n\nExisting tests do not cover it: `test_pacedFrontRunStallsTheBuyback` buys once (40% of depth) before the first buyback; no test trades between two buybacks.\n\nFix (design decision for the requester, both keep the hourly pace and the organic-rise behaviour): stop absorbing third-party rises at each buyback. Set the new reference to the old reference scaled by the buyback's OWN move only (`ref * sqrtAfter / sqrtBefore`, with `sqrtBefore` read just before `pepesRouter.buy`), never above the current price, and keep the flat 2% band plus the 2%/day drift above it. audit_economics and audit_permissions both validated this on a copy of the tree: the hourly-ratchet proof passes (attacker loses ~22 IMD) and PepesBuyback.t.sol and PepesFamily.t.sol still pass. A 24h TWAP from the pool's own observations is the alternative. Note this fix alone does NOT close the idle-allowance finding below.","line":132,"path":"contracts/src/PepesBuyback.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PepesBuyback} from \"src/PepesBuyback.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract MockIMD {\n    string public name = \"Identity.md\";\n    string public symbol = \"IMD\";\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev Launchpad A needs a wired buyback of its own (never used here): this reports an initialised IMD pair.\ncontract WiringStub {\n    PoolKey internal key;\n\n    function setKey(PoolKey memory k) external {\n        key = k;\n    }\n\n    function pad() external view returns (address) {\n        return address(this);\n    }\n\n    function poolKey(address) external view returns (PoolKey memory) {\n        return key;\n    }\n}\n\n/// @notice IMD Swarm re-check of audit ec4e3ea7, finding 1 (paced buyback front-run).\n///         Same world as test/PepesBuyback.t.sol: \"$Pepes\" is a token launched on launchpad A (4% hook fee,\n///         single-sided curve, like the live v1 $Pepes pool); launchpad B's PepesBuyback buys it through A's router.\n///         No idle time here: the reference is fresh. Each buyback re-anchors the reference at its own post-buyback\n///         price, so the guard only limits the rise since the PREVIOUS buyback. Eve buys as much as the buyback\n///         itself (1% of depth, about +1.9% in price) before every hourly buyback and is never blocked; after a day\n///         she sells everything at a profit taken out of the IMD the buybacks put in.\ncontract GuardHourlyRatchetTest is Test {\n    uint160 constant HOOK_FLAGS = 0x28CC;\n    int24 constant START_TICK = 161_000; // ~100 IMD starting market cap\n\n    PoolManager pm;\n    MockIMD imd;\n    PepesFamilyRouter routerA;\n    PadToken pepes;\n    PepesBuyback buyback;\n\n    address eve = makeAddr(\"eve\");\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockIMD();\n\n        WiringStub stub = new WiringStub();\n        (address c0, address c1) =\n            address(stub) < address(imd) ? (address(stub), address(imd)) : (address(imd), address(stub));\n        PoolKey memory k = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 3000, 60, IHooks(address(0)));\n        pm.initialize(k, TickMath.getSqrtPriceAtTick(0));\n        stub.setKey(k);\n\n        PepesFamily padA = _deployPad(address(stub), address(stub));\n        routerA = PepesFamilyRouter(payable(padA.router()));\n        pepes = PadToken(payable(padA.launch(\"Pepes\", \"PEPES\", \"\", address(imd))));\n        // a $Pepes pool with some depth: bob bought in earlier\n        imd.mint(bob, 1_000e18);\n        vm.startPrank(bob);\n        imd.approve(address(routerA), type(uint256).max);\n        routerA.buy(address(pepes), 1_000e18, 0, vm.getBlockTimestamp());\n        vm.stopPrank();\n\n        // the launchpad under review: its buyback buys \"$Pepes\" through launchpad A's router\n        buyback = PepesBuyback(_deployPad(address(pepes), address(routerA)).buyback());\n\n        imd.mint(eve, 10_000e18);\n        vm.startPrank(eve);\n        imd.approve(address(routerA), type(uint256).max);\n        pepes.approve(address(routerA), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function _deployPad(address pepes_, address pepesRouter_) internal returns (PepesFamily pad) {\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                address(this),\n                address(this),\n                START_TICK,\n                PepesFamily.ImdEthPool(10_000, 100, address(0)),\n                pepes_,\n                pepesRouter_\n            )\n        );\n        bytes32 initCodeHash = keccak256(initCode);\n        for (uint256 salt;; salt++) {\n            address a = address(\n                uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), initCodeHash))))\n            );\n            if (uint160(a) & 0x3FFF != HOOK_FLAGS) continue;\n            assembly {\n                pad := create2(0, add(initCode, 0x20), mload(initCode), salt)\n            }\n            require(address(pad) == a, \"hook address\");\n            return pad;\n        }\n    }\n\n    function test_toppingUpBeforeEveryHourlyBuybackMustNotPay() public {\n        uint256 depth = buyback.maxBuyback() * 100;\n        imd.mint(address(buyback), (depth * 40) / 100); // many holders' rewards expired\n        // A first buyback now: the reference price and time are as fresh as they can be.\n        buyback.buybackAndBurnPepes(0, vm.getBlockTimestamp());\n        uint256 start = imd.balanceOf(eve);\n\n        vm.startPrank(eve);\n        uint256 bought;\n        uint256 runs;\n        for (uint256 h; h < 24; h++) {\n            vm.warp(vm.getBlockTimestamp() + 1 hours);\n            // +1% of depth moves the price about +1.9%: inside the 2% the guard tolerates since the last buyback\n            bought += routerA.buy(address(pepes), buyback.maxBuyback(), 0, vm.getBlockTimestamp());\n            try buyback.buybackAndBurnPepes(0, vm.getBlockTimestamp()) {\n                runs++;\n            } catch {}\n        }\n        routerA.sell(address(pepes), bought, 0, vm.getBlockTimestamp());\n        vm.stopPrank();\n\n        emit log_named_uint(\"hourly buybacks that ran (of 24)\", runs);\n        emit log_named_decimal_uint(\"pool depth at the start (IMD)\", depth, 18);\n        emit log_named_decimal_uint(\"IMD the buyback spent\", buyback.totalImdSpent(), 18);\n        emit log_named_decimal_uint(\"eve's IMD before\", start, 18);\n        emit log_named_decimal_uint(\"eve's IMD after \", imd.balanceOf(eve), 18);\n        assertLe(imd.balanceOf(eve), start, \"front-running the paced buyback must not be profitable\");\n    }\n}","reproduction":"State: test/PepesBuyback.t.sol setUp (launchpad A's $Pepes pool after bob's 1,000 IMD buy, depth 1,061.9 IMD; launchpad B's PepesBuyback wired to it), reserve = 40% of depth, one buyback run so the reference is fresh.\nSteps: every hour for 24 hours eve (1) `routerA.buy(pepes, buyback.maxBuyback(), 0, now)` then (2) `buyback.buybackAndBurnPepes(0, now)`; after hour 24 she sells everything she bought.\nExpected (notice lines 33-36, README price-guard paragraph, the property `test_pacedFrontRunStallsTheBuyback` asserts): the buybacks stall with `PriceRisen`, and eve ends with no more IMD than she started with.\nActual: `priceRiseBps() <= allowedPriceRiseBps()` at every call, 24 of 24 buybacks run, 337.07 IMD spent, eve 10,000 -> 10,044.92 IMD.\nRun: `cd contracts && forge test --match-path test/scratch/Proof_29baf4b559f6.t.sol -vv` fails with \"front-running the paced buyback must not be profitable: 10044917259745802076753 > 10000000000000000000000\". Variant (audit_flow proof, also run): a single 1%-of-depth buy before an 8-buyback series, eve 10,000 -> 10,000.78 IMD.","severity":"medium","snippet":"        refSqrtPrice = _sqrtPrice();","title":"Price guard re-anchors the reference at every buyback, so a buy of up to 1% of depth before each hourly buyback is never blocked and the series is front-run for profit"},{"citation":"resolved","description":"Merged from audit_permissions 26b528a9 (b), audit_flow f5f15d87 and audit_economics 9d973ab3; all reproduce.\n\n`allowedPriceRiseBps()` is 200 + 200 x days since `refTime`, with no cap, and `refTime` only moves in the constructor (line 102) and after a SUCCESSFUL buyback (line 133). Every day without a buyback adds 2% of tolerance regardless of what the price did. The buyback is idle by construction: nothing can expire in the first 7 days after a launch (so the first buyback always has >= 16% of room), the reserve is empty between batches of expiries, and expired IMD only arrives when someone calls the permissionless `recycle()`, so the attacker also chooses when the reserve appears. After D idle days a pump of up to 2% + 2% x D in the same block passes line 118; the buyback buys into it (its cap is 1% of the pumped depth), line 132 re-anchors the reference at the pumped price, and every following hourly buyback runs too. This is exactly the scenario of audit ec4e3ea7 finding 1 that the guard was added to stop; `test_pacedFrontRunStallsTheBuyback` only pumps against a reference that is zero seconds old.\n\nMeasured on the guard-test pool (depth 1,061.9 IMD): 7 idle days, 8%-of-depth pump: 24/24 buybacks run, attacker +36.7 IMD (11.9% of the spend); 21 days, 20.8%: +87.9 IMD (25.7%); 48 days, 40% (the audit's own pump, +9,154 bps against 9,800 allowed): 8/8 run, +21.5 IMD on 8 buybacks (17.7% of the spend) and +153 IMD over 24. audit_permissions: 30 idle days, 25% pump, +51.3 IMD (24%). The attacker's return on the IMD she puts in is 36-43% in a day; the loser is the expired-rewards pool meant to burn $PEPES, and fewer $PEPES are burned because the buyback paid pumped prices.\n\nFix (requester's decision, it changes the agreed rule): the allowance must not grow over time in which no price was observed. Cap the accrued term (audit_economics tried a one-day cap on a copy: the proof passes, 0 of 8 buybacks run and the attacker loses 33.3 IMD) and let the reference ratchet toward the market by at most the accrued allowance via an internal `_ratchet()` called at the top of `buybackAndBurnPepes` and from a permissionless `poke()`, so a slow organic rise is still absorbed (audit_permissions validated this: paced series and post-idle pump both blocked, an honest hourly series and a 20% organic rise still run; `test_organicRiseOnlyDelays` then needs pokes during its wait). Until then anyone can keep the allowance from building by running a buyback regularly (1 wei in the reserve is enough), but nothing in the contracts makes that happen. The re-anchoring fix of the previous finding alone leaves this proof failing (8 of 8 run).","line":152,"path":"contracts/src/PepesBuyback.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PepesBuyback} from \"src/PepesBuyback.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract MockIMD {\n    string public name = \"Identity.md\";\n    string public symbol = \"IMD\";\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev Launchpad A needs a wired buyback of its own (never used here): this reports an initialised IMD pair.\ncontract WiringStub {\n    PoolKey internal key;\n\n    function setKey(PoolKey memory k) external {\n        key = k;\n    }\n\n    function pad() external view returns (address) {\n        return address(this);\n    }\n\n    function poolKey(address) external view returns (PoolKey memory) {\n        return key;\n    }\n}\n\n/// @notice IMD Swarm re-check of audit ec4e3ea7, finding 1 (paced buyback front-run).\n///         Same world as test/PepesBuyback.t.sol: \"$Pepes\" is a token launched on launchpad A (4% hook fee,\n///         single-sided curve, like the live v1 $Pepes pool); launchpad B's PepesBuyback buys it through A's router.\n///         The scenario is the audit's proof (`test_pacedFrontRunStallsTheBuyback`) with ONE change: no buyback ran\n///         for 48 days before eve arrives. The 2%-per-day allowance kept accruing over those days, so her pump is\n///         inside the guard and every hourly buyback buys into it.\ncontract GuardIdleAllowanceTest is Test {\n    uint160 constant HOOK_FLAGS = 0x28CC;\n    int24 constant START_TICK = 161_000; // ~100 IMD starting market cap\n\n    PoolManager pm;\n    MockIMD imd;\n    PepesFamilyRouter routerA;\n    PadToken pepes;\n    PepesBuyback buyback;\n\n    address eve = makeAddr(\"eve\");\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockIMD();\n\n        WiringStub stub = new WiringStub();\n        (address c0, address c1) =\n            address(stub) < address(imd) ? (address(stub), address(imd)) : (address(imd), address(stub));\n        PoolKey memory k = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 3000, 60, IHooks(address(0)));\n        pm.initialize(k, TickMath.getSqrtPriceAtTick(0));\n        stub.setKey(k);\n\n        PepesFamily padA = _deployPad(address(stub), address(stub));\n        routerA = PepesFamilyRouter(payable(padA.router()));\n        pepes = PadToken(payable(padA.launch(\"Pepes\", \"PEPES\", \"\", address(imd))));\n        // a $Pepes pool with some depth: bob bought in earlier\n        imd.mint(bob, 1_000e18);\n        vm.startPrank(bob);\n        imd.approve(address(routerA), type(uint256).max);\n        routerA.buy(address(pepes), 1_000e18, 0, vm.getBlockTimestamp());\n        vm.stopPrank();\n\n        // the launchpad under review: its buyback buys \"$Pepes\" through launchpad A's router\n        buyback = PepesBuyback(_deployPad(address(pepes), address(routerA)).buyback());\n\n        imd.mint(eve, 10_000e18);\n        vm.startPrank(eve);\n        imd.approve(address(routerA), type(uint256).max);\n        pepes.approve(address(routerA), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function _deployPad(address pepes_, address pepesRouter_) internal returns (PepesFamily pad) {\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                address(this),\n                address(this),\n                START_TICK,\n                PepesFamily.ImdEthPool(10_000, 100, address(0)),\n                pepes_,\n                pepesRouter_\n            )\n        );\n        bytes32 initCodeHash = keccak256(initCode);\n        for (uint256 salt;; salt++) {\n            address a = address(\n                uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), initCodeHash))))\n            );\n            if (uint160(a) & 0x3FFF != HOOK_FLAGS) continue;\n            assembly {\n                pad := create2(0, add(initCode, 0x20), mload(initCode), salt)\n            }\n            require(address(pad) == a, \"hook address\");\n            return pad;\n        }\n    }\n\n    function test_pacedFrontRunAfterIdlePeriodMustNotPay() public {\n        // The only change to the audit's scenario: the last buyback (here: deployment) was 48 days ago. Nothing can\n        // expire during the first 7 days after a launch, and the reserve is empty between batches of expiries, so\n        // the buyback regularly sits idle; the reference time only moves when a buyback runs.\n        vm.warp(vm.getBlockTimestamp() + 48 days);\n\n        uint256 depth = buyback.maxBuyback() * 100;\n        imd.mint(address(buyback), depth / 5); // expired rewards arrive now (recycle() is callable by anyone)\n        uint256 start = imd.balanceOf(eve);\n\n        vm.startPrank(eve);\n        uint256 got = routerA.buy(address(pepes), (depth * 40) / 100, 0, vm.getBlockTimestamp());\n        uint256 runs;\n        for (uint256 h; h < 8; h++) {\n            vm.warp(vm.getBlockTimestamp() + 1 hours);\n            try buyback.buybackAndBurnPepes(0, vm.getBlockTimestamp()) {\n                runs++;\n            } catch {}\n        }\n        routerA.sell(address(pepes), got, 0, vm.getBlockTimestamp());\n        vm.stopPrank();\n\n        emit log_named_uint(\"buybacks that ran into the pump (of 8)\", runs);\n        emit log_named_decimal_uint(\"IMD the buyback spent\", buyback.totalImdSpent(), 18);\n        emit log_named_decimal_uint(\"eve's IMD before\", start, 18);\n        emit log_named_decimal_uint(\"eve's IMD after \", imd.balanceOf(eve), 18);\n        assertLe(imd.balanceOf(eve), start, \"front-running the paced buyback must not be profitable\");\n    }\n}","reproduction":"State: `test_pacedFrontRunStallsTheBuyback` with one line added: `vm.warp(now + 48 days)` before eve buys (last buyback or deployment 48 days ago); depth 1,061.9 IMD; 212.4 IMD (depth/5) reaches the buyback now.\nSteps: 1. eve buys $Pepes with 40% of depth (424.76 IMD) through routerA: `priceRiseBps()` = 9,154, `allowedPriceRiseBps()` = 9,800. 2. once an hour for 8 hours anyone calls `buybackAndBurnPepes(0, now)`. 3. eve sells everything.\nExpected (notice, README, the existing test's two assertions): `PriceRisen` on every call, 0 buybacks, eve ends with less IMD than she started with.\nActual: 8 of 8 run and spend 121.60 IMD; eve 10,000 -> 10,021.52 IMD.\nRun: `cd contracts && forge test --match-path test/scratch/Proof_9d973ab37db8.t.sol -vv` fails with \"front-running the paced buyback must not be profitable: 10021521031196906105854 > 10000000000000000000000\".","severity":"medium","snippet":"        return MAX_PRICE_RISE_BPS + (PRICE_RISE_PER_DAY_BPS * (block.timestamp - refTime)) / 1 days;","title":"Price guard allowance grows 2%/day without bound while no buyback runs, so after an idle period a one-block pump passes the guard and the original front-run pays again"},{"citation":"resolved","description":"Merged from audit_permissions 4293024b, audit_flow f67d3f03, audit_math bbb61c7d and audit_economics 11fee012; all reproduce.\n\nA receipt the recipient did not start counts as activity when `amount * 10 >= balanceOf[to] - amount`, i.e. a tenth of the recipient's PRIOR balance. The constant's notice (lines 51-53) and the README promise that holding off someone's expiry costs a tenth of their bag whatever the token's price. For the wallets most likely to leave rewards unclaimed, those that sold everything (the case `test_expiry_zeroBalanceHolderLosesEverythingOld` covers: the router's sell path leaves accrued rewards withdrawable), the prior balance is 0 and `1 * 10 >= 0` holds, so a 1-wei gift resets the 7-day timer. It stays true for 1 wei until the balance reaches 10 wei, then for 2 wei, and so on: over 52 weekly gifts the total cost is under 1,000 wei of the token. The same applies to any holder whose bag is dust next to their rewards. This is a regression for these wallets of the finding-3 fix: before it a gift had to be at least 10,000 tokens (`ACTIVITY_MIN`) to count.\n\nImpact: anyone can keep arbitrary exited wallets' rewards away from the buyback for gas plus dust; the rewards remain claimable by their owner, nothing is stolen, so low.\n\nThe other edge cases asked about hold: a self-transfer marks the sender anyway and `balanceOf[to] - amount` cannot underflow (the balance was just raised by `amount`; for from == to it is unchanged); a transfer from an excluded account (the PoolManager paying out a buy) marks nobody as sender and leaves the recipient to this rule, the hook marking the buyer separately; a first receipt starts the timer once; a zero-amount transfer never counts; `amount * 10` cannot overflow with a 1e27 supply.\n\nFix: put an absolute floor next to the relative one (`amount * GIFT_ACTIVITY_DIVISOR >= prior && amount >= MIN_GIFT`, audit_economics validated a 1-token floor on a copy with all 56 PepesFamily tests passing; keep it small since finding 3 was about a fixed 10,000-token floor being wrong at high prices), or do not count gifts at all once `lastActive` is set, now that the hook records buys of any size.","line":236,"path":"contracts/src/PadToken.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract MockIMD {\n    string public name = \"Identity.md\";\n    string public symbol = \"IMD\";\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev The launchpad needs a wired buyback (never used here): this reports an initialised IMD pair.\ncontract WiringStub {\n    PoolKey internal key;\n\n    function setKey(PoolKey memory k) external {\n        key = k;\n    }\n\n    function pad() external view returns (address) {\n        return address(this);\n    }\n\n    function poolKey(address) external view returns (PoolKey memory) {\n        return key;\n    }\n}\n\n/// @notice IMD Swarm re-check of audit ec4e3ea7, finding 3 (activity): the 1/10-of-balance gift rule.\n///         \"Holding off someone's expiry costs a tenth of their bag each time\" - a tenth of nothing is nothing. For a\n///         wallet that sold everything (the wallets `test_expiry_zeroBalanceHolderLosesEverythingOld` is about),\n///         `amount * 10 >= balanceOf[to] - amount` is true for 1 wei, so anyone resets its 7-day timer for free.\ncontract GiftRuleZeroBalanceTest is Test {\n    uint160 constant HOOK_FLAGS = 0x28CC;\n    int24 constant START_TICK = 161_000; // ~100 IMD starting market cap\n\n    PoolManager pm;\n    MockIMD imd;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PadToken token;\n\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockIMD();\n\n        WiringStub stub = new WiringStub();\n        (address c0, address c1) =\n            address(stub) < address(imd) ? (address(stub), address(imd)) : (address(imd), address(stub));\n        PoolKey memory k = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 3000, 60, IHooks(address(0)));\n        pm.initialize(k, TickMath.getSqrtPriceAtTick(0));\n        stub.setKey(k);\n\n        pad = _deployPad(address(stub), address(stub));\n        router = PepesFamilyRouter(payable(pad.router()));\n        token = PadToken(payable(pad.launch(\"Test\", \"TST\", \"\", address(imd))));\n\n        address[2] memory users = [bob, carol];\n        for (uint256 i; i < users.length; i++) {\n            imd.mint(users[i], 10_000e18);\n            vm.startPrank(users[i]);\n            imd.approve(address(router), type(uint256).max);\n            token.approve(address(router), type(uint256).max);\n            vm.stopPrank();\n        }\n    }\n\n    function _deployPad(address pepes_, address pepesRouter_) internal returns (PepesFamily p) {\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                address(this),\n                address(this),\n                START_TICK,\n                PepesFamily.ImdEthPool(10_000, 100, address(0)),\n                pepes_,\n                pepesRouter_\n            )\n        );\n        bytes32 initCodeHash = keccak256(initCode);\n        for (uint256 salt;; salt++) {\n            address a = address(\n                uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), initCodeHash))))\n            );\n            if (uint160(a) & 0x3FFF != HOOK_FLAGS) continue;\n            assembly {\n                p := create2(0, add(initCode, 0x20), mload(initCode), salt)\n            }\n            require(address(p) == a, \"hook address\");\n            return p;\n        }\n    }\n\n    function test_oneWeiGiftMustNotHoldOffAnExitedHoldersExpiry() public {\n        uint256 t0 = vm.getBlockTimestamp();\n        // day 0: bob buys, carol's buy pays him a holder reward, bob sells everything (his last own act)\n        vm.prank(bob);\n        uint256 bag = router.buy(address(token), 100e18, 0, t0);\n        vm.prank(carol);\n        router.buy(address(token), 500e18, 0, t0);\n        vm.prank(bob);\n        router.sell(address(token), bag, 0, t0);\n        assertEq(token.balanceOf(bob), 0);\n        uint256 owed = token.withdrawableDividendOf(bob);\n        assertGt(owed, 1e18, \"bob left more than 1 IMD unclaimed\");\n        assertEq(token.lastActive(bob), t0);\n\n        // day 6: carol sends bob 1 wei of the token (10^-18 of one token)\n        vm.warp(t0 + 6 days);\n        vm.prank(carol);\n        token.transfer(bob, 1);\n\n        // 7 days and 1 second after bob's last own act: everything he left unclaimed has expired\n        vm.warp(t0 + 7 days + 1);\n        emit log_named_decimal_uint(\"IMD bob left unclaimed          \", owed, 18);\n        emit log_named_decimal_uint(\"expired after the 1-wei gift    \", token.expiredRewardsOf(bob), 18);\n        assertEq(token.lastActive(bob), t0, \"a 1-wei gift is not bob's activity\");\n        assertEq(token.expiredRewardsOf(bob), owed, \"a 1-wei gift must not hold off the expiry\");\n    }\n}","reproduction":"State: bob buys a v4 token for 100 IMD, carol's 500 IMD buy pays him a holder reward, bob sells his whole bag the same day: `balanceOf(bob) == 0`, `lastActive[bob]` = day 0, 18.0 IMD unclaimed.\nSteps: day 6 carol calls `token.transfer(bob, 1)` (1 wei); day 7 + 1 s anyone calls `token.recycle(bob)`.\nExpected (README and the constant's notice): `lastActive[bob]` stays day 0, `expiredRewardsOf(bob)` = 18.0 IMD and `recycle` sends it to the buyback.\nActual: `lastActive[bob]` = day 6 (1800518400), `expiredRewardsOf(bob)` = 0, `recycle(bob)` moves nothing; repeating the gift weekly keeps it so indefinitely.\nRun: `cd contracts && forge test --match-path test/scratch/Proof_11fee012a19e.t.sol -vv` fails with \"a 1-wei gift is not bob's activity: 1800518400 != 1800000000\".","severity":"low","snippet":"                            || amount * GIFT_ACTIVITY_DIVISOR >= balanceOf[to] - amount","title":"Gift-activity rule is free against a zero or dust balance: a 1-wei transfer keeps an exited holder's unclaimed rewards from ever expiring"},{"citation":"resolved","description":"Merged from audit_permissions ceb98093, audit_flow 5f91d1b2, audit_math cba47f1e and audit_economics d0933366; all reproduce.\n\nThe hook trusts hookData only when the PoolManager's `sender` is PepesFamilyRouter; every other buy is attributed to `tx.origin`. PepesFamilyEthRouter is deployed by the launchpad itself and is what the website uses for ETH payments: it knows its buyer (`r.user`, who receives the tokens) but passes empty hookData on the token leg (PepesFamilyEthRouter.sol:178) and is not recognised here, so its buys fall to `tx.origin` like any third-party router's. `tx.origin` is the buyer only for an EOA signing its own transaction. For a Safe or ERC-4337 account (an owner key or bundler signs), a sponsored or relayed transaction, or any contract that buys for itself, `markActive` stamps an address that receives nothing, and the actual holder is left to the receipt rule in `PadToken._transfer`: first receipt, or at least a tenth of its bag. A top-up below a tenth of the bag therefore does not count and anyone can `recycle()` the buyer's rewards a week after its first buy although it bought since. The mirror case exists: a contract that calls PepesFamilyRouter for a user is recorded instead of the user. PadToken's notice (lines 17-18: buys of any amount through any router count), the README and the `markActive` NatSpec ('the transaction's signer, which nobody can set for someone else') all assume buyer == tx.origin. Before the finding-3 fix a receipt of >= 10,000 tokens counted whatever the router or signer; moving buy detection into the hook left these buyers out.\n\nOn the brief's other questions: nobody can make a buy mark a wallet that neither signed nor called (hookData is trusted only from PepesFamilyRouter, which encodes its own msg.sender), and a mark can only postpone the marked wallet's expiry. `markActive` is one storage write guarded by `msg.sender == pad`, makes no external call and cannot revert a buy, so calling it inside `afterSwap` adds no reentrancy or liveness problem. The defect is only the wrong wallet being credited.\n\nNo test covers a buyer that is not the signer: `test_expiry_externalRouterBuyCountsForTheSigner` pranks bob as both sender and origin, and the ETH router is only exercised by fork tests that never read `lastActive`.\n\nFix: have PepesFamilyEthRouter pass `abi.encode(r.user)` as hookData on the token-pool swap and accept `sender == ethRouter` next to `sender == router` on this line (audit_economics validated this on a copy: the proof passes and the 56 PepesFamily tests still pass). For third-party routers `tx.origin` stays best effort: say so in the notice and README instead of promising 'any router', or additionally count a transfer from the PoolManager to a non-excluded recipient as that recipient's activity.","line":353,"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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PepesFamilyEthRouter} from \"src/PepesFamilyEthRouter.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract MockIMD {\n    string public name = \"Identity.md\";\n    string public symbol = \"IMD\";\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev The launchpad needs a wired buyback (never used here): this reports an initialised IMD pair.\ncontract WiringStub {\n    PoolKey internal key;\n\n    function setKey(PoolKey memory k) external {\n        key = k;\n    }\n\n    function pad() external view returns (address) {\n        return address(this);\n    }\n\n    function poolKey(address) external view returns (PoolKey memory) {\n        return key;\n    }\n}\n\n/// @dev A minimal smart-contract wallet: an owner key signs a transaction that makes the wallet call `target`.\n///      Stands in for a Safe, an ERC-4337 account or any contract that buys for itself.\ncontract Wallet {\n    function exec(address target, uint256 value, bytes calldata data) external returns (bytes memory ret) {\n        bool ok;\n        (ok, ret) = target.call{value: value}(data);\n        require(ok, \"wallet call failed\");\n    }\n\n    receive() external payable {}\n}\n\n/// @notice IMD Swarm re-check of audit ec4e3ea7, finding 3 (a buy through any router is the buyer's activity).\n///         `afterSwap` only trusts hookData from PepesFamilyRouter and otherwise marks `tx.origin`. The launchpad's\n///         own PepesFamilyEthRouter passes no hookData, so a buy by a contract wallet through it marks the key that\n///         signed the transaction, not the wallet that receives the tokens and accrues the rewards.\ncontract BuyerAttributionTest is Test {\n    uint160 constant HOOK_FLAGS = 0x28CC;\n    int24 constant START_TICK = 161_000; // ~100 IMD starting market cap\n\n    PoolManager pm;\n    MockIMD imd;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PepesFamilyEthRouter ethRouter;\n    PadToken token;\n    Wallet wallet;\n\n    address operator = makeAddr(\"operator\"); // the key that signs the wallet's transactions\n    address carol = makeAddr(\"carol\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockIMD();\n\n        WiringStub stub = new WiringStub();\n        (address c0, address c1) =\n            address(stub) < address(imd) ? (address(stub), address(imd)) : (address(imd), address(stub));\n        PoolKey memory k = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 3000, 60, IHooks(address(0)));\n        pm.initialize(k, TickMath.getSqrtPriceAtTick(0));\n        stub.setKey(k);\n\n        pad = _deployPad(address(stub), address(stub));\n        router = PepesFamilyRouter(payable(pad.router()));\n        ethRouter = PepesFamilyEthRouter(payable(pad.ethRouter()));\n        token = PadToken(payable(pad.launch(\"Test\", \"TST\", \"\", address(imd))));\n\n        // The IMD/ETH pool the ETH router uses: ETH is currency0, IMD currency1, fee 10000, spacing 100, no hook.\n        PoolModifyLiquidityTest lp = new PoolModifyLiquidityTest(pm);\n        PoolKey memory ethKey =\n            PoolKey(Currency.wrap(address(0)), Currency.wrap(address(imd)), 10_000, 100, IHooks(address(0)));\n        pm.initialize(ethKey, TickMath.getSqrtPriceAtTick(0)); // 1 ETH = 1 IMD\n        imd.mint(address(this), 100_000e18);\n        imd.approve(address(lp), type(uint256).max);\n        vm.deal(address(this), 100_000 ether);\n        lp.modifyLiquidity{value: 20_000 ether}(ethKey, ModifyLiquidityParams(-887200, 887200, 10_000e18, 0), \"\");\n\n        wallet = new Wallet();\n        vm.deal(address(wallet), 100 ether);\n        imd.mint(carol, 1_000e18);\n        vm.prank(carol);\n        imd.approve(address(router), type(uint256).max);\n    }\n\n    function _deployPad(address pepes_, address pepesRouter_) internal returns (PepesFamily p) {\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                address(this),\n                address(this),\n                START_TICK,\n                PepesFamily.ImdEthPool(10_000, 100, address(0)),\n                pepes_,\n                pepesRouter_\n            )\n        );\n        bytes32 initCodeHash = keccak256(initCode);\n        for (uint256 salt;; salt++) {\n            address a = address(\n                uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), initCodeHash))))\n            );\n            if (uint160(a) & 0x3FFF != HOOK_FLAGS) continue;\n            assembly {\n                p := create2(0, add(initCode, 0x20), mload(initCode), salt)\n            }\n            require(address(p) == a, \"hook address\");\n            return p;\n        }\n    }\n\n    /// The operator signs; the wallet buys through the pad's own ETH router and receives the tokens.\n    function _walletBuysWithEth(uint256 value) internal {\n        vm.prank(operator, operator);\n        wallet.exec(\n            address(ethRouter),\n            value,\n            abi.encodeCall(PepesFamilyEthRouter.buyWithEth, (address(token), 0, vm.getBlockTimestamp()))\n        );\n    }\n\n    function test_contractWalletBuyThroughEthRouterMustMarkTheWallet() public {\n        uint256 t0 = vm.getBlockTimestamp();\n        _walletBuysWithEth(1 ether); // first receipt: lastActive[wallet] = t0\n        assertEq(token.lastActive(address(wallet)), t0);\n        assertGt(token.balanceOf(address(wallet)), 0);\n\n        vm.prank(carol); // carol's buy pays the wallet a holder reward\n        router.buy(address(token), 100e18, 0, t0);\n        uint256 owed = token.withdrawableDividendOf(address(wallet));\n        assertGt(owed, 0, \"the wallet is owed rewards\");\n\n        // day 6: the wallet buys again (a top-up below a tenth of its bag) through the launchpad's own ETH router\n        vm.warp(t0 + 6 days);\n        _walletBuysWithEth(0.01 ether);\n\n        emit log_named_uint(\"lastActive[wallet]  \", token.lastActive(address(wallet)));\n        emit log_named_uint(\"lastActive[operator]\", token.lastActive(operator));\n        assertEq(\n            token.lastActive(address(wallet)), t0 + 6 days, \"a buy through the ETH router must restart the buyer's timer\"\n        );\n\n        // one day after its last buy, the wallet's rewards must not be recyclable\n        vm.warp(t0 + 7 days + 1);\n        assertEq(token.expiredRewardsOf(address(wallet)), 0, \"the wallet bought a day ago\");\n        assertEq(token.recycle(address(wallet)), 0, \"nothing to recycle\");\n    }\n}","reproduction":"State: a contract wallet W (exec(target, value, data)) whose owner key O signs its transactions; a v4 token T; the launchpad's PepesFamilyEthRouter with an ETH/IMD pool (ETH currency0, IMD currency1, fee 10000, spacing 100, no hook).\nSteps: 1. Day 0: O makes W call `ethRouter.buyWithEth{value: 1 ether}(T, 0, deadline)` (vm.prank(O, O)); first receipt so `lastActive[W]` = day 0. 2. carol buys T for 100 IMD through PepesFamilyRouter, W is owed rewards. 3. Day 6: O makes W call `buyWithEth{value: 0.01 ether}` (below a tenth of W's bag). 4. Day 7 + 1 s: anyone calls `T.recycle(W)`.\nExpected (PadToken notice, README): `lastActive[W]` = day 6 and `expiredRewardsOf(W)` = 0.\nActual: `lastActive[W]` is still day 0 (1800000000) and `lastActive[O]` = day 6 (1800518400) although O holds nothing; W's rewards are recyclable one day after its last buy. The same happens through a third-party router (v4-core's PoolSwapTest with W as the caller and O as tx.origin): reproduced in test/scratch, not attached.\nRun: `cd contracts && forge test --match-path test/scratch/BuyerAttribution.t.sol -vv` fails with \"a buy through the ETH router must restart the buyer's timer: 1800000000 != 1800518400\".","severity":"low","snippet":"        address trader = sender == router && hookData.length == 32 ? abi.decode(hookData, (address)) : tx.origin;","title":"afterSwap credits tx.origin for every router but PepesFamilyRouter, including the pad's own ETH router, so a contract wallet's buy marks its signer and the wallet's rewards still expire"},{"citation":"resolved","description":"Merged from audit_permissions 59b25c46, audit_flow 956f8f31, audit_math 62a8ff78 and audit_economics 9365488e; reproduced with the economics numbers exactly.\n\n`priceRiseBps()` treats any fall as 0 (line 144), so a buyback always runs into a dumped price, and line 132 then stores that dumped spot price as `refSqrtPrice`. Both the constructor read (line 99, so also by front-running the launchpad's CREATE2 deployment) and the post-buyback read (line 174 via line 132) are one-block spot prices the caller controls. A $PEPES holder sells, calls `buybackAndBurnPepes`, and buys back in the same transaction: the pool returns to where it was but reads as far above the reference, and no v4 buyback runs until the 2%/day allowance has caught up, (1/(1-d) - 1.02)/0.02 days for a dip of d. The reserve need not hold anything: `imdIn` only has to be non-zero (line 122), so with an empty reserve the caller sends 1 wei of IMD first; that 1-wei call also sets `lastBuyback`, so a real reserve arriving in the same hour reverts `TooSoon` (checked). Unlike the documented 'a pump stalls the buyback' case the attacker holds no pumped position afterwards; the cost is the 4% hook fee each way on the amount moved while the stall grows with 1/(1-d) and can be repeated whenever the allowance catches up. Measured (depth 1,061.9 IMD): a 4.3%-of-depth round trip stalls 4 days for ~3.8 IMD; 15.2% stalls 18 days for ~13.4 IMD; 30.7% stalls 52 days for ~27 IMD (about 128 IMD at the live pool's ~4,975 IMD depth).\n\nImpact: no IMD is lost, it waits, so this is griefing of the buyback's pace; it also lets a reserve pile up for the hourly series of the re-anchoring finding. `test_fallingPriceNeverBlocks` and `test_organicRiseOnlyDelays` cover a fall and a rise but not a dip undone right after the buyback.\n\nFix (trade-offs for the requester): do not take the reference from one spot read the caller can bracket. Options: record the reference as the price BEFORE the buyback's own swap or the max of pre- and post-swap price; let the reference fall by only a bounded step per buyback (looser guard after a genuine crash); or build it from observations over time (permissionless poke, time-weighted), which also removes the deployment front-run. Independently require a minimum spend (a share of `maxBuyback()`) before a call may move the reference and take the hourly slot, so 1 wei cannot. The ratchet fix of the re-anchoring finding does not remove this on its own.","line":174,"path":"contracts/src/PepesBuyback.sol","reproduction":"State: test/PepesBuyback.t.sol world (depth 1,061.9 IMD; bob holds 904,033,088 $PEPES), buyback reserve empty, reference fresh.\nSteps, all in one block by bob: 1. send 1 wei of IMD to the buyback. 2. `routerA.sell(pepes, bag/20, 0, now)` (45.2M $PEPES for 326.4 IMD); `priceRiseBps()` == 0. 3. `buybackAndBurnPepes(0, now)` succeeds, spends the 1 wei and records the dumped price. 4. `routerA.buy(pepes, 326.4 IMD)`. Then 100 IMD of expired rewards reach the buyback and anyone tries the buyback hourly.\nExpected: bob ends with the position he started with (same IMD, ~2.7M fewer $PEPES in fees), so buybacks keep running hourly ('a falling price never blocks the buyback').\nActual: `priceRiseBps()` = 10,566 against `allowedPriceRiseBps()` = 200; `buybackAndBurnPepes` reverts `PriceRisen` for 1,243 hours (52 days) with IMD waiting. bob: IMD unchanged, $PEPES 904,033,088 -> 901,561,959. Also: after a 1-wei buyback a call with a 100 IMD reserve in the same hour reverts `TooSoon`.\nRun: `cd contracts && forge test --match-path test/scratch/DumpStall.t.sol -vv` (judge's scratch test): test_sellBuybackRebuyPinsReferenceLow fails with \"... must not stall buybacks: 1243 != 0\"; test_oneWeiBuybackTakesTheSlot passes (behaviour confirmed).","severity":"low","snippet":"        (sqrtP,,,) = IPoolManager(poolManager).getSlot0(_poolId());","title":"Buyback reference is a single spot read the caller can bracket: sell, buyback (1 wei is enough), rebuy in one transaction pins the reference low and stalls every v4 buyback for days to weeks"}],"hash":"2609af5a3f9b907084d0269338f6b2fbaef2d8a1d76071f49d03cd3c74f87943","nodeId":"39a23f64-796e-478c-b656-fa801918aeee","outcome":"completed","summary":"The findings file is in place at the repo root, valid, with 5 findings and 4 proofs. It is hidden from git status only by a pre-existing entry in `.git/info/exclude`, so nothing in the tree was changed. The review is complete.\n\n## Result\n\nI judged 18 specialist findings, reproduced every one against commit 2097cf2, and merged them into 5 findings in `.imd-findings.json`. Nothing was dropped: all four specialists converged on the same five defects under different names. No source file was modified. The two project suites pass unchanged (5 buyback tests, 56 launchpad tests).\n\n**Kept findings, most severe first**\n\n- **Medium, PepesBuyback.sol:132.** The reference is re-anchored at the post-buyback spot price after every buyback, so the guard bounds the rise per hour, not per series. Buying 1% of depth before each hourly buyback is never blocked. Proof: 24 of 24 buybacks run, the attacker ends +44.9 IMD on a 337 IMD spend. Merged from 4 specialists.\n- **Medium, PepesBuyback.sol:152.** The 2%/day allowance accrues without bound while no buyback runs, and the reference time only moves on a successful buyback. After 48 idle days the audit's original 40% pump passes the guard. Proof: 8 of 8 buybacks run, attacker +21.5 IMD. Merged from 3 specialists. The fix for the first finding does not close this one.\n- **Low, PadToken.sol:236.** A tenth of a zero balance is zero, so a 1-wei gift resets a sold-out holder's timer and keeps 18 IMD of expired rewards from the buyback forever. Proof attached. Merged from 4.\n- **Low, PepesFamily.sol:353.** Every router except PepesFamilyRouter, including the pad's own ETH router, credits tx.origin. A contract wallet's buy marks its signing key instead, and its rewards become recyclable a day after it bought. I wrote this proof myself with a real ETH/IMD pool. Merged from 4.\n- **Low, PepesBuyback.sol:174.** Sell, 1-wei buyback, rebuy in one block pins the reference at the dip. I reproduced the economics specialist's numbers exactly: 10,566 bps over a 200 bps allowance and a 52-day stall, at a cost of 2.7M $PEPES in fees. No proof attached, since the four proof slots went to the more severe items. Merged from 4.\n\n**Answers to the brief's questions.** The guard can be gamed in all three asked ways. tx.origin cannot be used to mark someone else active against their will, and marking inside afterSwap adds no reentrancy or liveness risk, but it misattributes contract-wallet buyers. The gift rule's self-transfer, excluded-sender and first-receipt cases hold, only the zero-balance case breaks. The finding-3 fix regressed two cases that the old 10,000-token floor covered, and no v3 invariant was broken.\n\n**Caveats.** Proposed fixes were validated by the specialists on copies of the tree, not by me, since this task forbids changing files. The dump-stall and third-party-router reproductions live only in `contracts/test/scratch/`, which is discarded.","treeHash":null,"usage":{"cachedInputTokens":2058098,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":38279,"runtime":"claude","turns":40,"wallClockMs":761295}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"934ef01306a3cc89","findings":[{"citation":"resolved","description":"`buybackAndBurnPepes` requires `priceRiseBps() <= allowedPriceRiseBps()` (2% + 2% per day since `refTime`) and, after its swap, sets `refSqrtPrice = _sqrtPrice()` and `refTime = now`. Two consequences defeat the guard's stated purpose (contract notice: a series of buybacks must not be front-runnable by buying first and selling after several of them; audit ec4e3ea7 finding 1).\n\n(a) Per-buyback reset. The new reference is the spot price *after* the buyback, which contains whatever moved the price since the previous one, including an attacker's pump, provided it stayed under the allowance. The guard bounds the rise per buyback, not the cumulative rise since the attacker entered. Each hour: buy ~1% of the pool's IMD depth (price +1.93% < 2.08% allowed after one hour), call `buybackAndBurnPepes` in the same transaction, repeat while the reserve lasts, then sell everything. Every tranche sits in front of every later buyback. CPMM model with the 4% hook fee each way: break-even at ~8 buybacks, the attacker takes ~11% of the buyback's spend at 20 buybacks, ~34% at 48. On the repository's own guard-test pool (test/PepesBuyback.t.sol setUp, reserve = depth/5) eve runs 17 buybacks without `PriceRisen` ever firing and ends +16.53 IMD (7.8% of the 212 IMD spent).\n\n(b) Idle accrual. `refTime` only moves on a *successful* buyback. While the reserve is empty (or nobody calls), the allowance keeps growing: after 30 quiet days it is 6,200 bps. The first buyback of a new reserve batch then accepts a 25%-of-depth pump (+53.75%), folds it into the reference, and the following hourly buybacks run into it. Same pool and reserve: 16 buybacks run and eve ends +51.3 IMD (24% of the spend). This is the original audit scenario (large pump, hourly series, sell) passing the guard. `test_pacedFrontRunStallsTheBuyback` only covers a 40% pump made 0 days after the reference was set.\n\nFix that keeps the design (validated on a patched copy: paced series and post-idle pump both blocked, honest hourly series and a 20% organic rise still run): (1) cap the accrued allowance (e.g. at 7 days' worth) and let the reference *ratchet* toward the market by that accrued amount, at most, before the check (an internal `_ratchet()` called at the top of `buybackAndBurnPepes` and from a public `poke()`), then apply a flat `MAX_PRICE_RISE_BPS` check; (2) after the swap set the reference to the old reference scaled by the buyback's *own* move only (`ref * sqrtAfter / sqrtBefore`, `sqrtBefore` read just before `pepesRouter.buy`), never above the current price. A pump then gets at most 2%/day of room instead of 2% per buyback and never 62%; the residual leak of a single sub-2% tranche riding a long series is ~1-3% of the spend (inherent to any predictable public buy series) versus 8-60% now. A time-weighted reference (TWAP/EMA) is the alternative if a flat window is preferred.","line":132,"path":"contracts/src/PepesBuyback.sol","reproduction":"Deploy as in test/PepesBuyback.t.sol (launchpad A's $Pepes pool after bob's 1,000 IMD buy; launchpad B's PepesBuyback funded with depth/5 IMD). (a) eve: hour 0 `routerA.buy(pepes, buyback.maxBuyback(), 0, now)` (priceRiseBps 193 <= 200) then `buybackAndBurnPepes(0, now)` -> runs; hour 1 the same 1% buy (price now +3.9% over the pre-series price) then `buybackAndBurnPepes` -> expected PriceRisen, actual it runs (the reference was reset to the post-pump price). Continued hourly until the reserve is empty and sold: eve 100,000 -> 100,016.53 IMD. (b) warp 30 days with an empty reserve, fund depth/5, eve buys 25% of depth (priceRiseBps 5,375 <= allowed 6,200), buyback runs; 15 more run hourly; eve sells: 100,000 -> 100,051.33 IMD. Expected in both: eve's IMD <= start (the assertion test_pacedFrontRunStallsTheBuyback makes). Proof: test/scratch/PacedSandwich.t.sol, both tests fail on 2097cf2.","severity":"medium","snippet":"        refSqrtPrice = _sqrtPrice();","title":"Price guard folds every pump into the reference and its allowance grows while idle: a paced or post-idle front-run of the buyback series is profitable"},{"citation":"resolved","description":"`afterSwap` records the buyer as the router-reported user only when `sender == router`; every other buy, including through the project's own `PepesFamilyEthRouter` (it passes `\"\"` as hookData on the token leg, PepesFamilyEthRouter.sol:178), falls back to `tx.origin`. For a smart-contract wallet `tx.origin` is the EOA that executed the transaction (Safe owner, ERC-4337 bundler, relayer), not the wallet that receives the tokens and accrues the rewards. `markActive` therefore stamps an unrelated EOA and the actual holder stays inactive; the receipt itself only counts if it is the wallet's first or at least a tenth of its bag. README/PadToken notice promise that a wallet is active when it buys any amount through any router; for contract wallets that is false, and their unclaimed rewards older than 7 days become recyclable although they bought days ago. (The inverse question, whether a buy can mark *another* wallet active, is safe: only the signer is stamped and a stamp can only delay expiry.) Fix: have `PepesFamilyEthRouter` pass `abi.encode(r.user)` as hookData on the token-pool swap and accept it in the hook when `sender == ethRouter` as well as `router`; keep `tx.origin` only as the fallback for unknown routers and document that contract wallets using third-party routers must claim or send to stay active (or additionally treat a transfer *from the PoolManager* to a non-excluded address as the recipient's activity, which is what the ACTIVITY_MIN rule used to approximate).","line":353,"path":"contracts/src/PepesFamily.sol","reproduction":"Unit test setup with an IMD/ETH pool for the ETH router. A `Wallet` contract (exec(target, value, data)) run by EOA `operator` (`vm.prank(operator, operator)`): day 0 `ethRouter.buyWithEth(token, 0, now)` with 1 ETH (first receipt starts its timer); carol buys 100 IMD so the wallet earns rewards; day 6 the wallet buys again with 0.01 ETH through `ethRouter.buyWithEth`. Expected: `lastActive(wallet) == now` (it bought) and on day 8 `expiredRewardsOf(wallet) == 0`. Actual: `lastActive(operator) == now`, `lastActive(wallet)` is still day 0 (1800000000 != 1800518400) and on day 8 `recycle(wallet)` moves its rewards to the buyback. Proof: test/scratch/ContractWalletActivity.t.sol.","severity":"low","snippet":"        address trader = sender == router && hookData.length == 32 ? abi.decode(hookData, (address)) : tx.origin;","title":"afterSwap marks tx.origin, so a contract wallet (Safe, ERC-4337 account, relayed tx) buying through the ETH router or any third-party router is never recorded as active"},{"citation":"resolved","description":"A receipt the recipient did not initiate counts as activity when `amount * 10 >= balanceOf[to] - amount` (the recipient's prior balance). The comment on `GIFT_ACTIVITY_DIVISOR` says holding off someone's expiry \"costs a tenth of their bag each time, whatever the token's price\". For the holders most likely to leave rewards unclaimed, those who sold everything (the router's sell path leaves their already-accrued rewards withdrawable, see test_expiry_zeroBalanceHolderLosesEverythingOld), the prior balance is 0 and `1 * 10 >= 0` holds: a 1-wei gift resets their timer. The same applies to any holder whose bag is tiny next to their rewards (e.g. kept 1 token after selling). Anyone can therefore keep arbitrary sold-out wallets \"active\" at gas cost, which is exactly the dust-gift griefing of the burn mechanism that audit finding 3 was meant to price. Self-transfers, `transferFrom(holder, holder, 1)` by any spender with allowance, and transfers from excluded accounts behave as intended (sender activity, no underflow). Fix: now that the hook records buys through any router, a receipt the recipient did not initiate has no reason to count at all except the first one (which exists so `lastActive != 0` and the wallet can ever expire): drop the `amount * GIFT_ACTIVITY_DIVISOR >= ...` clause; or, if gifts should still count, also require `balanceOf[to] - amount >= MIN_ELIGIBLE_SUPPLY` (a bag worth protecting) so an empty wallet cannot be kept alive for free.","line":236,"path":"contracts/src/PadToken.sol","reproduction":"bob buys 10 IMD of a token, mallory buys 10 IMD (bob earns rewards `owed > 0`), bob sells his whole balance (`balanceOf(bob) == 0`, rewards still withdrawable). Every 6 days mallory calls `t.transfer(bob, 1)`. Expected (finding-3 fix): a gift that is not at least a tenth of bob's bag does not reset his timer, so after 7+ days `expiredRewardsOf(bob) == owed`. Actual: `lastActive(bob) == block.timestamp` after each 1-wei transfer; 60 days later `expiredRewardsOf(bob) == 0`, `recycle(bob) == 0` and `withdrawableDividendOf(bob) == owed`. Verified in test/scratch/GuardEdges.t.sol::test_zeroBalanceHolderKeptActiveForOneWei (passes = behaviour confirmed).","severity":"low","snippet":"                            || amount * GIFT_ACTIVITY_DIVISOR >= balanceOf[to] - amount","title":"Gift-activity rule is free against holders with a zero or tiny balance: 1 wei keeps a sold-out holder's unclaimed rewards from ever expiring"},{"citation":"resolved","description":"`refSqrtPrice` is read from the pool's slot0 in the constructor (so at the moment PepesFamily v4 is deployed) and again right after each buyback. Both reads are spot prices anyone can set within the same transaction or block. A holder sells $Pepes, calls `buybackAndBurnPepes` (the buyback buys at the depressed price and records it as the reference), and buys back in the same transaction. The price is back where it was but now reads as far above the reference, so every buyback reverts with `PriceRisen` until the 2%/day allowance has caught up. The same can be done by front-running the launchpad's CREATE2 deployment. It is griefing, not profit: the round trip costs the attacker ~8% of the dumped amount in hook fees and the IMD only waits, so this is in line with the design's \"a pump stalls the buyback (the IMD waits)\" and it is not a permanent stall. It is reported because the brief asked whether a dump can set a low reference: it can, for (gap - 2%) / 2% days per 8%-of-dump fee, repeatable. The ratchet fix proposed for the medium finding does not remove this (a lower spot still lowers the reference); a time-weighted reference (e.g. an EMA of the price updated on every buyback/poke) would, at the cost of the guard reacting a little later to real moves.","line":101,"path":"contracts/src/PepesBuyback.sol","reproduction":"Guard-test pool (test/PepesBuyback.t.sol setUp). eve holds $Pepes bought with 10% of the pool's IMD depth and a buyback has run since (reference includes her position). In one transaction eve sells all her $Pepes (`routerA.sell`), calls `buybackAndBurnPepes(0, now)` (runs, reference = depressed price), and rebuys with the IMD received. Expected: the price is back to the pre-dump level, so the next hourly buyback runs. Actual: `priceRiseBps() == 1847`; `buybackAndBurnPepes` reverts `PriceRisen` every day for 8 days before it runs again. eve's IMD balance is unchanged (she holds ~8% fewer $Pepes: the fee she paid). Verified in test/scratch/GuardEdges.t.sol::test_dumpSetsLowReferenceThenStalls.","severity":"low","snippet":"        refSqrtPrice = sqrtP;","title":"Buyback reference is a spot price the caller controls in the same transaction: a dump right before a buyback (or before the launchpad deployment) parks the reference low and stalls buybacks for days"}],"hash":"289eff1626e4cd587c3f2a85b58cb69b32ba6e50953a0115a67f70b7609f76d2","nodeId":"1a07644e-0a90-4509-bbf1-17df2b0f83fc","outcome":"completed","summary":"The findings file is in place (ignored by git via a global rule, which is fine — it's read from the tree). Review complete; no source files were changed.\n\n## Summary\n\n`.imd-findings.json` holds 4 findings (1 medium, 3 low), two with Foundry proofs under `contracts/test/scratch/` that fail on 2097cf2 and that I validated pass on a patched copy implementing the recommended fix.\n\n**Medium — Price guard is gameable (`PepesBuyback.sol:132`)**\nTwo variants of the same root cause (spot reference folded in after every buyback; allowance accrues indefinitely from `refTime`, which only moves on a *successful* buyback):\n- *Paced series*: pump ~1% of depth per hour (+1.93% < 2.08% allowed), run the buyback in the same tx, repeat, sell at the end. On the repo's own guard-test pool with its own `depth/5` reserve: 17 buybacks run, guard never fires, eve +16.5 IMD (7.8% of the spend). Model: break-even at ~8 buybacks, 34% of spend at 48.\n- *Idle accrual*: after 30 quiet days the allowance is 62%; a 25%-of-depth pump passes, 16 buybacks run into it, eve +51.3 IMD (24%). This is the original audit scenario going through.\nRecommended fix (validated): cap the accrued allowance (~7 days), ratchet the reference toward the market by that amount before the check (`poke()`/internal), flat 2% check, then after the swap fold in only the buyback's own move. Honest hourly series and organic rises still run; residual leak ~1–3%.\n\n**Low — `tx.origin` misses contract wallets (`PepesFamily.sol:353`)** — including through the project's own ETH router (empty hookData). A Safe buying via `buyWithEth` stamps its operator EOA; the Safe's rewards expire anyway. Proof included. The inverse (someone marking *another* wallet) is safe: only the signer is stamped, and a stamp only delays expiry.\n\n**Low — Gift rule is free for zero/tiny bags (`PadToken.sol:236`)** — 1 wei resets a sold-out holder's timer indefinitely (`1*10 >= 0`). Self-transfers, allowance self-transfers and excluded-sender transfers behave as intended.\n\n**Low — Dump before buyback/deployment sets a low reference (`PepesBuyback.sol:101`)** — stalls ~8 days per 18% gap at ~8% round-trip fee; griefing with cost, not permanent, in line with the \"IMD waits\" design; reported because the brief asked.\n\n**Checked and found sound:** `markActive` access (`onlyPad`), no reentrancy or ordering issue with its call inside `afterSwap`; hookData spoofing impossible (needs `sender == router`); finding 2 (burn whole balance) and finding 5 (wiring check; v1 router exposes `pad()`) correct; `priceRiseBps` math and overflow bounds; all 119 unit tests pass; no v3 invariant broken by the changes. Fork tests were not run (no network for the verifier profile).","treeHash":null,"usage":{"cachedInputTokens":5380979,"inputTokens":87,"model":"claude-fable-5-1","outputTokens":76024,"runtime":"claude","turns":46,"wallClockMs":1480341}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9df7d5d52e83c572","findings":[{"citation":"resolved","description":"`allowedPriceRiseBps()` returns 200 + 200 x (now - refTime) / 1 day with no upper bound, and `refTime` only moves in the constructor (line 102) and after a successful buyback (line 133). Every day without a buyback therefore adds 2% of tolerance, whatever the price did in between.\n\nThe buyback is idle by construction: nothing can expire during the first 7 days after a launch, so the very first buyback always has at least 16% of tolerance; the reserve is empty between batches of expiries; and expired rewards only arrive when someone calls the permissionless `recycle()` / `recycleMany()`, so an attacker also chooses the moment the IMD arrives.\n\nAfter D idle days a pump of up to 2% + 2% x D made in the same block passes the check on line 118. The buyback buys into the pump (its cap is 1% of the pumped depth), line 132 re-anchors the reference at the pumped price, and every following hourly buyback runs too. This is the scenario of audit ec4e3ea7 finding 1 that the guard was added to stop. The notice (lines 35-36) says \"A pump stalls the buyback (the IMD waits) instead of selling into it\", the README repeats it, and `test_pacedFrontRunStallsTheBuyback` asserts it, but that test only pumps against a reference that is zero seconds old.\n\nMeasured on the code as it is (pool depth 1,061.9 IMD, 24 hourly buybacks, the attacker sells afterwards):\n- 7 idle days, pump of 8% of depth (+1,594 bps, 1,600 allowed): 24 of 24 buybacks run, attacker +36.7 IMD, 11.9% of the 307.0 IMD the buyback spent\n- 10 idle days, 10.85% of depth (+2,191 bps): +48.8 IMD, 15.5% of the spend\n- 21 idle days, 20.8% of depth (+4,392 bps): +87.9 IMD, 25.7%\n- 48 idle days, 40% of depth (+9,154 bps, 9,800 allowed): +153.2 IMD, 38.8%\nThe attacker's return on the IMD she puts in is 36-43% in a day. Who loses: the IMD is holders' expired rewards, meant to support and burn $PEPES. The attacker leaves with that share of it, and fewer $PEPES are burned because the buyback paid pumped prices.\n\nWhat fixing it takes: the allowance must not grow over time in which no price was observed. Capping the elapsed term (tried with a one-day cap on a copy of the tree) makes the proof pass: 0 of 8 buybacks run and the attacker loses 33.3 IMD. A slow organic rise during an idle period then has to be recorded by someone, for example a permissionless `poke()` (also run by every buyback) that moves the reference towards the spot price by at most the allowance accrued since the previous poke; `test_organicRiseOnlyDelays` would need pokes during its wait. That changes the agreed rule, so it is the requester's decision. Until then, anyone can keep the allowance from building up by running a buyback regularly (1 wei of IMD in the reserve is enough for a call to succeed), but nothing in the contracts makes that happen. The design-preserving fix for the re-anchoring finding below does not close this one: with it alone this proof still fails with 8 of 8 buybacks running.","line":152,"path":"contracts/src/PepesBuyback.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PepesBuyback} from \"src/PepesBuyback.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract MockIMD {\n    string public name = \"Identity.md\";\n    string public symbol = \"IMD\";\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev Launchpad A needs a wired buyback of its own (never used here): this reports an initialised IMD pair.\ncontract WiringStub {\n    PoolKey internal key;\n\n    function setKey(PoolKey memory k) external {\n        key = k;\n    }\n\n    function pad() external view returns (address) {\n        return address(this);\n    }\n\n    function poolKey(address) external view returns (PoolKey memory) {\n        return key;\n    }\n}\n\n/// @notice IMD Swarm re-check of audit ec4e3ea7, finding 1 (paced buyback front-run).\n///         Same world as test/PepesBuyback.t.sol: \"$Pepes\" is a token launched on launchpad A (4% hook fee,\n///         single-sided curve, like the live v1 $Pepes pool); launchpad B's PepesBuyback buys it through A's router.\n///         The scenario is the audit's proof (`test_pacedFrontRunStallsTheBuyback`) with ONE change: no buyback ran\n///         for 48 days before eve arrives. The 2%-per-day allowance kept accruing over those days, so her pump is\n///         inside the guard and every hourly buyback buys into it.\ncontract GuardIdleAllowanceTest is Test {\n    uint160 constant HOOK_FLAGS = 0x28CC;\n    int24 constant START_TICK = 161_000; // ~100 IMD starting market cap\n\n    PoolManager pm;\n    MockIMD imd;\n    PepesFamilyRouter routerA;\n    PadToken pepes;\n    PepesBuyback buyback;\n\n    address eve = makeAddr(\"eve\");\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockIMD();\n\n        WiringStub stub = new WiringStub();\n        (address c0, address c1) =\n            address(stub) < address(imd) ? (address(stub), address(imd)) : (address(imd), address(stub));\n        PoolKey memory k = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 3000, 60, IHooks(address(0)));\n        pm.initialize(k, TickMath.getSqrtPriceAtTick(0));\n        stub.setKey(k);\n\n        PepesFamily padA = _deployPad(address(stub), address(stub));\n        routerA = PepesFamilyRouter(payable(padA.router()));\n        pepes = PadToken(payable(padA.launch(\"Pepes\", \"PEPES\", \"\", address(imd))));\n        // a $Pepes pool with some depth: bob bought in earlier\n        imd.mint(bob, 1_000e18);\n        vm.startPrank(bob);\n        imd.approve(address(routerA), type(uint256).max);\n        routerA.buy(address(pepes), 1_000e18, 0, vm.getBlockTimestamp());\n        vm.stopPrank();\n\n        // the launchpad under review: its buyback buys \"$Pepes\" through launchpad A's router\n        buyback = PepesBuyback(_deployPad(address(pepes), address(routerA)).buyback());\n\n        imd.mint(eve, 10_000e18);\n        vm.startPrank(eve);\n        imd.approve(address(routerA), type(uint256).max);\n        pepes.approve(address(routerA), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function _deployPad(address pepes_, address pepesRouter_) internal returns (PepesFamily pad) {\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                address(this),\n                address(this),\n                START_TICK,\n                PepesFamily.ImdEthPool(10_000, 100, address(0)),\n                pepes_,\n                pepesRouter_\n            )\n        );\n        bytes32 initCodeHash = keccak256(initCode);\n        for (uint256 salt;; salt++) {\n            address a = address(\n                uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), initCodeHash))))\n            );\n            if (uint160(a) & 0x3FFF != HOOK_FLAGS) continue;\n            assembly {\n                pad := create2(0, add(initCode, 0x20), mload(initCode), salt)\n            }\n            require(address(pad) == a, \"hook address\");\n            return pad;\n        }\n    }\n\n    function test_pacedFrontRunAfterIdlePeriodMustNotPay() public {\n        // The only change to the audit's scenario: the last buyback (here: deployment) was 48 days ago. Nothing can\n        // expire during the first 7 days after a launch, and the reserve is empty between batches of expiries, so\n        // the buyback regularly sits idle; the reference time only moves when a buyback runs.\n        vm.warp(vm.getBlockTimestamp() + 48 days);\n\n        uint256 depth = buyback.maxBuyback() * 100;\n        imd.mint(address(buyback), depth / 5); // expired rewards arrive now (recycle() is callable by anyone)\n        uint256 start = imd.balanceOf(eve);\n\n        vm.startPrank(eve);\n        uint256 got = routerA.buy(address(pepes), (depth * 40) / 100, 0, vm.getBlockTimestamp());\n        uint256 runs;\n        for (uint256 h; h < 8; h++) {\n            vm.warp(vm.getBlockTimestamp() + 1 hours);\n            try buyback.buybackAndBurnPepes(0, vm.getBlockTimestamp()) {\n                runs++;\n            } catch {}\n        }\n        routerA.sell(address(pepes), got, 0, vm.getBlockTimestamp());\n        vm.stopPrank();\n\n        emit log_named_uint(\"buybacks that ran into the pump (of 8)\", runs);\n        emit log_named_decimal_uint(\"IMD the buyback spent\", buyback.totalImdSpent(), 18);\n        emit log_named_decimal_uint(\"eve's IMD before\", start, 18);\n        emit log_named_decimal_uint(\"eve's IMD after \", imd.balanceOf(eve), 18);\n        assertLe(imd.balanceOf(eve), start, \"front-running the paced buyback must not be profitable\");\n    }\n}","reproduction":"State: PepesBuyback whose last buyback (or deployment) was 48 days ago; $PEPES pool depth 1,061.9 IMD (`maxBuyback()` x 100); 212.4 IMD of expired rewards reach the buyback.\nSteps, which are `test_pacedFrontRunStallsTheBuyback` with one line added (`vm.warp(+48 days)` before eve buys):\n1. eve buys $PEPES with 424.76 IMD (40% of depth) through the pool's router. `priceRiseBps()` = 9,154, `allowedPriceRiseBps()` = 9,800.\n2. Once an hour for 8 hours anyone calls `buybackAndBurnPepes(0, deadline)`.\n3. eve sells everything she bought.\nExpected (README, the PepesBuyback notice, the existing test's two assertions): `PriceRisen` on every call, 0 buybacks, eve ends with less IMD than she started with.\nActual: 8 of 8 buybacks run and spend 121.60 IMD; eve ends with 10,021.52 IMD from 10,000.00, a gain of 21.52 IMD (17.7% of what the buyback spent).\nThe same steps on a local fork of Robinhood Chain (2026-10-06, the live $PEPES pool at a depth of 4,974.6 IMD, the real v1 router): 8 of 8 run, 569.7 IMD spent, eve +100.8 IMD.\nRun: `forge test --match-path test/scratch/GuardIdleAllowance.t.sol -vv` in contracts/. It fails with \"front-running the paced buyback must not be profitable: 10021521031196906105854 > 10000000000000000000000\".","severity":"medium","snippet":"        return MAX_PRICE_RISE_BPS + (PRICE_RISE_PER_DAY_BPS * (block.timestamp - refTime)) / 1 days;","title":"Buyback price guard: the 2%-per-day allowance accrues while no buyback runs, so after an idle period a one-block pump passes the guard and the audit's front-run pays again"},{"citation":"resolved","description":"After every buyback these two lines set the reference to the current spot price and restart the clock. The guard therefore limits the rise since the PREVIOUS buyback (2% plus 0.08% for the hour), not the rise over the series: whatever a third party bought between two buybacks is absorbed into the reference in full, one hour later. The third-party rise the guard tolerates is 2% per hour, about 60% a day compounded, where the notice and the README describe 2% plus 2% per day.\n\nAn attacker does not need the up-front 40% pump the guard was tested against. Before each hourly buyback she buys as much as the buyback itself (`maxBuyback()`, 1% of depth, about +1.9% in price, inside the 2%), is never blocked, and after N hours sells a position of about N% of depth into the price that N buybacks built. Each buyback moves the price about 1.9% and a round trip costs 8% in hook fees, so the series pays once it is long enough. In a constant-product model of the pool the break-even is at about 8 hourly buybacks, and the attacker keeps 14.6% of the buyback's spend after 24 hours, 34% after 48 and 49% after 72.\n\nMeasured on the code: 24 of 24 buybacks run, the buyback spends 337.1 IMD, the attacker gains 44.9 IMD (13.3%). On a local fork of the live $PEPES pool (depth 4,974.6 IMD): 24 of 24 run, 1,579.1 IMD spent, attacker +210.4 IMD. A day of buybacks needs a reserve of about a third of the pool's depth; the attacker can let expired rewards pile up unrecycled, or stall the buyback first (see the low-reference finding), and then recycle them in one go. Who loses is the same as in the previous finding: the expired rewards meant to support and burn $PEPES.\n\n`test_pacedFrontRunStallsTheBuyback` buys once, before the first buyback, and no test trades between two buybacks, so this path is untested.\n\nWhat fixing it takes, keeping the hourly series and the organic-rise behaviour: stop absorbing third-party rises at each buyback. Let the reference follow (a) the buyback's own price impact and (b) at most 2% per day of drift towards the pre-buyback price, and keep the flat 2% as a band above it. Tried on a copy of the tree: this proof passes (1 further buyback runs, the attacker loses 22.6 IMD) and all 5 tests of PepesBuyback.t.sol and all 56 of PepesFamily.t.sol still pass. A residual remains by design and belongs in the notice: one buy of up to 1% of depth before an uninterrupted series still gains from it (measured: +4.8 IMD on 10.6 IMD over 24 buybacks, 1.7% of the spend). Only a slower pace, under the roughly 4% of depth per day at which a round trip breaks even, removes that, which would change the agreed pace.","line":132,"path":"contracts/src/PepesBuyback.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PepesBuyback} from \"src/PepesBuyback.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract MockIMD {\n    string public name = \"Identity.md\";\n    string public symbol = \"IMD\";\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev Launchpad A needs a wired buyback of its own (never used here): this reports an initialised IMD pair.\ncontract WiringStub {\n    PoolKey internal key;\n\n    function setKey(PoolKey memory k) external {\n        key = k;\n    }\n\n    function pad() external view returns (address) {\n        return address(this);\n    }\n\n    function poolKey(address) external view returns (PoolKey memory) {\n        return key;\n    }\n}\n\n/// @notice IMD Swarm re-check of audit ec4e3ea7, finding 1 (paced buyback front-run).\n///         Same world as test/PepesBuyback.t.sol: \"$Pepes\" is a token launched on launchpad A (4% hook fee,\n///         single-sided curve, like the live v1 $Pepes pool); launchpad B's PepesBuyback buys it through A's router.\n///         No idle time here: the reference is fresh. Each buyback re-anchors the reference at its own post-buyback\n///         price, so the guard only limits the rise since the PREVIOUS buyback. Eve buys as much as the buyback\n///         itself (1% of depth, about +1.9% in price) before every hourly buyback and is never blocked; after a day\n///         she sells everything at a profit taken out of the IMD the buybacks put in.\ncontract GuardHourlyRatchetTest is Test {\n    uint160 constant HOOK_FLAGS = 0x28CC;\n    int24 constant START_TICK = 161_000; // ~100 IMD starting market cap\n\n    PoolManager pm;\n    MockIMD imd;\n    PepesFamilyRouter routerA;\n    PadToken pepes;\n    PepesBuyback buyback;\n\n    address eve = makeAddr(\"eve\");\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockIMD();\n\n        WiringStub stub = new WiringStub();\n        (address c0, address c1) =\n            address(stub) < address(imd) ? (address(stub), address(imd)) : (address(imd), address(stub));\n        PoolKey memory k = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 3000, 60, IHooks(address(0)));\n        pm.initialize(k, TickMath.getSqrtPriceAtTick(0));\n        stub.setKey(k);\n\n        PepesFamily padA = _deployPad(address(stub), address(stub));\n        routerA = PepesFamilyRouter(payable(padA.router()));\n        pepes = PadToken(payable(padA.launch(\"Pepes\", \"PEPES\", \"\", address(imd))));\n        // a $Pepes pool with some depth: bob bought in earlier\n        imd.mint(bob, 1_000e18);\n        vm.startPrank(bob);\n        imd.approve(address(routerA), type(uint256).max);\n        routerA.buy(address(pepes), 1_000e18, 0, vm.getBlockTimestamp());\n        vm.stopPrank();\n\n        // the launchpad under review: its buyback buys \"$Pepes\" through launchpad A's router\n        buyback = PepesBuyback(_deployPad(address(pepes), address(routerA)).buyback());\n\n        imd.mint(eve, 10_000e18);\n        vm.startPrank(eve);\n        imd.approve(address(routerA), type(uint256).max);\n        pepes.approve(address(routerA), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function _deployPad(address pepes_, address pepesRouter_) internal returns (PepesFamily pad) {\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                address(this),\n                address(this),\n                START_TICK,\n                PepesFamily.ImdEthPool(10_000, 100, address(0)),\n                pepes_,\n                pepesRouter_\n            )\n        );\n        bytes32 initCodeHash = keccak256(initCode);\n        for (uint256 salt;; salt++) {\n            address a = address(\n                uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), initCodeHash))))\n            );\n            if (uint160(a) & 0x3FFF != HOOK_FLAGS) continue;\n            assembly {\n                pad := create2(0, add(initCode, 0x20), mload(initCode), salt)\n            }\n            require(address(pad) == a, \"hook address\");\n            return pad;\n        }\n    }\n\n    function test_toppingUpBeforeEveryHourlyBuybackMustNotPay() public {\n        uint256 depth = buyback.maxBuyback() * 100;\n        imd.mint(address(buyback), (depth * 40) / 100); // many holders' rewards expired\n        // A first buyback now: the reference price and time are as fresh as they can be.\n        buyback.buybackAndBurnPepes(0, vm.getBlockTimestamp());\n        uint256 start = imd.balanceOf(eve);\n\n        vm.startPrank(eve);\n        uint256 bought;\n        uint256 runs;\n        for (uint256 h; h < 24; h++) {\n            vm.warp(vm.getBlockTimestamp() + 1 hours);\n            // +1% of depth moves the price about +1.9%: inside the 2% the guard tolerates since the last buyback\n            bought += routerA.buy(address(pepes), buyback.maxBuyback(), 0, vm.getBlockTimestamp());\n            try buyback.buybackAndBurnPepes(0, vm.getBlockTimestamp()) {\n                runs++;\n            } catch {}\n        }\n        routerA.sell(address(pepes), bought, 0, vm.getBlockTimestamp());\n        vm.stopPrank();\n\n        emit log_named_uint(\"hourly buybacks that ran (of 24)\", runs);\n        emit log_named_decimal_uint(\"pool depth at the start (IMD)\", depth, 18);\n        emit log_named_decimal_uint(\"IMD the buyback spent\", buyback.totalImdSpent(), 18);\n        emit log_named_decimal_uint(\"eve's IMD before\", start, 18);\n        emit log_named_decimal_uint(\"eve's IMD after \", imd.balanceOf(eve), 18);\n        assertLe(imd.balanceOf(eve), start, \"front-running the paced buyback must not be profitable\");\n    }\n}","reproduction":"State: reference fresh (a buyback ran in this block), reserve of 40% of the pool's depth (424.8 IMD at a depth of 1,061.9 IMD).\nSteps: every hour for 24 hours eve (1) buys $PEPES with exactly `buyback.maxBuyback()` IMD through the pool's router, then (2) calls `buybackAndBurnPepes(0, deadline)`. After the 24th hour she sells everything she bought.\nExpected (the property `test_pacedFrontRunStallsTheBuyback` asserts, \"front-running the paced buyback must not be profitable\"; README: \"Pumping $Pepes ahead of a series of buybacks stalls them instead of being sold into\"): the buybacks stall, or eve ends with less IMD than she started with.\nActual: 24 of 24 buybacks run and 337.07 IMD is spent in total; eve ends with 10,044.92 IMD from 10,000.00.\nRun: `forge test --match-path test/scratch/GuardHourlyRatchet.t.sol -vv` in contracts/. It fails with \"front-running the paced buyback must not be profitable: 10044917259745802076753 > 10000000000000000000000\".","severity":"medium","snippet":"        refSqrtPrice = _sqrtPrice();\n        refTime = uint64(block.timestamp);","title":"Buyback price guard: the reference is re-anchored at every buyback, so buying 1% of depth before each hourly buyback is never blocked and the series can be front-run"},{"citation":"resolved","description":"The hook decides who bought on this line: the user PepesFamilyRouter reports, and for every other caller of the PoolManager the transaction's signer. PepesFamilyEthRouter is deployed by the launchpad and is what the website uses for ETH payments. It knows its buyer (`r.user`, who receives the tokens) but passes empty hookData and is not recognised here, so its buys fall to `tx.origin` like any third-party router's.\n\n`tx.origin` is the buyer only for an EOA that signs its own transaction. For a multisig or smart account (an owner key or an ERC-4337 bundler signs), a sponsored or relayed transaction, or any contract that buys for itself, `markActive` records an address that receives nothing, and the buyer is left to the receipt rule in `PadToken._transfer`: first receipt, or at least a tenth of its bag. A top-up below a tenth of the bag therefore does not count, and anyone can `recycle()` the buyer's rewards a week after its first buy although it bought since. The mirror case exists too: a contract that calls PepesFamilyRouter on behalf of a user is recorded instead of that user.\n\nThis is a regression of the finding 3 fix. Before it, a receipt of 10,000 tokens or more counted for the recipient whatever the router or the signer, so the top-up below (2.2M tokens) kept the wallet active; the fix moved buy detection into the hook and left these buyers out.\n\nOn the three questions asked: nobody can make a buy mark a wallet that neither signed nor called. hookData is trusted only from PepesFamilyRouter, which encodes its own `msg.sender`, and otherwise the signer is used. A contract that a user calls can mark that user, which only postpones the user's own expiry. `markActive` itself is one storage write guarded by `msg.sender == pad`, makes no external call, and cannot make a buy revert, so calling it inside `afterSwap` adds no reentrancy or liveness problem. The defect is the opposite direction: buyers who are not marked.\n\nWhat fixing it takes: have PepesFamilyEthRouter pass `abi.encode(r.user)` on the token leg and accept `sender == ethRouter` next to `sender == router` here. Tried on a copy of the tree: this proof passes and the 56 tests of PepesFamily.t.sol still pass. For third-party routers `tx.origin` stays a best effort; say so where the notice and README promise \"any router\", or contract wallets and relayed buyers will rely on a guarantee they do not have. No test covers a buyer that is not the signer: the one third-party-router test pranks the buyer as both sender and origin, and the ETH router is exercised only by the fork tests (skipped without FORK_RPC), none of which reads `lastActive`.","line":353,"path":"contracts/src/PepesFamily.sol","reproduction":"State: a contract wallet W whose owner key O signs its transactions; a v4 token T; the launchpad's PepesFamilyEthRouter with its ETH/IMD pool.\nSteps:\n1. Day 0: O makes W call `PepesFamilyEthRouter.buyWithEth{value: 0.01 ether}(T, 0, deadline)`. W receives 84.65M T; first receipt, so `lastActive[W]` = day 0.\n2. Day 0: another trader buys T for 100 IMD through PepesFamilyRouter. W is owed 3.2945 IMD.\n3. Day 6: O makes W call `buyWithEth{value: 0.001 ether}`. W receives 2.22M T (2.6% of its bag).\n4. Day 7 plus 1 second: anyone calls `T.recycle(W)`.\nExpected (PadToken notice, lines 17-18: \"a wallet is active when it claims, buys (any amount, through any router: the pad's hook records the buyer)\"): `lastActive[W]` = day 6 and `expiredRewardsOf(W)` = 0.\nActual: `lastActive[W]` is still day 0 and `lastActive[O]` = day 6, although O holds nothing. `expiredRewardsOf(W)` = 3.2944 of the 3.2945 IMD owed, and `recycle(W)` sends it to the buyback one day after W's last buy.\nThe same happens through a third-party router (v4-core's PoolSwapTest standing in for one): W buys for 100 IMD on day 0 and for 5 IMD on day 6, and at day 7 plus 1 second 0.4188 of the 0.4188 IMD it is owed is recycled.\nRun: `forge test --match-path test/scratch/BuyerAttribution.t.sol -vv` in contracts/. It fails with \"a buy through the ETH router must restart the buyer's timer: 1800000000 != 1800518400\".","severity":"low","snippet":"        address trader = sender == router && hookData.length == 32 ? abi.decode(hookData, (address)) : tx.origin;","title":"Buys by a contract wallet or through a relayer are credited to tx.origin, not to the buyer, even through the launchpad's own ETH router, so the buyer's rewards expire a week after its first buy"},{"citation":"resolved","description":"The reference is the pool's spot price read inside the buyback call (`_sqrtPrice()`, stored on line 132), and the call is permissionless, so the caller can put their own trades around it. A $PEPES holder sells, calls `buybackAndBurnPepes`, and buys back, all in one transaction. The reference is recorded at the dumped price; once the price is back, `priceRiseBps()` exceeds the 2% and no v4 buyback runs until the 2% per day has caught up: (1/(1-d) - 1.02) / 0.02 days for a dip of d. The stall grows faster than its cost. The cost is the 4% hook fee each way on the amount moved; the duration grows with 1/(1-d).\n\nThe reserve does not have to hold anything worth buying. `imdIn` only has to be non-zero (line 122), so with an empty reserve the caller sends 1 wei of IMD first. Checked: a 1-wei buyback bought 1,549,006 wei of $PEPES, set `refSqrtPrice` and `refTime`, and took the hourly slot (the next call reverted `TooSoon` although 100 IMD had arrived in the meantime).\n\nMeasured (pool depth 1,061.9 IMD, 1 wei in the reserve, the holder buys back with exactly the IMD the sale returned):\n- sells 4.52M $PEPES for 45.9 IMD (4.3% of depth): the price ends 887 bps above the reference with 200 allowed, 4 days until a buyback can run; cost 0.34M $PEPES, about 3.8 IMD or 0.35% of depth\n- sells 18.1M for 161.6 IMD (15.2% of depth): 3,774 bps, 18 days; cost 1.21M $PEPES, about 13.4 IMD or 1.3% of depth\n- sells 45.2M for 326.4 IMD (30.7% of depth): 10,566 bps, 52 days; cost 2.47M $PEPES, about 27.3 IMD or 2.6% of depth\nAt the live pool's depth (about 4,975 IMD on 2026-10-06) the last line scales to about 128 IMD for 52 days, and the round trip can be repeated before the stall ends.\n\nImpact: no IMD is lost, but every v4 token's expired rewards wait for as long as the holder chooses, at a cost that is small next to the amounts held up; and a stall is what lets a reserve pile up for the hourly series of the re-anchoring finding. The existing tests cover a falling price (`test_fallingPriceNeverBlocks`) and a rise (`test_organicRiseOnlyDelays`), but not a dip that is undone right after the buyback.\n\nWhat fixing it takes: do not take the reference from one spot read that the caller can bracket. Each option is a trade-off for the requester: build the reference from observations taken over time (a permissionless poke, time-weighted), so a one-block dip moves it little; or let the reference fall by only a bounded step per buyback, accepting that after a genuine crash the guard is looser for a while. Independently, require a minimum spend (for example a share of `maxBuyback()`) before a call may move the reference and take the hourly slot, so that 1 wei cannot do it.","line":174,"path":"contracts/src/PepesBuyback.sol","reproduction":"State: the PepesBuyback.t.sol setup (pool depth 1,061.9 IMD; bob holds 904,033,088 $PEPES), buyback reserve empty, reference fresh.\nSteps, all in one block:\n1. bob sends 1 wei of IMD to the buyback.\n2. bob sells 45,201,654 $PEPES (5% of his bag) through the pool's router and receives 326.42 IMD.\n3. bob calls `buybackAndBurnPepes(0, deadline)`. It spends the 1 wei and records the dumped price as the reference.\n4. bob buys $PEPES back with the 326.42 IMD.\nThen expired rewards reach the buyback.\nExpected: buybacks keep running hourly (\"A falling price never blocks the buyback\"; a rise only delays it when it is organic).\nActual: `priceRiseBps()` = 10,566 against `allowedPriceRiseBps()` = 200. `buybackAndBurnPepes` reverts `PriceRisen` for 52 days, until the allowance reaches the gap. bob ends with the same IMD and 901,561,959 $PEPES, 2.47M fewer (about 27 IMD).\nWith a full reserve instead of 1 wei the same steps give 10,447 bps and 52 days; the buyback then spends 7.2 IMD at the low price, and bob's cost rises to 3.16M $PEPES.","severity":"low","snippet":"        (sqrtP,,,) = IPoolManager(poolManager).getSlot0(_poolId());","title":"Buyback price guard: a sell, buyback, buy-back round trip in one transaction pins the reference at a one-block low and stalls every v4 buyback for weeks"},{"citation":"resolved","description":"For a recipient that did not start the transfer, a receipt counts as activity when `amount * 10 >= balanceOf[to] - amount`, the recipient's balance before the transfer. When that balance is 0 the condition is true for 1 wei. (`lastActive[to]` is already set for such a wallet, so this is the tenth rule, not the first-receipt rule.)\n\nSo for a wallet that sold or moved its whole bag and left rewards unclaimed, the case `test_expiry_zeroBalanceHolderLosesEverythingOld` is about, anyone holds off the expiry for nothing. Staying at a tenth of the balance from zero took 874 wei of the token in total over 52 weekly gifts (checked), less than 10^-15 of one token, while the 45 IMD that wallet was owed never expired. More generally the rule prices a gift by the recipient's bag, not by the rewards at stake, so a wallet that sold down to dust is as cheap to keep alive as its dust.\n\nThis is a regression of the finding 3 fix for these wallets: before it, a gift had to be at least 10,000 tokens (`ACTIVITY_MIN`) to count. Impact is limited to the buyback: the rewards stay claimable by the wallet and never reach the burn. Nobody profits directly, which is why this is low.\n\nThe other edge cases asked about hold. A self-transfer marks the sender anyway, and `balanceOf[to] - amount` cannot underflow there because the balance is unchanged. A transfer from an excluded account (the PoolManager paying out a buy) marks nobody as sender and leaves the recipient to this rule, the hook marking the buyer separately. A first receipt starts the timer once, including for a wallet the hook marked before it ever held tokens, since its balance is 0. A zero-amount transfer never counts. `amount * 10` cannot overflow with a supply of 1e27.\n\nWhat fixing it takes: put an absolute floor next to the relative one, or do not count gifts at all once `lastActive` is set. Tried with a floor of one whole token on a copy of the tree: this proof passes and the 56 tests of PepesFamily.t.sol still pass. The floor's size is the requester's choice; the previous finding was about a fixed 10,000-token floor being wrong at high prices, so it should be small.","line":236,"path":"contracts/src/PadToken.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract MockIMD {\n    string public name = \"Identity.md\";\n    string public symbol = \"IMD\";\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev The launchpad needs a wired buyback (never used here): this reports an initialised IMD pair.\ncontract WiringStub {\n    PoolKey internal key;\n\n    function setKey(PoolKey memory k) external {\n        key = k;\n    }\n\n    function pad() external view returns (address) {\n        return address(this);\n    }\n\n    function poolKey(address) external view returns (PoolKey memory) {\n        return key;\n    }\n}\n\n/// @notice IMD Swarm re-check of audit ec4e3ea7, finding 3 (activity): the 1/10-of-balance gift rule.\n///         \"Holding off someone's expiry costs a tenth of their bag each time\" - a tenth of nothing is nothing. For a\n///         wallet that sold everything (the wallets `test_expiry_zeroBalanceHolderLosesEverythingOld` is about),\n///         `amount * 10 >= balanceOf[to] - amount` is true for 1 wei, so anyone resets its 7-day timer for free.\ncontract GiftRuleZeroBalanceTest is Test {\n    uint160 constant HOOK_FLAGS = 0x28CC;\n    int24 constant START_TICK = 161_000; // ~100 IMD starting market cap\n\n    PoolManager pm;\n    MockIMD imd;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PadToken token;\n\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockIMD();\n\n        WiringStub stub = new WiringStub();\n        (address c0, address c1) =\n            address(stub) < address(imd) ? (address(stub), address(imd)) : (address(imd), address(stub));\n        PoolKey memory k = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 3000, 60, IHooks(address(0)));\n        pm.initialize(k, TickMath.getSqrtPriceAtTick(0));\n        stub.setKey(k);\n\n        pad = _deployPad(address(stub), address(stub));\n        router = PepesFamilyRouter(payable(pad.router()));\n        token = PadToken(payable(pad.launch(\"Test\", \"TST\", \"\", address(imd))));\n\n        address[2] memory users = [bob, carol];\n        for (uint256 i; i < users.length; i++) {\n            imd.mint(users[i], 10_000e18);\n            vm.startPrank(users[i]);\n            imd.approve(address(router), type(uint256).max);\n            token.approve(address(router), type(uint256).max);\n            vm.stopPrank();\n        }\n    }\n\n    function _deployPad(address pepes_, address pepesRouter_) internal returns (PepesFamily p) {\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                address(this),\n                address(this),\n                START_TICK,\n                PepesFamily.ImdEthPool(10_000, 100, address(0)),\n                pepes_,\n                pepesRouter_\n            )\n        );\n        bytes32 initCodeHash = keccak256(initCode);\n        for (uint256 salt;; salt++) {\n            address a = address(\n                uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), initCodeHash))))\n            );\n            if (uint160(a) & 0x3FFF != HOOK_FLAGS) continue;\n            assembly {\n                p := create2(0, add(initCode, 0x20), mload(initCode), salt)\n            }\n            require(address(p) == a, \"hook address\");\n            return p;\n        }\n    }\n\n    function test_oneWeiGiftMustNotHoldOffAnExitedHoldersExpiry() public {\n        uint256 t0 = vm.getBlockTimestamp();\n        // day 0: bob buys, carol's buy pays him a holder reward, bob sells everything (his last own act)\n        vm.prank(bob);\n        uint256 bag = router.buy(address(token), 100e18, 0, t0);\n        vm.prank(carol);\n        router.buy(address(token), 500e18, 0, t0);\n        vm.prank(bob);\n        router.sell(address(token), bag, 0, t0);\n        assertEq(token.balanceOf(bob), 0);\n        uint256 owed = token.withdrawableDividendOf(bob);\n        assertGt(owed, 1e18, \"bob left more than 1 IMD unclaimed\");\n        assertEq(token.lastActive(bob), t0);\n\n        // day 6: carol sends bob 1 wei of the token (10^-18 of one token)\n        vm.warp(t0 + 6 days);\n        vm.prank(carol);\n        token.transfer(bob, 1);\n\n        // 7 days and 1 second after bob's last own act: everything he left unclaimed has expired\n        vm.warp(t0 + 7 days + 1);\n        emit log_named_decimal_uint(\"IMD bob left unclaimed          \", owed, 18);\n        emit log_named_decimal_uint(\"expired after the 1-wei gift    \", token.expiredRewardsOf(bob), 18);\n        assertEq(token.lastActive(bob), t0, \"a 1-wei gift is not bob's activity\");\n        assertEq(token.expiredRewardsOf(bob), owed, \"a 1-wei gift must not hold off the expiry\");\n    }\n}","reproduction":"State: bob bought a v4 token for 100 IMD, carol's 500 IMD buy paid him a holder reward, and he sold his whole bag the same day: balance 0, `lastActive[bob]` = day 0, 18.0 IMD unclaimed.\nSteps:\n1. Day 6: carol calls `token.transfer(bob, 1)`, 1 wei, 10^-18 of one token.\n2. Day 7 plus 1 second: anyone calls `token.recycle(bob)`.\nExpected (README: \"small gifts can't hold off another wallet's expiry, whatever the token's price\"; the constant's notice: \"holding off someone's expiry costs a tenth of their bag each time\"; `test_expiry_zeroBalanceHolderLosesEverythingOld`): `lastActive[bob]` stays day 0, `expiredRewardsOf(bob)` = 18.0 IMD, and `recycle` sends it to the buyback.\nActual: `lastActive[bob]` = day 6, `expiredRewardsOf(bob)` = 0, and `recycle(bob)` moves nothing. Repeating the gift once a week keeps it that way.\nRun: `forge test --match-path test/scratch/GiftRuleZeroBalance.t.sol -vv` in contracts/. It fails with \"a 1-wei gift is not bob's activity: 1800518400 != 1800000000\".","severity":"low","snippet":"                            || amount * GIFT_ACTIVITY_DIVISOR >= balanceOf[to] - amount","title":"Gift rule: a tenth of a zero balance is nothing, so a 1-wei transfer resets the 7-day timer of a wallet that sold everything"}],"hash":"7db544583dd7f39435f24842bdb347e679da900eb59f46177738422f231e3ed5","nodeId":"678d3111-6afa-4b08-a1ed-a22801a7d1c0","outcome":"completed","summary":"partial review: the turn budget ran out with 5 finding(s) written.\n","treeHash":null,"usage":{"cachedInputTokens":14191979,"inputTokens":107,"model":"claude-fable-5-1","outputTokens":226739,"runtime":"claude","turns":57,"wallClockMs":3927982}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"af9a875696459139","findings":[{"citation":"resolved","description":"PRELIMINARY. buybackAndBurnPepes() moves the reference to the post-buyback price after every run, so the 2% tolerance is measured from the buyback's own last push. A buyer who enters inside the tolerance is never stalled and each hourly buyback adds about 1.93% to the price, which beats the 4% + 4% round-trip fee after five buybacks.","line":132,"path":"contracts/src/PepesBuyback.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PepesBuyback} from \"src/PepesBuyback.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\n\ncontract Erc20Mock {\n    string public name = \"Mock\";\n    string public symbol = \"MOCK\";\n    uint8 public decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev Router/pad stand-in that only reports a pool key: wires launchpad A's own (unused) buyback.\ncontract KeyOnlyRouter {\n    PoolKey internal key;\n\n    function setKey(PoolKey memory k) external {\n        key = k;\n    }\n\n    function pad() external view returns (address) {\n        return address(this);\n    }\n\n    function poolKey(address) external view returns (PoolKey memory) {\n        return key;\n    }\n}\n\n/// @notice The price guard against a real priced pool, as in test/PepesBuyback.t.sol: \"$Pepes\" is a token launched\n///         on launchpad A (same 4% hook fee and single-sided curve as the live v1 $Pepes pool) and launchpad B's\n///         PepesBuyback buys it through A's router.\n///\n///         The audit's front-run (ec4e3ea7, finding 1), sized to stay inside the guard: eve buys 1% of the pool's\n///         IMD depth (price +1.92%, tolerance 2%), the buyback runs every hour for 8 hours, eve sells.\ncontract GuardInsideToleranceTest is Test {\n    uint160 constant FLAGS = uint160((1 << 13) | (1 << 11) | (1 << 7) | (1 << 6) | (1 << 3) | (1 << 2));\n    uint256 constant T0 = 1_800_000_000;\n\n    PoolManager pm;\n    Erc20Mock imd;\n    PepesFamilyRouter routerA;\n    PadToken pepes;\n    PepesBuyback buyback;\n\n    address eve = makeAddr(\"eve\");\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(T0);\n        pm = new PoolManager(address(this));\n        imd = new Erc20Mock();\n\n        // launchpad A needs a wired buyback of its own: point it at a plain mock pool\n        Erc20Mock mp = new Erc20Mock();\n        KeyOnlyRouter mr = new KeyOnlyRouter();\n        PoolModifyLiquidityTest lp = new PoolModifyLiquidityTest(pm);\n        mp.mint(address(this), 10_000e18);\n        imd.mint(address(this), 10_000e18);\n        mp.approve(address(lp), type(uint256).max);\n        imd.approve(address(lp), type(uint256).max);\n        (address c0, address c1) = address(mp) < address(imd) ? (address(mp), address(imd)) : (address(imd), address(mp));\n        PoolKey memory k = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 3000, 60, IHooks(address(0)));\n        pm.initialize(k, TickMath.getSqrtPriceAtTick(0));\n        lp.modifyLiquidity(k, ModifyLiquidityParams(-887220, 887220, 1_000e18, 0), \"\");\n        mr.setKey(k);\n\n        PepesFamily padA = _deployPad(address(mp), address(mr));\n        routerA = PepesFamilyRouter(payable(padA.router()));\n        pepes = PadToken(payable(padA.launch(\"Pepes\", \"PEPES\", \"\", address(imd))));\n        // a $Pepes pool with some depth: bob bought in earlier\n        imd.mint(bob, 10_000e18);\n        vm.startPrank(bob);\n        imd.approve(address(routerA), type(uint256).max);\n        routerA.buy(address(pepes), 1_000e18, 0, T0);\n        vm.stopPrank();\n\n        buyback = PepesBuyback(_deployPad(address(pepes), address(routerA)).buyback());\n\n        imd.mint(eve, 10_000e18);\n        vm.startPrank(eve);\n        imd.approve(address(routerA), type(uint256).max);\n        pepes.approve(address(routerA), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function _deployPad(address pepes_, address pepesRouter_) internal returns (PepesFamily) {\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                address(this),\n                address(this),\n                int24(161_000), // ~100 IMD starting market cap\n                PepesFamily.ImdEthPool(10_000, 100, address(0)),\n                pepes_,\n                pepesRouter_\n            )\n        );\n        bytes32 h = keccak256(initCode);\n        address deployed;\n        for (uint256 i; i < 500_000; i++) {\n            address a = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), h)))));\n            if (uint160(a) & 0x3FFF == FLAGS) {\n                bytes32 salt = bytes32(i);\n                assembly {\n                    deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n                }\n                break;\n            }\n        }\n        require(deployed != address(0), \"hook address\");\n        return PepesFamily(deployed);\n    }\n\n    function test_frontRunInsideToleranceMustNotPay() public {\n        uint256 depth = buyback.maxBuyback() * 100;\n        imd.mint(address(buyback), depth / 5); // same reserve as test_pacedFrontRunStallsTheBuyback\n        uint256 start = imd.balanceOf(eve);\n\n        // 1% of depth instead of the 40% of the audit's proof: the price moves less than the 2% tolerance\n        vm.prank(eve);\n        uint256 got = routerA.buy(address(pepes), depth / 100, 0, T0);\n        assertLe(buyback.priceRiseBps(), buyback.allowedPriceRiseBps(), \"entry is inside the guard\");\n\n        uint256 runs;\n        for (uint256 h = 1; h <= 8; h++) {\n            vm.warp(T0 + h * 1 hours);\n            try buyback.buybackAndBurnPepes(0, T0 + h * 1 hours) {\n                runs++;\n            } catch {}\n        }\n        vm.prank(eve);\n        routerA.sell(address(pepes), got, 0, T0 + 8 hours);\n\n        emit log_named_uint(\"buybacks that ran into eve's position\", runs);\n        emit log_named_decimal_uint(\"eve start IMD\", start, 18);\n        emit log_named_decimal_uint(\"eve end IMD\", imd.balanceOf(eve), 18);\n        // the property test_pacedFrontRunStallsTheBuyback asserts for a 40% entry\n        assertLe(imd.balanceOf(eve), start, \"front-running the paced buyback must not be profitable\");\n    }\n}","reproduction":"PRELIMINARY. Same harness as test/PepesBuyback.t.sol::test_pacedFrontRunStallsTheBuyback but eve buys depth/100 instead of depth*40/100. Expected: eve ends with no more IMD than she started with. Actual: 8 buybacks run and eve ends with 10000.775 IMD from 10000 (+7.3% on a 10.62 IMD entry).","severity":"low","snippet":"        refSqrtPrice = _sqrtPrice();\n        refTime = uint64(block.timestamp);","title":"Price guard re-arms after every buyback, so a front-run sized inside the 2% tolerance still profits (finding 1 not closed)"},{"citation":"resolved","description":"PRELIMINARY. The tolerance is 2% + 2% per day since the reference was set, unbounded, and the reference only moves when a buyback runs. A pump is therefore tolerated once enough days pass; the pumper is still in position when the series starts.","line":152,"path":"contracts/src/PepesBuyback.sol","reproduction":"PRELIMINARY. test_pacedFrontRunStallsTheBuyback, but eve holds for 46 days instead of selling: 8 buybacks then run, eve ends with 10021.52 IMD from 10000. Also: 8 days after deployment, a 6%-of-depth entry is inside the 18% tolerance and earns +4.41 IMD in 8 hours.","severity":"low","snippet":"        return MAX_PRICE_RISE_BPS + (PRICE_RISE_PER_DAY_BPS * (block.timestamp - refTime)) / 1 days;","title":"Guard tolerance grows without limit, so a pump only postpones the buyback series and the audit's own front-run pays again"},{"citation":"resolved","description":"PRELIMINARY. Any non-zero buyback, including 1 wei donated by the caller, moves refSqrtPrice to the spot price and restarts refTime and lastBuyback. After a 19% dip a 1-wei call pins the reference; when the price is back where it was the guard reads +22.9% and blocks the buyback for 11 days.","line":122,"path":"contracts/src/PepesBuyback.sol","reproduction":"PRELIMINARY. bob sells 1.25% of his bag (price -19%); eve transfers 1 wei IMD to the buyback and calls buybackAndBurnPepes; bob buys back; reserve funded. Expected: buyback runs. Actual: PriceRisen for 11 days.","severity":"low","snippet":"        if (imdIn == 0) revert BadAmount();","title":"A 1-wei buyback pins the price reference to a dip and stalls the funded buyback for days"},{"citation":"resolved","description":"PRELIMINARY. Only PepesFamilyRouter's hookData is trusted. The launchpad's own ETH router passes no hookData, so the hook marks tx.origin. For a smart-contract wallet that is the relayer.","line":353,"path":"contracts/src/PepesFamily.sol","reproduction":"PRELIMINARY. Smart wallet buys 10 ETH worth via ethRouter (day 0), earns 3.297 IMD, buys 0.2 ETH worth on day 6 with a relayer as tx.origin. Expected lastActive[wallet] = day 6 and recycle on day 8 returns 0. Actual lastActive[wallet] = day 0, lastActive[relayer] = day 6, recycle sends 3.297 IMD to the buyback.","severity":"low","snippet":"        address trader = sender == router && hookData.length == 32 ? abi.decode(hookData, (address)) : tx.origin;","title":"Buys through the ETH router mark tx.origin, so a smart-contract wallet's buy is credited to the relayer and its rewards still expire"},{"citation":"resolved","description":"PRELIMINARY. With a prior balance of zero the threshold is zero, so any non-zero receipt counts as the recipient's activity.","line":235,"path":"contracts/src/PadToken.sol","reproduction":"PRELIMINARY. bob buys, earns 0.6 IMD, sells everything on day 1. carol transfers 1 wei to bob on day 6 and day 7. Day 8 + 1s: expected recycle(bob) = 0.6 IMD, actual 0; lastActive[bob] = day 7.","severity":"low","snippet":"                        msg.sender == to || lastActive[to] == 0\n                            || amount * GIFT_ACTIVITY_DIVISOR >= balanceOf[to] - amount","title":"The 1/10 gift rule degenerates at a zero or dust balance: 1 wei from anyone holds off a sold-out wallet's expiry"}],"hash":"9dba96e6bc81725c5dbde410e6e81c60032c59b6c2f6ad120c8c97b93bcd63c1","nodeId":"a1bbbcd5-7057-477c-94e5-9b71a3b60864","outcome":"completed","summary":"partial review: the turn budget ran out with 5 finding(s) written.\n","treeHash":null,"usage":{"cachedInputTokens":14054826,"inputTokens":112,"model":"claude-fable-5-1","outputTokens":214430,"runtime":"claude","turns":57,"wallClockMs":4244442}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a406deaac63a93b0","findings":[],"hash":"d803f5a905f755e37bc9b2eb96faa35a9f2f1b7ad8c13e053a1e2131c710a90a","nodeId":"a1bbbcd5-7057-477c-94e5-9b71a3b60864","outcome":"failed","summary":"runtime reported <synthetic>, not the required premium model claude-fable-5-1","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"<synthetic>","outputTokens":0,"runtime":"claude","turns":1,"wallClockMs":2828}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"5835e48821d8827d","findings":[{"citation":"resolved","description":"The guard in buybackAndBurnPepes (line 118) compares the current $Pepes price with refSqrtPrice, and line 132 resets that reference to the pool price right AFTER each buyback, i.e. including the buyback's own ~1.9% impact and any pump that fitted under the allowance. Only the per-interval increment is bounded (200 bps + 8 bps per hour); the cumulative rise an attacker can build across a series is unbounded, and a holder who simply keeps a position never moves the price at all, so priceRiseBps() stays 0 and every hourly buyback runs. Each buyback injects 0.96% of the pool's IMD depth (1% cap minus the 4% hook fee) and, on this x*y=k curve, raises the $Pepes price by about 1.93%; a holder's position gains ~2x the injected fraction per buyback, while a round trip through the pool costs 8.2% in fees. Break-even is therefore ~4.3 buybacks (hours); anything beyond is extracted from the IMD the buybacks put into the pool. The fix for audit ec4e3ea7 finding 1 only defeats a single large pump (test_pacedFrontRunStallsTheBuyback uses 40% of depth); the paced form the finding described, and even a passive cap-sized hold, still win. The README's claim that 'pumping $Pepes ahead of a series of buybacks stalls them instead of being sold into' does not hold for pumps of <= ~1% of depth per hour. Minimal fix direction (design decision for the requester): do not let the reference absorb the buyback's own impact plus the pump every hour. E.g. keep the reference as the price BEFORE the first buyback of a day and let only the 2%/day allowance grow (so the series is bounded to ~2%/day, below the round-trip fee for any rider holding < 4 days), or compare against a 24h TWAP from the pool's own observations, or make the per-day IMD spend (not just per-hour) a small fraction of depth. Any of these changes the buyback pace, so it is a tradeoff the requester must choose.","line":132,"path":"contracts/src/PepesBuyback.sol","reproduction":"Harness identical to test/PepesBuyback.t.sol (launchpad A's $Pepes pool with the 4% hook, depth 1,061.9 IMD after bob's 1,000 IMD buy; launchpad B's buyback wired to it; 318.6 IMD = 30% of depth of expired rewards in the buyback). Paced variant: every hour eve buys exactly buyback.maxBuyback() (= 1% of current depth, raising the price ~1.93% < the 208 bps allowed after 1 h), then calls buybackAndBurnPepes(0, now). priceRiseBps() <= allowedPriceRiseBps() holds at every call; all 24 buybacks run (expected per README: the pump stalls them). After 24 h eve sells everything: eve's IMD balance is 100,042.72 vs 100,000 at start, +42.72 IMD = 13.4% of the 318.6 IMD the buybacks spent and 4% of pool depth. Passive variant: eve buys maxBuyback() once, holds through 24 hourly buybacks (priceRiseBps() == 0 each time), sells: +4.82 IMD on a 10.6 IMD position (+45%). Expected: a rider must not profit (assertLe(balance, start)); actual: both assertions fail on the current code. Run: forge test --match-path test/scratch/PacedBuybackRide.t.sol -vv.","severity":"medium","snippet":"        refSqrtPrice = _sqrtPrice();","title":"Price guard is bypassed by pacing the pump to the buyback's own size: riding the hourly series is profitable"},{"citation":"resolved","description":"priceRiseBps() treats any fall as 0, so a buyback always runs into a dumped price and then records that dumped price as refSqrtPrice (line 132). An attacker who already holds $Pepes can, in one transaction, sell, call buybackAndBurnPepes (allowed: the price fell), and buy back the same amount. The pool returns to roughly where it was, but the reference is now ~20% below it, and allowedPriceRiseBps() needs ~9 days to catch up. Unlike the documented 'pump stalls the buyback' case, the attacker holds no pumped position at risk afterwards: the stall is caused by the reference, not by a price the attacker has to defend, and the cycle can be repeated each time the allowance catches up. The IMD is not lost (it waits), so this is griefing of the buyback's pace only, but it is far cheaper than the pump the design assumed: cost is the 4%+4% fee on the dumped size plus the ~1% depth the buyback injects between sell and rebuy. Fix direction: record the reference as the price BEFORE the buyback's own swap (or the max of pre- and post-swap price), or do not lower the reference below the previous reference when the buyback itself ran into a price below it; combine with the finding above.","line":144,"path":"contracts/src/PepesBuyback.sol","reproduction":"Same harness (depth 1,061.9 IMD, buyback funded). Eve holds a 10%-of-depth position (bought 12 days earlier; the guard has caught up and a buyback ran, so refSqrtPrice is the pumped price). One hour later, in one transaction: eve sells all (priceRiseBps() == 0), calls buybackAndBurnPepes (succeeds, refSqrtPrice = dumped price), rebuys 10% of depth. Result: priceRiseBps() = 1,974 bps against the new reference; eve's net cost for the cycle 6.52 IMD (0.61% of depth); buybackAndBurnPepes reverts PriceRisen for the next 212 hourly attempts (8.8 days) with IMD waiting in the contract. Expected: a trader who ends the transaction with the same position they started with should not be able to stall buybacks for days; actual: 212-hour stall. Scratch test: ProbesTest.test_probe_dumpSetsLowReferenceAndStalls.","severity":"low","snippet":"        if (lo == 0 || hi <= lo) return 0;","title":"A dump right before a buyback sets a low reference and stalls all v4 buybacks for ~9 days at ~0.6% of depth"},{"citation":"resolved","description":"afterSwap passes `trader` to PadToken.markActive. Only swaps whose PoolManager sender is PepesFamilyRouter carry the user in hookData; every other buy, including the pad's own PepesFamilyEthRouter (it sends empty hookData, so `sender == router` is false), falls back to tx.origin. For a contract wallet (Safe, ERC-4337 account, any contract that holds the token) tx.origin is the EOA that relayed the transaction, so the wallet's own buy does not reset its 7-day timer while an unrelated EOA (a bundler or a Safe owner) is marked active. The NatSpec ('the buyer is ... the transaction's signer, which nobody can set for someone else') and the README ('buys, any amount, through any router: the pad's hook records the buyer') assume buyer == tx.origin, which is false exactly for the holders most likely to leave rewards unclaimed for a week. Their rewards older than 7 days can then be recycled by anyone although they bought. Nobody can mark a victim active against their will through this path (that would only help the victim), so the impact is limited to the wrong wallet being credited. Fix: have PepesFamilyEthRouter pass abi.encode(r.user) as hookData and accept `sender == ethRouter` in afterSwap; for third-party routers consider recording the recipient of the tokens instead (the `to` of the PoolManager's transfer, i.e. keep the first-receipt/gift rule as the fallback) rather than tx.origin.","line":353,"path":"contracts/src/PepesFamily.sol","reproduction":"ContractWallet w (a plain contract) buys the launched token through PoolSwapTest (stands in for Universal Router) with vm.prank(relayer, relayer) so tx.origin = relayer. First buy at t0: lastActive[w] = t0 (first-receipt rule). 6 days later w buys again the same way. Expected (per README): lastActive[w] = t0 + 6 days. Actual: lastActive[w] stays t0 and lastActive[relayer] = t0 + 6 days. One day later w's rewards older than 7 days are recyclable although it bought 25 hours earlier. Scratch test: ProbesTest.test_probe_contractWalletBuyMarksTheRelayer.","severity":"low","snippet":"        address trader = sender == router && hookData.length == 32 ? abi.decode(hookData, (address)) : tx.origin;","title":"Buys from contract wallets through any router but PepesFamilyRouter mark tx.origin (the relayer), not the buyer"},{"citation":"resolved","description":"The 1/10-of-prior-balance rule is meant to make holding off someone's expiry cost a tenth of their bag. When the recipient's prior balance is 0 (a holder who sold everything but never claimed, the exact case test_expiry_zeroBalanceHolderLosesEverythingOld covers) the condition is `amount * 10 >= 0`, true for 1 wei, and it stays true for 1-wei gifts until the balance reaches 10 wei, then for 2 wei, etc.: the cost stays dust forever. Any third party can therefore keep every exited holder's rewards alive indefinitely for gas plus dust, and since the exited holder has balance ~0, expiredRewardsOf's 'recent' term is 0, so the whole unclaimed amount is what is being kept away from the buyback. It also means the other direction of the fix is weaker than documented for small bags generally (prior balance of a few wei). Impact is griefing of the buyback (rewards stay claimable by their owner, nothing is lost), so low. Fix: require a minimum absolute gift as well (e.g. `amount * GIFT_ACTIVITY_DIVISOR >= prior && amount >= MIN_GIFT`), or do not count gifts as activity at all now that buys are recorded by the hook (the first-receipt rule can stay).","line":236,"path":"contracts/src/PadToken.sol","reproduction":"carol and eve buy 100 IMD each; carol has withdrawable rewards (owed > 0) and sells her whole balance without claiming (balance 0). Every 7 days eve calls pepes.transfer(carol, 1). Expected (per PadToken notice: a receipt counts only when it is at least a tenth of what the recipient holds, 'holding off someone's expiry costs a tenth of their bag'): no activity, carol's rewards expire after 7 days and recycle(carol) sends them to the buyback. Actual: lastActive[carol] is reset by each 1-wei gift; after 10 cycles (77 days) expiredRewardsOf(carol) == 0, recycle(carol) returns 0, withdrawableDividendOf(carol) == owed. Scratch test: ProbesTest.test_probe_oneWeiGiftKeepsExitedHolderAlive.","severity":"low","snippet":"                            || amount * GIFT_ACTIVITY_DIVISOR >= balanceOf[to] - amount","title":"Gift rule degenerates at zero balance: a 1-wei transfer keeps an exited holder's unclaimed rewards from ever expiring"}],"hash":"e8041970019620cd1adfc92ef2e07d406689b83f6f21a26bc9a807c0b6ebb479","nodeId":"d1d2dfc2-8c11-49ec-825b-b2a8c23e30a5","outcome":"completed","summary":"The review is complete. Four findings are in `.imd-findings.json`, all snippets verified against the tree, and the only new files are under `contracts/test/scratch/`.\n\n**Headline: the finding 1 fix does not hold.** The price guard resets its reference to the price right after each buyback, so it bounds only the per-hour increment, never the cumulative pump. An attacker who buys exactly the buyback's own cap size each hour stays under the 208 bps allowance, rides every buyback, and sells at a profit. Even a single cap-sized buy held passively through the series profits. Each buyback lifts the price ~1.93% while a round trip costs 8.2%, so break-even is about 4 buybacks. The proof test is embedded in the findings file and fails on current code.\n\n| Scenario (24 hourly buybacks, 1,062 IMD depth) | Attacker result |\n| --- | --- |\n| Paced 1%-of-depth buy before each buyback | +42.7 IMD, 13.4% of what the buybacks spent |\n| One cap-sized buy, hold, sell | +4.8 IMD on a 10.6 IMD position |\n\n**Other findings, all low:**\n- **Dump sets a low reference.** Sell, call the buyback (a fall is never a rise), rebuy in one transaction. The attacker ends with the same position and the buyback stalls for 212 hours at a cost of 0.6% of depth. Cheaper griefing than the pump the design assumed.\n- **tx.origin marks the relayer, not the buyer.** Contract wallets buying through anything other than PepesFamilyRouter never reset their own timer. This includes the pad's own ETH router, which sends empty hookData.\n- **Gift rule degenerates at zero balance.** A 1-wei transfer to an exited holder passes `amount × 10 ≥ 0`, so anyone can keep that holder's unclaimed rewards from expiring for dust, indefinitely.\n\n**Checked and found sound:** priceRiseBps cannot overflow within the real pool's single-sided range; the empty-liquidity price push always reads as a fall and returns 0; the gift rule's subtraction cannot underflow; markActive in afterSwap cannot revert or reenter; nobody can mark another wallet active without that wallet signing; the whole-balance burn and wiring check behave as intended. No v3 guarantee or earlier fix regressed, and the full in-scope suite of 61 tests still passes.\n\nNot covered: fork tests were not run (no network use), so the real $Pepes pool depth is unknown. The attack's profitability only requires the reserve to hold about 5 hours of buybacks, which expired rewards will plausibly reach.","treeHash":null,"usage":{"cachedInputTokens":2739705,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":53585,"runtime":"claude","turns":40,"wallClockMs":1005969}}],"verification":[]}