{"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":"964f33f1-8840-4eec-9489-b908591ddbeb","kind":"audit","nodes":[{"acceptedSubmissionHash":"c227034927f485962997885d97b7ae8e424ffde68a7de351136bf5ea431f97a7","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"54e435b14488165cb2cd8cf456cd06d733768f9b4455dfb9e9f2a18769c2bcc9","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"d1b6311f93af8d08e7efea8824ccedb3e47cc325c70d9460221bf2f8f804ab05","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"7e4b1612d71b3a48a487874c4298bc9b1633ce17676d3b8c07c48be02c6e2c47","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"304b6f8119524a26f62e1985452c329f3a317c04a175f7fbd23b63212c0d67fe","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the contracts in 0xtenang/PepesFamily as they are.","parentJobId":null,"planHash":"d1dd6b10544871ccd88e22984bb740da6fa2b509359a6e7c5d9a90a2aec84688","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"964f33f1-8840-4eec-9489-b908591ddbeb","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":"52253","feedbackHash":"0e4cd36b0a2bf7b87c23c667aa713cf0461b27b7a4148c302eebdf69e130bdee","nodeKey":"audit_economics","submissionHash":"c227034927f485962997885d97b7ae8e424ffde68a7de351136bf5ea431f97a7","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52173","feedbackHash":"6c29750828fdbed8aa050a8ee1dae1cdf7f85a193808a3966df70bbd371368b2","nodeKey":"audit_flow","submissionHash":"54e435b14488165cb2cd8cf456cd06d733768f9b4455dfb9e9f2a18769c2bcc9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51880","feedbackHash":"86d971b7480c00a01fd09024d651597824382ee3eedc7a905589b01b7917a252","nodeKey":"audit_judge","submissionHash":"d1b6311f93af8d08e7efea8824ccedb3e47cc325c70d9460221bf2f8f804ab05","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52701","feedbackHash":"945d1d8134d5fa98083f9cf642cf7b754f0b1da99e3d45dcea1825c76c9f5246","nodeKey":"audit_math","submissionHash":"7e4b1612d71b3a48a487874c4298bc9b1633ce17676d3b8c07c48be02c6e2c47","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52391","feedbackHash":"619ba50c0ed315370098fee16b63d1d1d7277c504a56ebc725deb0dd49f43b29","nodeKey":"audit_permissions","submissionHash":"304b6f8119524a26f62e1985452c329f3a317c04a175f7fbd23b63212c0d67fe","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"3b424c6a0223e2ef7210de22064e57482ea92dadee1d46847317843db6e94ab5","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"686fbab25113a42b","findings":[{"citation":"resolved","description":"A token launched through PepesFamilyRemote.launchFor has the remote contract as its creator on the launchpad, so PepesFamily.creatorPayout[token] == address(remote) (contracts/src/PepesFamily.sol:301). PepesFamily.collectCreatorFees(token) (contracts/src/PepesFamily.sol:497) is callable by anyone and takes the pending IMD to creatorPayout, i.e. into the remote contract. PepesFamilyRemote.collectCreatorFees, the only path that pays the real creator (creatorOf[token]), forwards only the quote-balance delta of its own call to the launchpad. IMD that reached the remote through a direct launchpad call is therefore never forwarded: the remote has no sweep and no owner withdrawal ('It can't move any funds'), and later calls measure a zero delta. Because every token shares IMD as quote, the stranded balance cannot be attributed to a token afterwards. Access gap: the launchpad lets an unprivileged caller choose the moment the fee moves, while the remote's accounting assumes it is the only caller. A griefer can front-run every PepesFamilyRemote.collectCreatorFees(token) with PepesFamily.collectCreatorFees(token) and lock 100% of that creator's fees (up to 2% of every trade) until the creator completes handOver + acceptCreatorPayout; everything locked before that is lost for good. An ordinary keeper or explorer user calling the public launchpad function causes the same loss by accident. Fix: do not let the remote be a payout address that direct collections pay into without attribution. Deploy a minimal per-token payout escrow (CREATE2 clone) as the launchpad creatorPayout for remote launches, whose only function forwards its whole quote balance to creatorOf[token]; or, at launch, immediately call pad.setCreatorPayout(token, creator) so the creator only has to accept, and in collectCreatorFees read pad.pendingCreatorFees(token) before the call and forward that amount. At minimum add a creator-callable sweep of the remote's quote balance so already-stranded fees are recoverable.","line":157,"path":"contracts/src/PepesFamilyRemote.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 {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PepesFamilyRemote, IRemotePad} from \"src/PepesFamilyRemote.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\ncontract ScratchWETH {\n    string public name = \"Wrapped Ether\";\n    string public symbol = \"WETH\";\n    uint8 public decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    receive() external payable {\n        deposit();\n    }\n\n    function deposit() public payable {\n        balanceOf[msg.sender] += msg.value;\n    }\n\n    function withdraw(uint256 wad) external {\n        balanceOf[msg.sender] -= wad;\n        (bool ok,) = msg.sender.call{value: wad}(\"\");\n        require(ok, \"withdraw\");\n    }\n\n    function approve(address s, uint256 a) external returns (bool) {\n        allowance[msg.sender][s] = a;\n        return true;\n    }\n\n    function transfer(address to, uint256 a) external returns (bool) {\n        return transferFrom(msg.sender, to, a);\n    }\n\n    function transferFrom(address f, address to, uint256 a) public returns (bool) {\n        if (f != msg.sender && allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\n/// @notice A token launched through PepesFamilyRemote has the remote contract as its `creatorPayout` on the\n///         launchpad. The launchpad's `collectCreatorFees(token)` is callable by anyone and pays `creatorPayout`,\n///         so anyone can make the creator fees land in the remote contract outside `PepesFamilyRemote.collectCreatorFees`,\n///         which only forwards the balance delta of its own call. The fees are then stuck in the remote forever.\ncontract RemoteStrandedCreatorFeesTest is Test {\n    PoolManager pm;\n    ScratchWETH weth;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PepesFamilyRemote remote;\n    address owner = makeAddr(\"owner\");\n    address filler = makeAddr(\"filler\");\n    address alice = makeAddr(\"alice\"); // the real creator, who paid on another chain\n    address bob = makeAddr(\"bob\"); // a trader\n    address griefer = makeAddr(\"griefer\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        weth = new ScratchWETH();\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(weth),\n                owner,\n                makeAddr(\"fees\"),\n                DeployLib.startTickForMarketCap(1.5e18),\n                PepesFamily.ImdEthPool(0, 0, address(0))\n            )\n        );\n        (bytes32 salt,) = DeployLib.mineSalt(address(this), uint160(0x28CC), initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        pad = PepesFamily(deployed);\n        router = PepesFamilyRouter(payable(pad.router()));\n        address[] memory pads = new address[](1);\n        pads[0] = address(pad);\n        remote = new PepesFamilyRemote(owner, pads);\n        vm.deal(filler, 100 ether);\n        vm.deal(bob, 100 ether);\n        vm.prank(bob);\n        weth.deposit{value: 50 ether}();\n        vm.prank(bob);\n        weth.approve(address(router), type(uint256).max);\n    }\n\n    function test_anyoneCanStrandRemoteCreatorFees() public {\n        // Alice launches cross-chain: the remote contract is the creator payout on the launchpad.\n        vm.prank(filler);\n        (address token,) = remote.launchFor(\n            address(pad), alice, \"Remote\", \"RMT\", \"{}\", IRemotePad.FeeSplit(100, 100, 100), 0, 0, 0\n        );\n        assertEq(pad.creatorPayout(token), address(remote));\n\n        // A trade accrues 1% creator fee: 0.1 WETH on a 10 WETH buy.\n        vm.prank(bob);\n        router.buy(token, 10 ether, 1, block.timestamp);\n        assertEq(pad.pendingCreatorFees(token), 0.1 ether);\n\n        // Anyone calls the launchpad's public collector directly: the fee lands in the remote contract.\n        vm.prank(griefer);\n        pad.collectCreatorFees(token);\n        assertEq(pad.pendingCreatorFees(token), 0);\n        assertEq(weth.balanceOf(address(remote)), 0.1 ether, \"fee sits in the remote contract\");\n\n        // The remote's forwarder measures only the delta of its own call, so it forwards nothing.\n        uint256 a0 = weth.balanceOf(alice);\n        uint256 fwd = remote.collectCreatorFees(token);\n\n        // Expected: the creator receives her 0.1 WETH. Actual: zero is forwarded and the WETH stays in the remote,\n        // which has no function that can move it.\n        assertEq(fwd, 0.1 ether, \"forwarded amount\");\n        assertEq(weth.balanceOf(alice) - a0, 0.1 ether, \"creator paid\");\n        assertEq(weth.balanceOf(address(remote)), 0, \"remote keeps nothing\");\n    }\n}","reproduction":"State: remote.launchFor(pad, alice, ..., FeeSplit(100,100,100), 0, 0, 0) so pad.creatorPayout(token) == address(remote). Bob buys with 10 WETH through PepesFamilyRouter.buy: pad.pendingCreatorFees(token) == 0.1e18. Input: griefer (any address) calls pad.collectCreatorFees(token); then anyone calls remote.collectCreatorFees(token). Expected: alice receives 0.1e18 quote. Actual: pad.collectCreatorFees takes 0.1e18 to the remote; remote.collectCreatorFees returns 0 and forwards nothing; weth.balanceOf(remote) stays 0.1e18 and no function can move it. Proof test test/scratch/RemoteStrandedCreatorFees.t.sol (run from contracts/) fails with 'forwarded amount: 0 != 100000000000000000'.","severity":"high","snippet":"        uint256 before = IRemoteToken(quote).balanceOf(address(this));\n        IRemotePad(pad).collectCreatorFees(token);\n        amount = IRemoteToken(quote).balanceOf(address(this)) - before;\n        if (amount != 0) quote.transferOut(creator, amount);","title":"PepesFamilyRemote: anyone can strand a remote-launched token's creator fees by calling the launchpad's public collectCreatorFees first"},{"citation":"resolved","description":"PepesFamily.afterSwap (contracts/src/PepesFamily.sol:430-434) records a buy as activity of the address the PepesFamily router reports in hookData, and the ETH/native routers report their own msg.sender (PepesFamilyNativeRouter.sol:141, PepesFamilyEthRouter.sol:135: abi.encode(r.user) with r.user = msg.sender). When PepesFamilyRemote buys for a cross-chain user, that msg.sender is the remote contract, so PadToken.markActive(remote) runs and the real buyer is never marked. The subsequent token.transferOut(to, tokens) only sets lastActive[to] on a first receipt (PadToken.sol:233: msg.sender == to || lastActive[to] == 0). PadToken's documented rule is that a buy of any size through any router is the buyer's own activity (PadToken.sol:18-20, AUDIT.md section 'Also live: v4'). Asymmetry between the direct router path and the remote path: a repeat buyer who only trades cross-chain keeps a stale timer, PadToken.expiredRewardsOf treats every reward older than 7 days as expired, and anyone can call PadToken.recycle(buyer) to send it to feeRecipient. The victim loses funds they would keep had they bought through the router directly. Fix: give the routers a recipient parameter (e.g. buyWithEthFor(token, recipient, minOut, deadline)) that is used both as the hookData user and as the take() destination, and have the remote pass the real buyer; or have the remote call a pad-side markActiveFor that only the remote (allowlisted) may use.","line":207,"path":"contracts/src/PepesFamilyRemote.sol","reproduction":"State: remote.launchFor{value: 1 ether}(pad, alice, ..., FeeSplit(0,300,0), buyValue 1 ether, 1, 0) at t0, then bob buys 10 WETH worth through the router so alice has rewards earned at t0. Input: at t0 + 6 days the filler calls remote.buyFor{value: 0.5 ether}(pad, token, alice, 1, 0). Expected: lastActive(alice) == t0 + 6 days (a buy is activity), expiredRewardsOf(alice) == 0 at t0 + 7 days + 1. Actual (test/scratch/RemoteActivity.t.sol logs): lastActive(alice) stays 1800000000 (t0), lastActive(remote) == 1800518400 (t0 + 6 days), and at t0 + 7 days + 1 expiredRewardsOf(alice) == withdrawableDividendOf(alice) == 336624218273969879 wei, so recycle(alice) by anyone sends all of it to feeRecipient.","severity":"medium","snippet":"        tokens = IRemoteEthRouter(IRemotePad(pad).ethRouter()).buyWithEth{value: value}(token, minTokens, block.timestamp);\n        token.transferOut(to, tokens);","title":"PepesFamilyRemote buys mark the remote contract active instead of the buyer, so the buyer's holder rewards expire and anyone can recycle them despite a recent purchase"},{"citation":"resolved","description":"Two handover paths exist for a remote-launched token: handOver (proposes creatorOf[token] as the launchpad's creatorPayout via pad.setCreatorPayout, which the creator later accepts) and transferCreator (changes creatorOf in the remote only). They are not mirrored: transferCreator does not cancel a proposal the old creator already made on the launchpad (pad.pendingCreatorPayout[token]). After handOver then transferCreator(to), the old creator can still call pad.acceptCreatorPayout(token) and becomes the launchpad's creatorPayout, taking every future creator fee directly. The new creator's handOver then reverts on the launchpad (PepesFamily.setCreatorPayout checks msg.sender == creatorPayout, which is now the old creator, PepesFamily.sol:506), and remote.collectCreatorFees forwards nothing because the launchpad no longer pays the remote. Fix: in transferCreator, if pad.pendingCreatorPayout(token) != address(0) call pad.setCreatorPayout(token, address(0)) to cancel it (the remote is still the launchpad payout at that point); and revert transferCreator/handOver when pad.creatorPayout(token) != address(this), since the role has already left the remote.","line":174,"path":"contracts/src/PepesFamilyRemote.sol","reproduction":"State: token launched via remote.launchFor with creator alice. Input sequence: (1) alice calls remote.handOver(token): pad.pendingCreatorPayout(token) == alice. (2) alice calls remote.transferCreator(token, bob): remote.creatorOf(token) == bob. (3) alice calls pad.acceptCreatorPayout(token). Expected: bob holds the creator role and receives creator fees. Actual: pad.creatorPayout(token) == alice; bob's remote.handOver(token) reverts with PepesFamily.NotCreator; after a trade, pad.collectCreatorFees(token) pays alice and remote.collectCreatorFees(token) returns 0.","severity":"low","snippet":"    function transferCreator(address token, address to) external {\n        if (msg.sender != creatorOf[token]) revert NotCreator();\n        if (to == address(0)) revert ZeroAddress();\n        creatorOf[token] = to;","title":"PepesFamilyRemote.transferCreator leaves a pending launchpad-side handover in place, so the old creator can keep the fee role after passing it on"}],"hash":"304b6f8119524a26f62e1985452c329f3a317c04a175f7fbd23b63212c0d67fe","nodeId":"ec4c7413-633d-4c1a-a48d-4754375cc643","outcome":"completed","summary":"The review is complete. Findings are in `.imd-findings.json` at the repository root; no source or configuration files were changed, and the only new files are scratch tests under `contracts/test/scratch/`.\n\n**Scope covered.** Both Foundry projects were built and read in full for the permissions area: the Long/Short perps system (LevExchange, LevExchangeAdmin, LevExchangeBase, LevPool, LevRouter, LevLens, the three oracles, deploy scripts) and the launchpad (PepesFamily hook, PadToken, the three routers, PepesFamilyRemote, PepesWorldVault, PepesEarnIMD, PepesEarnToken, PepesEarnMirror). I mapped every state-changing entry point, its guard and who holds each role after deployment, then ran the Access Control, Trust Gap and Asymmetry checks from the pinned guides. The existing suites pass on both projects with the scratch tests excluded.\n\n**Three findings, all in the cross-chain remote contract:**\n\n- **High, with a failing Foundry proof.** The launchpad's public `collectCreatorFees` pays the remote contract, which is the creator payout for every remote-launched token. The remote only forwards the balance delta of its own call, so anyone who calls the launchpad first strands the creator's fees in the remote forever. The proof test fails on current code and asserts the behaviour a fix must restore.\n- **Medium.** Buys relayed through the remote mark the remote contract active, not the real buyer, because the routers report their own caller to the hook. A buyer who only trades cross-chain keeps a stale timer and all their holder rewards become recyclable by anyone seven days after their first receipt, even a day after a fresh purchase. Verified with logged values.\n- **Low.** The remote's two handover paths are not mirrored. A creator can propose themselves on the launchpad, then transfer the role in the remote, then accept on the launchpad and keep every future fee. Verified with a scratch test.\n\n**Perps result.** The permission model matches the audit brief: owner powers are bounded by `ConfigCheck`, the guardian can only pause, the router is set once, the pool's exchange-only paths are guarded, the delegatecall split shares one layout and one reentrancy slot, and session intents are bound to owner, key, generation, nonce, epoch and deadline. I found no permission bypass there. One lead I did not pursue because it is economic rather than a permission gap: an unprivileged actor holding a thin coin's spot price under the rug threshold keeps the pool valuation blocked, which stalls LP withdrawal requests.\n\n**Not done.** No fork tests were run, and the main project's scratch tests cover only the remote contract. The perps `claimTreasury` burns ETH if the treasury is ever set to zero, but that needs an owner misconfiguration and was left out as admin trust.","treeHash":null,"usage":{"cachedInputTokens":2971671,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":40700,"runtime":"claude","turns":55,"wallClockMs":933697}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c2e167266d13dcf4","findings":[{"citation":"resolved","description":"PadToken's expiry rule (contracts/src/PadToken.sol:19-25 and :239-245, AUDIT.md section 'Also live: v4') says a wallet is active when it buys through any router: afterSwap calls markActive(trader) on every buy. For the pad's own routers the trader is the address in hookData, which PepesFamilyEthRouter/PepesFamilyNativeRouter fill with their msg.sender (Route.user, contracts/src/PepesFamilyNativeRouter.sol:77 and :141). PepesFamilyRemote (added 2026-10-27, commit 8d2a8ee) buys on behalf of a user by calling ethRouter.buyWithEth itself (contracts/src/PepesFamilyRemote.sol:207), so hookData carries the Remote's address and markActive is applied to the Remote contract. The Remote then forwards the tokens with transfer (:208); PadToken._update only records the recipient's activity when msg.sender == to or on the first receipt (contracts/src/PadToken.sol:233). A user who already holds the token and buys again only through the cross-chain path therefore never refreshes lastActive. After 7 days without a claim or send, expiredRewardsOf returns their rewards older than 7 days and anyone can call recycle(holder) to move them to the pad's feeRecipient, contradicting the documented rule and the Remote's own notice ('Tokens bought go straight to the user'). Fix: let the Remote report the end user to the hook. E.g. add buyWithEthFor(address user, ...) to PepesFamilyEthRouter and PepesFamilyNativeRouter that puts `user` in Route.user/hookData (and keeps paying out to msg.sender, the Remote, so the Remote's forwarding and refund logic is unchanged), and have PepesFamilyRemote._buy call it with `to`. Alternatively the Remote could be excluded in PadToken and have the user's receipt from the Remote count, but the router change keeps the existing activity rules intact.","line":430,"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 {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PepesFamilyRemote, IRemotePad} from \"src/PepesFamilyRemote.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\n/// @dev WETH9-like wrapped native coin, used as the pad's pair asset so the pad deploys a PepesFamilyNativeRouter.\ncontract ScratchWETH {\n    string public name = \"Wrapped Ether\";\n    string public symbol = \"WETH\";\n    uint8 public decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    receive() external payable {\n        deposit();\n    }\n\n    function deposit() public payable {\n        balanceOf[msg.sender] += msg.value;\n    }\n\n    function withdraw(uint256 wad) external {\n        balanceOf[msg.sender] -= wad;\n        (bool ok,) = msg.sender.call{value: wad}(\"\");\n        require(ok, \"withdraw\");\n    }\n\n    function approve(address s, uint256 a) external returns (bool) {\n        allowance[msg.sender][s] = a;\n        return true;\n    }\n\n    function transfer(address to, uint256 a) external returns (bool) {\n        return transferFrom(msg.sender, to, a);\n    }\n\n    function transferFrom(address f, address to, uint256 a) public returns (bool) {\n        if (f != msg.sender && allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\n/// @notice A buy through PepesFamilyRemote must count as the end buyer's activity (PadToken: \"a buy of any size,\n///         through any router, is the buyer's own activity\"). Today the hook marks the Remote contract instead, so\n///         a repeat cross-chain buyer stays \"inactive\" and anyone can recycle their older rewards to feeRecipient.\ncontract RemoteActivityTest is Test {\n    PoolManager pm;\n    ScratchWETH weth;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PepesFamilyRemote remote;\n    address owner = makeAddr(\"owner\");\n    address fees = makeAddr(\"fees\");\n    address filler = makeAddr(\"filler\");\n    address alice = makeAddr(\"alice\"); // pays on another chain; the filler calls the Remote for her\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        weth = new ScratchWETH();\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(weth),\n                owner,\n                fees,\n                DeployLib.startTickForMarketCap(1.5e18),\n                PepesFamily.ImdEthPool(0, 0, address(0))\n            )\n        );\n        (bytes32 salt,) = DeployLib.mineSalt(address(this), uint160(0x28CC), initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        pad = PepesFamily(deployed);\n        router = PepesFamilyRouter(payable(pad.router()));\n        address[] memory pads = new address[](1);\n        pads[0] = address(pad);\n        remote = new PepesFamilyRemote(owner, pads);\n        vm.deal(filler, 100 ether);\n        vm.deal(bob, 100 ether);\n        vm.prank(bob);\n        weth.deposit{value: 50 ether}();\n        vm.prank(bob);\n        weth.approve(address(router), type(uint256).max);\n    }\n\n    function test_remoteBuyIsTheBuyersActivity() public {\n        uint256 t0 = block.timestamp;\n        // Alice launches with a buy from another chain: her first receipt records activity at t0.\n        vm.prank(filler);\n        (address token,) = remote.launchFor{value: 1 ether}(\n            address(pad), alice, \"Remote\", \"RMT\", \"{}\", IRemotePad.FeeSplit(0, 300, 0), 1 ether, 1, 0\n        );\n        PadToken t = PadToken(payable(token));\n        assertEq(t.lastActive(alice), t0, \"first receipt counts\");\n\n        // Bob buys directly: his 3% holder fee is distributed to Alice, the only holder, at t0.\n        vm.prank(bob);\n        router.buy(token, 5 ether, 1, block.timestamp);\n        assertGt(t.withdrawableDividendOf(alice), 0, \"alice earned rewards at t0\");\n\n        // Six days later Alice buys again, through the Remote. A buy is the buyer's own activity.\n        vm.warp(t0 + 6 days);\n        vm.prank(filler);\n        remote.buyFor{value: 1 ether}(address(pad), token, alice, 1, 0);\n        assertEq(t.lastActive(alice), block.timestamp, \"a buy through the Remote must mark the buyer active\");\n\n        // Seven days and an hour after t0 she has been active for just over a day: nothing of hers may expire.\n        vm.warp(t0 + 7 days + 1 hours);\n        assertEq(t.expiredRewardsOf(alice), 0, \"an active holder's rewards don't expire\");\n        uint256 feesBefore = weth.balanceOf(fees);\n        t.recycle(alice);\n        assertEq(weth.balanceOf(fees), feesBefore, \"nothing of alice's is recycled to feeRecipient\");\n    }\n}","reproduction":"Pad paired with a WETH9-like asset (ImdEthPool tickSpacing 0, so ethRouter is PepesFamilyNativeRouter), PepesFamilyRemote allowlisting the pad. t0: filler calls remote.launchFor{value: 1 ether}(pad, alice, ..., split(0,300,0), buyValue=1 ether, minTokens=1, gasDrop=0): alice's first receipt sets lastActive[alice]=t0. t0: bob calls router.buy(token, 5 ether, 1, deadline): his 3% holder fee is distributed to alice (withdrawableDividendOf(alice) > 0). t0+6 days: filler calls remote.buyFor{value: 1 ether}(pad, token, alice, 1, 0). Expected: lastActive[alice] == t0+6 days (a buy is the buyer's activity). Actual: lastActive[alice] == t0; the hook called markActive(address(remote)). t0+7 days+1 hour: expected expiredRewardsOf(alice) == 0 and recycle(alice) moves nothing. Actual: expiredRewardsOf(alice) == 194644400168944778 wei and recycle(alice) sends that amount of WETH to feeRecipient although alice bought 25 hours earlier. Test: contracts/test/scratch/RemoteActivity.t.sol, run with `cd contracts && forge test --match-path test/scratch/RemoteActivity.t.sol` (fails on this code at the lastActive assertion; the harm assertions fail too when the first is removed).","severity":"medium","snippet":"        address trader = (sender == router || sender == ethRouter) && hookData.length == 32\n            ? abi.decode(hookData, (address))\n            : tx.origin;","title":"Buys through PepesFamilyRemote mark the Remote contract active, not the buyer: a repeat cross-chain buyer's rewards expire and are recycled to feeRecipient"}],"hash":"54e435b14488165cb2cd8cf456cd06d733768f9b4455dfb9e9f2a18769c2bcc9","nodeId":"766b287e-ebed-4cc9-8a30-290ce6322270","outcome":"completed","summary":"Review complete. The findings file is written and the tree holds only my allowed outputs (`.imd-findings.json`, which is gitignored, and `contracts/test/scratch/`).\n\n**Result: one substantiated finding, medium severity, with a failing Foundry proof.**\n\n- **Cross-chain buys don't count as the buyer's activity.** The hook attributes a buy to the address in hookData, which the two pad routers fill with their own caller. `PepesFamilyRemote` (added 2026-10-27, after the last launchpad audit) is that caller, so the hook marks the Remote contract active, not the end user. The user's later token receipt from the Remote only counts the first time. A repeat buyer who trades only through the cross-chain path and doesn't claim or send for 7 days has their older rewards expire, and anyone can recycle them to feeRecipient. This contradicts the documented rule that a buy through any router is the buyer's activity. The proof test at `contracts/test/scratch/RemoteActivity.t.sol` fails on this code at the activity assertion, and a harm-only variant showed about 0.19 WETH of the buyer's rewards moved to feeRecipient one day after her buy. The suggested fix is a `buyWithEthFor(user, ...)` on both pad routers that puts the end user in hookData while still paying the Remote.\n\n**Coverage, for the control-flow area (Execution Trace, Periphery, First Principles):**\n\n- **Perps, in depth.** LevExchange, LevExchangeAdmin, LevExchangeBase, LevTypes, LevPool, LevLens, LevRouter, LevLogic, ConfigCheck, TickRatio, all three oracles, the interfaces and both deploy scripts. I traced every exit path (close, closeFor, closeByTrigger, closeAtMaxWin, liquidate, settleRugged, stale path, order fills), the delegatecall split (shared layout, shared reentrancy slot, payable fallback, direct calls on the admin contract), escrow accounting, the pool's cash-versus-maxWin solvency argument, the growth-window give-back, the bridged oracle's tick and depth composition, and the V3 oracle's observation-history assumptions. No defect found; the baseline suite passes (219 tests). Known limits in perps/AUDIT.md section 5 were confirmed, not re-reported.\n- **Launchpad periphery.** PepesFamilyRemote, PepesWorldVault, PepesFamilyNativeRouter, PepesFamilyQuoter, SafeTransfer, PermitHelper, the hook's swap and fee flows in PepesFamily.sol, and PadToken's activity and expiry logic. The one finding came from here.\n- **Not covered in depth.** PadToken's dividend math beyond the activity path, the v1 to v3 token variants, the earn/ contracts, PepesFamilyLens, PepesFamilyRouter's launch-with-buy flow, and the `src/fees/*` contracts the brief excludes. No fork tests were run. A clean pass over the perps code is not proof that no defects exist there.\n\nLeads I checked and rejected as findings: admin-only hazards (a reverting or zero treasury address stranding or burning the treasury share), and LevLens over-counting the close-fee floor for bankrupt positions, which is dust-level and depositor-unfavourable only.","treeHash":null,"usage":{"cachedInputTokens":2582401,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":52071,"runtime":"claude","turns":47,"wallClockMs":958835}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"aac006f7fc669006","findings":[{"citation":"resolved","description":"Seam: precision x invariant (boundary of the equity <= 0 branch). liabilityRange() computes owedLow, from which LevPool prices deposits at high = cash - owedLow. The doc comment states the fee credit is 'A floor, so deposits never pay for fees that might not arrive'. _feesOwedLowerBound credits every open position on a side with the pool's share of a size-based close fee (size * openFeeSizeBps * poolFeeShareBps / BPS^2) plus accrued holding fees, regardless of the position's equity. But a position whose equity is <= 0 settles through LevExchange._outcome's liquidation branch with o.fees = 0 (left = 0) and no holding fee beyond the stake+margin the pool already gets; liabilityRange already counts that position at -withMargin (line 138: `lo[...] += v < -withMargin ? -withMargin : v`). So the same position is credited twice: once as the full stake+margin loss and again as close-fee share and holding fee that will never be paid. The same over-credit applies to liquidations with small positive equity (fees = left - reward, which can be below the size-based fee) and to max-win/trigger closes by a keeper (the pool share is taken after keeperRewardBps is removed). Effect: while such positions are open, previewDeposit/deposit price LP shares above any value the pool can realize, so new LPs overpay and existing LPs (and DEAD house shares) capture the difference. Magnitude per bankrupt position: 0.125% of size (default openFeeSizeBps 25, poolFeeShareBps 5000) plus any holding fee above the free waiver; bounded but real, and it grows with bankrupt open interest (e.g. on a stale feed, where positions can sit below zero equity until the feed is declared stale).","line":163,"path":"perps/contracts/src/LevLens.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {LevExchange} from \"src/LevExchange.sol\";\nimport {LevExchangeAdmin} from \"src/LevExchangeAdmin.sol\";\nimport {LevPool} from \"src/LevPool.sol\";\nimport {LevLens} from \"src/LevLens.sol\";\nimport {RiskConfig} from \"src/LevTypes.sol\";\nimport {ILevPool, ILevExchange, ILevNav} from \"src/interfaces/ILevPool.sol\";\nimport {MockOracle} from \"src/mocks/MockOracle.sol\";\n\n/// A bankrupt, not-yet-liquidated position: liabilityRange's \"lower bound\" still credits the pool with that\n/// position's close-fee share, so the deposit price (cash - owedLow) is above what the pool can ever book.\ncontract NavFloorTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    LevLens lens;\n    MockOracle o0;\n    address owner = makeAddr(\"owner\");\n    address lp = makeAddr(\"lp\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_750_000_000);\n        pool = new LevPool{value: 0.001 ether}(owner);\n        ex = new LevExchange(ILevPool(address(pool)), owner, makeAddr(\"treasury\"), address(new LevExchangeAdmin()));\n        exa = LevExchangeAdmin(payable(address(ex)));\n        lens = new LevLens(ex);\n        vm.startPrank(owner);\n        pool.setExchange(ILevExchange(address(ex)));\n        pool.setNavSource(ILevNav(address(lens)));\n        exa.setRiskConfig(\n            RiskConfig({\n                openBandSpotTicks: 30_000,\n                openBandTrendTicks: 30_000,\n                pumpBandTicks: 30_000,\n                spreadBaseBps: 0,\n                spreadImpactBps: 0,\n                spreadMaxBps: 0,\n                sizeTier5xBps: 500,\n                sizeTier2xBps: 500,\n                hourlyGrowthBps: 60_000,\n                platformSizeBps: 100_000\n            })\n        );\n        o0 = new MockOracle(0, 400 ether);\n        exa.listMarket(o0, 10, 5 ether);\n        vm.stopPrank();\n        vm.deal(lp, 100 ether);\n        vm.prank(lp);\n        pool.deposit{value: 8 ether}();\n        vm.deal(alice, 10 ether);\n        vm.prank(alice);\n        ex.deposit{value: 10 ether}();\n    }\n\n    function test_depositPriceCountsCloseFeeOfBankruptPosition() public {\n        vm.prank(alice);\n        uint256 id = ex.open(0, true, 10, 0.2 ether, type(int24).max); // size 2 ETH\n        vm.warp(block.timestamp + 60);\n        o0.setAll(-1625); // about -15%: pnl about -0.3 ETH on a 0.2 ETH stake, equity < 0\n\n        (, uint256 highBefore) = pool.navRange();\n        // Liquidating books exactly the stake to the pool (equity <= 0: no fees, no reward).\n        vm.prank(makeAddr(\"keeper\"));\n        ex.liquidate(id);\n        (uint256 lowAfter, uint256 highAfter) = pool.navRange();\n        assertEq(lowAfter, highAfter, \"no positions left\");\n\n        // The deposit price must never be above what the pool really ends up holding for that position.\n        assertLe(highBefore, highAfter, \"deposit-side NAV overstated before the liquidation\");\n    }\n}","reproduction":"Default config, neutral risk config, market listed at tick 0 with 400 ETH liquidity, LP deposits 8 ETH. Alice opens a 10x long with stake 0.2 ETH (size 2 ETH). After 60 s the oracle moves all ticks to -1625 (~-15%): pnl ~ -0.3 ETH, equity < 0, position not yet liquidated. pool.navRange() high = 8.206000 ETH. A keeper calls liquidate(id): equity <= 0 so the pool books exactly the 0.2 ETH stake and no fee. navRange() afterwards (no positions left) = 8.203500 ETH. Expected: high before liquidation <= 8.2035 ETH (fees credited only if they can arrive). Actual: 8.2060 ETH, overstated by 0.0025 ETH = 2 ETH * 25 * 5000 / 1e8, the close-fee share of a position that pays no fee. A depositor in that state pays ~0.03% too much per share here; the error scales with bankrupt size. Fix: only credit closeFeeShare and holding fees for positions with positive equity at the pool-favourable price, i.e. compute the fee credit per position inside the liabilityRange loop and cap each position's total (pnl clamp + hold + fee share) at its stake+margin (e.g. lo += max(v - hold_i - feeShare_i, -withMargin)), and apply the keeper-reward haircut to the fee share.","severity":"low","snippet":"        uint256 closeFeeShare = (s.size * c.openFeeSizeBps * c.poolFeeShareBps) / (BPS * BPS);","title":"LevLens._feesOwedLowerBound is not a lower bound: deposit-side NAV credits close fees (and holding fees) that bankrupt or liquidated positions never pay"}],"hash":"7e4b1612d71b3a48a487874c4298bc9b1633ce17676d3b8c07c48be02c6e2c47","nodeId":"a11e21db-001d-4b69-a040-9a0108dbc916","outcome":"completed","summary":"I found one defect in the math review, rated low severity, and confirmed it with a test that fails on the current code. It's recorded in `.imd-findings.json`.\n\n**Finding (low): the LP deposit price is too high while a position is underwater.** In `perps/contracts/src/LevLens.sol:163`, `_feesOwedLowerBound` is meant to credit the pool only with fees it is sure to receive. It actually credits every open position with the pool's share of a closing fee, plus holding fees. A position whose equity is at or below zero pays no fee when it is liquidated. `liabilityRange` already counts that position as a full loss of its stake and margin, so it is credited twice. The pool prices new deposits from this figure, so new LPs pay slightly too much for their shares while such positions are open.\n\n- **Test case:** the pool has 8 ETH. Someone opens a 10x long with a 0.2 ETH stake (2 ETH position), and the price falls about 15%. Before liquidation, the deposit-side value is 8.2060 ETH. After liquidation, which adds only the 0.2 ETH stake and no fee, it is 8.2035 ETH. The 0.0025 ETH gap is exactly the fee share that never arrives. The error grows with the size of the underwater positions.\n- **Same pattern:** the credit is also too high for liquidations with a small positive equity, and for take-profit, stop-loss and max-win closes where a keeper's reward comes out first.\n- **Fix:** work out the fee credit per position, only for positions whose equity is still positive. Cap each position's total at its stake plus margin, and apply the keeper-reward reduction.\n\nThe test was in `test/scratch/`. I deleted it after the run, so the full source is only in the finding's `proof` field. Its imports are relative to `perps/contracts`, not the repository root.\n\n**Covered:** the precision, boundary and seam passes, read line by line, over:\n- **Perps:** `TickRatio`, `LevLogic`, `ConfigCheck`, `LevExchange`, `LevExchangeBase`, `LevExchangeAdmin`, `LevPool`, `LevLens`, the three oracles, and the buyback price floor in `CreatorFeeRouterV2`.\n- **Launchpad:** the fee and burn maths in the `PepesFamily` hook and the dividend maths in `PadToken`.\n\n**Checked and left out:**\n- **Order fill price limit:** `fillOrder` narrows the limit to `int24`, which wraps silently, but only for trigger ticks no real price reaches. The only effect is that the trader's own order can never fill.\n- **Buyback price floor:** `_floorOut` stops scaling once a coin is priced below about 1.0001^-400000 ETH per token. That makes the floor far too loose, but a 4.2 ETH graduated memecoin sits around tick -184,000, nowhere near it.\n- **Possible overflows:** the dividend total, the order escrow (`uint96`) and the oracle's depth figure (`uint128`) can't overflow at real supplies.\n- **Holding-fee waiver:** it can be off after the owner changes the fee config, which is an owner action.\n\n**Not covered:** I didn't read the Earn contracts (`PepesEarnIMD`, `PepesEarnToken`, renderer), the older `PadToken` versions, `PepesWorldVault`, `PepesFamilyRemote`, or the router and quoter contracts. This was a single reviewer doing these passes; no helpers ran. I ran nothing beyond that one test, and no findings in the unread files doesn't mean there are none.","treeHash":null,"usage":{"cachedInputTokens":2403588,"inputTokens":48,"model":"claude-opus-5-5","outputTokens":27532,"runtime":"claude","turns":25,"wallClockMs":383007}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7591760a616c6429","findings":[{"citation":"resolved","description":"LevPool.withdraw() pays min(value at request, value now), where 'value now' is cash minus owedHigh from LevLens.liabilityRange(). owedHigh marks every open position at whichever of the live tick, the fast price and the slow price is worst for the pool, and each long's pnl is capped at its max win. The live tick is the coin pool's current slot0, which anyone can move inside one block (buy, let the withdrawal execute, sell back; the cost is the coin pool's round-trip LP fee, nothing else, since fast and slow are untouched). A push of +49% on a coin that carries a 10x long marks that long at its full 400% max win, so owedHigh jumps to the sum of the longs' max wins (up to coverageBps = 50% of cash) and the leaving LP is paid that much less. Because the drop in 'low' also lowers the ratio low/cash, the stress fee (_withdrawFeeBps) kicks in as well: in the reproduction the LP loses 0.8 ETH of mark plus 0.108 ETH of a 1.5% stress fee on a pool that had no trader winning anything. The withdrawn shares are burned in full, so the unpaid ETH stays with the remaining shareholders, which the pusher can be (they only need to hold LP shares and be able to order two swaps around the victim's withdraw in the same block, e.g. by being, or bundling through, the sequencer). The LP has no slippage parameter: withdraw() takes no minAssets and the request-time snapshot only caps the payout from above. Compare deposit(), which prices at owedLow, a minimum over the three marks, so a single-tick push can never improve a depositor's price; the withdrawal side has no such protection because the live tick enters the maximum. The brief's invariant 4 says LP share pricing can't be gamed. Fix: value withdrawals (and the stress-fee ratio) at the fast and slow marks only, keeping the live tick out of owedHigh, or add a minAssets argument to withdraw() so an LP can refuse a repriced payout. The reproduction asserts the first fix.","line":125,"path":"perps/contracts/src/LevPool.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {LevExchange} from \"src/LevExchange.sol\";\nimport {LevExchangeAdmin} from \"src/LevExchangeAdmin.sol\";\nimport {LevPool} from \"src/LevPool.sol\";\nimport {LevLens} from \"src/LevLens.sol\";\nimport {RiskConfig} from \"src/LevTypes.sol\";\nimport {ILevPool, ILevExchange, ILevNav} from \"src/interfaces/ILevPool.sol\";\nimport {MockOracle} from \"src/mocks/MockOracle.sol\";\n\n/// @dev An LP's matured withdrawal is repriced by whatever the coin pool's live tick shows in the block the\n///      withdrawal executes. Fast and slow are untouched, so this is a one-block spot push, not a market move.\n///      The LP has no minimum-payout parameter, so whatever the push takes stays with the remaining shares.\ncontract LpWithdrawSpotPushTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    LevLens lens;\n    MockOracle o;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address lp = makeAddr(\"lp\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_750_000_000);\n        pool = new LevPool{value: 0.001 ether}(owner);\n        ex = new LevExchange(ILevPool(address(pool)), owner, treasury, address(new LevExchangeAdmin()));\n        exa = LevExchangeAdmin(payable(address(ex)));\n        lens = new LevLens(ex);\n        vm.startPrank(owner);\n        pool.setExchange(ILevExchange(address(ex)));\n        pool.setNavSource(ILevNav(address(lens)));\n        exa.setRiskConfig(\n            RiskConfig({\n                openBandSpotTicks: 30_000,\n                openBandTrendTicks: 30_000,\n                pumpBandTicks: 30_000,\n                spreadBaseBps: 0,\n                spreadImpactBps: 0,\n                spreadMaxBps: 0,\n                sizeTier5xBps: 500,\n                sizeTier2xBps: 500,\n                hourlyGrowthBps: 60_000,\n                platformSizeBps: 100_000\n            })\n        );\n        vm.stopPrank();\n        o = new MockOracle(0, 400 ether);\n        vm.prank(owner);\n        exa.listMarket(o, 10, 5 ether);\n\n        vm.deal(lp, 100 ether);\n        vm.prank(lp);\n        pool.deposit{value: 8 ether}();\n        vm.deal(alice, 100 ether);\n        vm.prank(alice);\n        ex.deposit{value: 10 ether}();\n    }\n\n    function test_withdrawRepricedByOneBlockSpotPush() public {\n        // alice: 10x long, 0.2 ETH stake (size 2 ETH = the coin cap), max win 400% = 0.8 ETH reserved\n        vm.prank(alice);\n        ex.open(0, true, 10, 0.2 ether, type(int24).max);\n\n        // LP asks to leave with every share; the request snapshots the value at today's prices\n        uint256 shares = pool.balanceOf(lp);\n        vm.prank(lp);\n        pool.requestWithdraw(shares);\n        (, uint256 snapshot,) = pool.withdrawRequests(lp);\n\n        vm.warp(block.timestamp + pool.withdrawCooldown());\n        // nothing changed on the market: fast and slow still at tick 0\n        (uint256 lowBefore,) = pool.navRange();\n        uint256 honest = (shares * lowBefore) / pool.totalSupply();\n        if (honest > snapshot) honest = snapshot;\n\n        // one-block live-tick push of +49% (fast and slow untouched): every long marks at its max win\n        o.set(4000, 0, 0);\n        uint256 balBefore = lp.balance;\n        vm.prank(lp);\n        pool.withdraw();\n        uint256 paid = lp.balance - balBefore;\n        o.set(0, 0, 0); // the push is undone right after\n\n        // Expected: the LP is paid what the request was worth (fast and slow never moved). Actual: 0.8 ETH less.\n        assertEq(paid, honest, \"withdrawal repriced by a one-block spot push\");\n    }\n}","reproduction":"Fresh LevPool seeded with 0.001 ETH, LevExchange + LevLens as navSource, risk config with bands/spread switched off (as perps/contracts/test/Base.t.sol does), one market on a MockOracle at tick 0 with 400 ETH depth, maxLeverage 10, netCap 5 ETH. lp deposits 8 ETH. alice deposits 10 ETH and opens a 10x long with stake 0.2 ETH (size 2 ETH, max win 400% = 0.8 ETH). lp calls requestWithdraw(all shares): snapshot = 8.0025 ETH. Warp 1 day. Fast and slow still at tick 0. Expected payout: shares*low/supply with low = cash - 0 = 8.0025 ETH (capped by the snapshot) = 8.0025 ETH. Attacker sets only the live tick to +4000 (~+49%) for that block (MockOracle.set(4000, 0, 0)); lp calls withdraw(). Actual payout: 7.0946 ETH (owedHigh = 0.8 ETH max win, low/cash = 0.90 so a 150 bps stress fee of 0.108 ETH is kept as well). The live tick is reset right after. The 0.908 ETH stays in the pool for the remaining shares. Test: perps/contracts/test/scratch/LpWithdrawSpotPush.t.sol, run with `cd perps/contracts && forge test --match-path test/scratch/LpWithdrawSpotPush.t.sol`; it fails on this code with `7094560679915010624 != 8002499687539057617`.","severity":"medium","snippet":"        assets = (r.shares * low) / totalSupply();","title":"LP withdrawal is repriced by the coin pool's live tick in the execution block, with no minimum payout: a one-block spot push moves part of the leaving LP's money to the remaining shares"},{"citation":"resolved","description":"withdraw() first pays the lower of the request-time value and the value now, then applies _withdrawFeeBps(low) using the execution-time 'low'. When 'low' fell because of a momentary mark (the live-tick case above, or a fast price that reverts within the hour), the LP pays the reduced mark and, on top of it, a 0.5% to 2.5% stress fee computed from the same momentary ratio. In the reproduction above the mark alone costs 0.8 ETH and the fee adds 0.108 ETH (ratio 7.2/8.0 = 0.90 -> 150 bps), on a pool in which no trader had any realised or sustained win. The fee is meant to slow a run while traders are winning (the comment on withdrawFeeBps), not to tax a single-block mark. Fix: compute the tier from the fast/slow-only valuation, or from the lower of the request-time ratio and now.","line":127,"path":"perps/contracts/src/LevPool.sol","reproduction":"Same setup as the medium finding. After requestWithdraw at tick 0 and the 1-day cooldown, set only the live tick to +4000 and call withdraw(). pool.withdrawFeeBps() reads 150 in that block (low = 7.2025 ETH, cash = 8.0025 ETH, ratio 0.90), so fee = 7.2025 ETH * 1.5% = 0.108 ETH is kept, although at the fast and slow marks the ratio is 1.0 and the fee would be 0. Observed: lp receives 7.0946 ETH; shares*low/supply before the fee was 7.2025 ETH.","severity":"low","snippet":"        uint256 fee = (assets * _withdrawFeeBps(low)) / BPS;","title":"Stress fee is charged on a withdrawal whose own repricing created the stress: the fee tier is read from the already-reduced 'low' value instead of the request-time value"},{"citation":"resolved","description":"The rug guard (triggerRugGuard) fires only when the fast price is at or below (1 - rugDropBps) = 20% of the slow price, and a short may close whenever the live tick is above that line. A coin's insider who shorts at 5x and then dumps the pool to exactly -79% (ratio 0.21 > 0.20) is not blocked by _requireNoRug, waits for the fast median/TWAP to follow (1 to 2 minutes), and closes at max(spot, fast) for pnl = 5 * 79% = 395% of stake, capped at shortMaxWinBps = 100% of stake: the full short max win, paid by the pool. Only after that does the dump continue to -100%. The guard's 20% cap (rugCapBps) is never reached. This is the documented design (the guard is a last line for a single-step rug), and the per-coin caps (netCap, 0.5% of liquidity, hourly growth, 5x short limit) bound what one coin can cost; it is recorded here as the concrete boundary of the protection, so the curator's listing decision and netCap are set with it in mind. No code change is proposed; lowering rugDropBps toward its 50% floor widens the protection at the cost of catching real 50% moves.","line":334,"path":"perps/contracts/src/LevExchange.sol","reproduction":"Short 5x, stake S, entry tick 0, slow tick 0. Set spot = -15590 ticks (ratio 0.2102, a 78.98% drop) and fast = -15590 after the fast window. isRug(c, spot, slow): 0.2102e18 * 10000 <= 1e18 * 2000 is false, so close() passes _requireNoRug and pays pnl = min(5 * 0.79 * S, 1.0 * S) = S. With spot = -16100 (ratio 0.1998, an 80.02% drop) the same close reverts RugInProgress and, once triggerRugGuard runs, settleRugged pays at most rugCapBps = 0.2 * S.","severity":"info","snippet":"            if (!p.isLong) _requireNoRug(c, spot, slow);","title":"Rug cap on shorts only applies to crashes of rugDropBps (80%) or more: a short can take its full capped win on a 79% crash by closing before the guard can fire"}],"hash":"c227034927f485962997885d97b7ae8e424ffde68a7de351136bf5ea431f97a7","nodeId":"568e3b96-64fe-4fcf-807c-ebee4aaf063f","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds three findings, one with a failing Foundry proof.\n\n**What I found**\n\n- **Medium, LevPool withdrawals can be repriced by a one-block spot push.** The payout reads the pool's \"low\" value, which marks every open position at the worst of the live tick, fast and slow. The live tick is the coin pool's current slot0 and anyone can move it for one block at the cost of the coin pool's round-trip fee. With a 10x long on the coin, a +49% live-tick push marks that long at its full max win, so the leaving LP is paid less and the unpaid ETH stays with remaining shares, which the pusher can hold. The LP has no minimum-payout argument. The proof test under `perps/contracts/test/scratch/` fails on the current code: the LP receives 7.09 ETH where the request was worth 8.00 ETH with fast and slow unchanged. Suggested fix: price withdrawals at the fast and slow marks only, or add a minimum-payout argument.\n- **Low, the stress fee compounds the same repricing.** The fee tier is read from the already-reduced value in the execution block, so the LP above also pays a 1.5% stress fee of 0.108 ETH on a pool in which no trader had any sustained win.\n- **Info, rug cap boundary.** A short can collect its full capped win on a 79% crash by closing before the guard can fire, since the guard only triggers at 80% or more. This is the documented design and the per-coin caps bound the exposure. It is recorded with the exact ticks so the curator sets netCap with that boundary in mind.\n\n**What I checked without finding a defect**\n\n- Perps settlement: `_finish` and `_outcome` conserve value on every exit path (close, liquidation, max win, trigger, rug, stale). Pool cash never falls below the reserved max wins through settlements or LP withdrawals, so `payToExchange` cannot underflow.\n- Perps share pricing: deposits price at a minimum over the three marks, so a single-tick push cannot improve a depositor's price. Order escrow, margin bookkeeping, hourly growth give-back and the breaker were traced by hand.\n- Launchpad v5: fee and burn math in all four swap modes for both currency orders, the split rounding into protocol, creator and holder shares, claim solvency of the ERC-6909 mints against pending balances, `flush` and `distribute` gating inside foreign unlocks, and the routers' refund paths.\n\n**Limits of this review**\n\n- No stateful fuzzing or fork tests were run. The out-of-scope fee contracts under `perps/contracts/src/fees/` were not read.\n- The medium finding needs same-block ordering around the victim's withdrawal, which on a sequencer chain means being, or bundling through, the sequencer. Its loss is bounded by the sum of open max wins, at most half of pool cash, plus the stress fee.","treeHash":null,"usage":{"cachedInputTokens":2098274,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":41046,"runtime":"claude","turns":37,"wallClockMs":595908}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"09078b7cdfb673fe","findings":[{"citation":"resolved","description":"A token launched through PepesFamilyRemote.launchFor has the remote contract as its creator on the launchpad, so PepesFamily.creatorPayout[token] == address(remote) (contracts/src/PepesFamily.sol:301). PepesFamily.collectCreatorFees(token) (contracts/src/PepesFamily.sol:497) is callable by anyone and takes the pending quote to creatorPayout, i.e. into the remote. PepesFamilyRemote.collectCreatorFees, the only path that pays the real creator (creatorOf[token]), forwards only the quote-balance delta of its own call (lines 157-160). Quote that reached the remote through a direct launchpad call is never forwarded: the remote has no sweep and no owner withdrawal, and later calls measure a zero delta. Because every token on a pad shares one quote, a stranded balance cannot be attributed to a token afterwards. A griefer can front-run every remote.collectCreatorFees(token) with pad.collectCreatorFees(token) and lock 100% of that creator's fees until the creator completes handOver + acceptCreatorPayout; everything collected before that is lost for good. An ordinary keeper or explorer user calling the public launchpad function causes the same loss by accident. Verified: the remote's only state-changing functions are launchFor, buyFor, collectCreatorFees, handOver, transferCreator, setPad and the ownership pair; none moves an existing ERC20 balance. Fix: do not let the remote be a payout address that direct collections pay into without attribution. Options: deploy a minimal per-token payout escrow (CREATE2 clone) as the launchpad creatorPayout for remote launches whose only function forwards its whole quote balance to creatorOf[token]; or read pad.pendingCreatorFees(token) before the call and forward that amount, combined with proposing the creator as payout at launch so direct collections cannot land in the remote. At minimum add a creator-callable sweep so already-stranded fees are recoverable.","line":157,"path":"contracts/src/PepesFamilyRemote.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 {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PepesFamilyRemote, IRemotePad} from \"src/PepesFamilyRemote.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\ncontract ScratchWETH {\n    string public name = \"Wrapped Ether\";\n    string public symbol = \"WETH\";\n    uint8 public decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    receive() external payable {\n        deposit();\n    }\n\n    function deposit() public payable {\n        balanceOf[msg.sender] += msg.value;\n    }\n\n    function withdraw(uint256 wad) external {\n        balanceOf[msg.sender] -= wad;\n        (bool ok,) = msg.sender.call{value: wad}(\"\");\n        require(ok, \"withdraw\");\n    }\n\n    function approve(address s, uint256 a) external returns (bool) {\n        allowance[msg.sender][s] = a;\n        return true;\n    }\n\n    function transfer(address to, uint256 a) external returns (bool) {\n        return transferFrom(msg.sender, to, a);\n    }\n\n    function transferFrom(address f, address to, uint256 a) public returns (bool) {\n        if (f != msg.sender && allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\n/// @notice A token launched through PepesFamilyRemote has the remote contract as its `creatorPayout` on the\n///         launchpad. The launchpad's `collectCreatorFees(token)` is callable by anyone and pays `creatorPayout`,\n///         so anyone can make the creator fees land in the remote contract outside `PepesFamilyRemote.collectCreatorFees`,\n///         which only forwards the balance delta of its own call. The fees are then stuck in the remote forever.\ncontract RemoteStrandedCreatorFeesTest is Test {\n    PoolManager pm;\n    ScratchWETH weth;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PepesFamilyRemote remote;\n    address owner = makeAddr(\"owner\");\n    address filler = makeAddr(\"filler\");\n    address alice = makeAddr(\"alice\"); // the real creator, who paid on another chain\n    address bob = makeAddr(\"bob\"); // a trader\n    address griefer = makeAddr(\"griefer\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        weth = new ScratchWETH();\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(weth),\n                owner,\n                makeAddr(\"fees\"),\n                DeployLib.startTickForMarketCap(1.5e18),\n                PepesFamily.ImdEthPool(0, 0, address(0))\n            )\n        );\n        (bytes32 salt,) = DeployLib.mineSalt(address(this), uint160(0x28CC), initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        pad = PepesFamily(deployed);\n        router = PepesFamilyRouter(payable(pad.router()));\n        address[] memory pads = new address[](1);\n        pads[0] = address(pad);\n        remote = new PepesFamilyRemote(owner, pads);\n        vm.deal(filler, 100 ether);\n        vm.deal(bob, 100 ether);\n        vm.prank(bob);\n        weth.deposit{value: 50 ether}();\n        vm.prank(bob);\n        weth.approve(address(router), type(uint256).max);\n    }\n\n    function test_anyoneCanStrandRemoteCreatorFees() public {\n        // Alice launches cross-chain: the remote contract is the creator payout on the launchpad.\n        vm.prank(filler);\n        (address token,) = remote.launchFor(\n            address(pad), alice, \"Remote\", \"RMT\", \"{}\", IRemotePad.FeeSplit(100, 100, 100), 0, 0, 0\n        );\n        assertEq(pad.creatorPayout(token), address(remote));\n\n        // A trade accrues 1% creator fee: 0.1 WETH on a 10 WETH buy.\n        vm.prank(bob);\n        router.buy(token, 10 ether, 1, block.timestamp);\n        assertEq(pad.pendingCreatorFees(token), 0.1 ether);\n\n        // Anyone calls the launchpad's public collector directly: the fee lands in the remote contract.\n        vm.prank(griefer);\n        pad.collectCreatorFees(token);\n        assertEq(pad.pendingCreatorFees(token), 0);\n        assertEq(weth.balanceOf(address(remote)), 0.1 ether, \"fee sits in the remote contract\");\n\n        // The remote's forwarder measures only the delta of its own call, so it forwards nothing.\n        uint256 a0 = weth.balanceOf(alice);\n        uint256 fwd = remote.collectCreatorFees(token);\n\n        // Expected: the creator receives her 0.1 WETH. Actual: zero is forwarded and the WETH stays in the remote,\n        // which has no function that can move it.\n        assertEq(fwd, 0.1 ether, \"forwarded amount\");\n        assertEq(weth.balanceOf(alice) - a0, 0.1 ether, \"creator paid\");\n        assertEq(weth.balanceOf(address(remote)), 0, \"remote keeps nothing\");\n    }\n}","reproduction":"State: remote.launchFor(pad, alice, 'Remote', 'RMT', '{}', FeeSplit(100,100,100), 0, 0, 0) so pad.creatorPayout(token) == address(remote). Bob buys with 10 WETH through PepesFamilyRouter.buy: pad.pendingCreatorFees(token) == 0.1e18. Input: griefer (any address) calls pad.collectCreatorFees(token); then anyone calls remote.collectCreatorFees(token). Expected: alice receives 0.1e18 quote. Actual: pad.collectCreatorFees takes 0.1e18 to the remote; remote.collectCreatorFees returns 0 and forwards nothing; weth.balanceOf(remote) stays 0.1e18 and no function can move it. Reproduced: contracts/test/scratch/RemoteStrandedCreatorFees.t.sol, run with `cd contracts && forge test --match-path test/scratch/RemoteStrandedCreatorFees.t.sol`, fails on this code with 'forwarded amount: 0 != 100000000000000000'.","severity":"high","snippet":"        uint256 before = IRemoteToken(quote).balanceOf(address(this));","title":"PepesFamilyRemote: anyone can strand a remote-launched token's creator fees by calling the launchpad's public collectCreatorFees first"},{"citation":"resolved","description":"Merged from two specialists (audit_flow and audit_permissions) reporting the same mechanism. PadToken's rule (contracts/src/PadToken.sol:18-25 and 239-245) is that a buy of any size through any router is the buyer's own activity: PepesFamily.afterSwap calls markActive(trader) on every buy (contracts/src/PepesFamily.sol:430-434), where trader is the address in hookData for the pad's own routers. PepesFamilyEthRouter and PepesFamilyNativeRouter put their msg.sender there (Route.user, contracts/src/PepesFamilyNativeRouter.sol:77 and :141). PepesFamilyRemote._buy calls ethRouter.buyWithEth itself (line 207), so hookData carries the Remote's address and markActive is applied to the Remote contract, never to the end buyer. The Remote then forwards the tokens with transfer (line 208); PadToken._update only records the recipient's activity when msg.sender == to or on a first receipt (contracts/src/PadToken.sol:233). A user who already holds the token and buys again only through the cross-chain path therefore never refreshes lastActive. After 7 days without a claim or send, expiredRewardsOf returns their rewards older than 7 days and anyone can call recycle(holder) to move them to the pad's feeRecipient, contradicting the documented rule and the Remote's own notice ('Tokens bought go straight to the user'). The victim loses funds they would keep had they bought through the router directly. Fix: let the Remote report the end user to the hook. Add buyWithEthFor(address user, ...) to PepesFamilyEthRouter and PepesFamilyNativeRouter that puts `user` in Route.user/hookData (keeping payout and refunds to msg.sender, the Remote, so its forwarding logic is unchanged), and have PepesFamilyRemote._buy call it with `to`.","line":207,"path":"contracts/src/PepesFamilyRemote.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 {PepesFamily} from \"src/PepesFamily.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {PepesFamilyRemote, IRemotePad} from \"src/PepesFamilyRemote.sol\";\nimport {PadToken} from \"src/PadToken.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\n/// @dev WETH9-like wrapped native coin, used as the pad's pair asset so the pad deploys a PepesFamilyNativeRouter.\ncontract ScratchWETH {\n    string public name = \"Wrapped Ether\";\n    string public symbol = \"WETH\";\n    uint8 public decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    receive() external payable {\n        deposit();\n    }\n\n    function deposit() public payable {\n        balanceOf[msg.sender] += msg.value;\n    }\n\n    function withdraw(uint256 wad) external {\n        balanceOf[msg.sender] -= wad;\n        (bool ok,) = msg.sender.call{value: wad}(\"\");\n        require(ok, \"withdraw\");\n    }\n\n    function approve(address s, uint256 a) external returns (bool) {\n        allowance[msg.sender][s] = a;\n        return true;\n    }\n\n    function transfer(address to, uint256 a) external returns (bool) {\n        return transferFrom(msg.sender, to, a);\n    }\n\n    function transferFrom(address f, address to, uint256 a) public returns (bool) {\n        if (f != msg.sender && allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\n/// @notice A buy through PepesFamilyRemote must count as the end buyer's activity (PadToken: \"a buy of any size,\n///         through any router, is the buyer's own activity\"). Today the hook marks the Remote contract instead, so\n///         a repeat cross-chain buyer stays \"inactive\" and anyone can recycle their older rewards to feeRecipient.\ncontract RemoteActivityTest is Test {\n    PoolManager pm;\n    ScratchWETH weth;\n    PepesFamily pad;\n    PepesFamilyRouter router;\n    PepesFamilyRemote remote;\n    address owner = makeAddr(\"owner\");\n    address fees = makeAddr(\"fees\");\n    address filler = makeAddr(\"filler\");\n    address alice = makeAddr(\"alice\"); // pays on another chain; the filler calls the Remote for her\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        weth = new ScratchWETH();\n        bytes memory initCode = abi.encodePacked(\n            type(PepesFamily).creationCode,\n            abi.encode(\n                pm,\n                address(weth),\n                owner,\n                fees,\n                DeployLib.startTickForMarketCap(1.5e18),\n                PepesFamily.ImdEthPool(0, 0, address(0))\n            )\n        );\n        (bytes32 salt,) = DeployLib.mineSalt(address(this), uint160(0x28CC), initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        pad = PepesFamily(deployed);\n        router = PepesFamilyRouter(payable(pad.router()));\n        address[] memory pads = new address[](1);\n        pads[0] = address(pad);\n        remote = new PepesFamilyRemote(owner, pads);\n        vm.deal(filler, 100 ether);\n        vm.deal(bob, 100 ether);\n        vm.prank(bob);\n        weth.deposit{value: 50 ether}();\n        vm.prank(bob);\n        weth.approve(address(router), type(uint256).max);\n    }\n\n    function test_remoteBuyIsTheBuyersActivity() public {\n        uint256 t0 = block.timestamp;\n        // Alice launches with a buy from another chain: her first receipt records activity at t0.\n        vm.prank(filler);\n        (address token,) = remote.launchFor{value: 1 ether}(\n            address(pad), alice, \"Remote\", \"RMT\", \"{}\", IRemotePad.FeeSplit(0, 300, 0), 1 ether, 1, 0\n        );\n        PadToken t = PadToken(payable(token));\n        assertEq(t.lastActive(alice), t0, \"first receipt counts\");\n\n        // Bob buys directly: his 3% holder fee is distributed to Alice, the only holder, at t0.\n        vm.prank(bob);\n        router.buy(token, 5 ether, 1, block.timestamp);\n        assertGt(t.withdrawableDividendOf(alice), 0, \"alice earned rewards at t0\");\n\n        // Six days later Alice buys again, through the Remote. A buy is the buyer's own activity.\n        vm.warp(t0 + 6 days);\n        vm.prank(filler);\n        remote.buyFor{value: 1 ether}(address(pad), token, alice, 1, 0);\n        assertEq(t.lastActive(alice), block.timestamp, \"a buy through the Remote must mark the buyer active\");\n\n        // Seven days and an hour after t0 she has been active for just over a day: nothing of hers may expire.\n        vm.warp(t0 + 7 days + 1 hours);\n        assertEq(t.expiredRewardsOf(alice), 0, \"an active holder's rewards don't expire\");\n        uint256 feesBefore = weth.balanceOf(fees);\n        t.recycle(alice);\n        assertEq(weth.balanceOf(fees), feesBefore, \"nothing of alice's is recycled to feeRecipient\");\n    }\n}","reproduction":"Pad paired with a WETH9-like asset (ImdEthPool tickSpacing 0, so ethRouter is PepesFamilyNativeRouter), PepesFamilyRemote allowlisting the pad. t0: filler calls remote.launchFor{value: 1 ether}(pad, alice, ..., FeeSplit(0,300,0), buyValue=1 ether, minTokens=1, gasDrop=0): alice's first receipt sets lastActive[alice]=t0. t0: bob calls router.buy(token, 5 ether, 1, deadline): his 3% holder fee is distributed to alice (withdrawableDividendOf(alice) > 0). t0+6 days: filler calls remote.buyFor{value: 1 ether}(pad, token, alice, 1, 0). Expected: lastActive[alice] == t0+6 days. Actual: lastActive[alice] == t0 (1800000000 != 1800518400); the hook called markActive(address(remote)). t0+7 days+1 hour: expected expiredRewardsOf(alice) == 0 and recycle(alice) moves nothing. Actual: expiredRewardsOf(alice) == 194644400168944778 wei and recycle(alice) sends that WETH to feeRecipient although alice bought 25 hours earlier. Reproduced: contracts/test/scratch/RemoteActivity.t.sol, run with `cd contracts && forge test --match-path test/scratch/RemoteActivity.t.sol`; fails on this code at the lastActive assertion.","severity":"medium","snippet":"        tokens = IRemoteEthRouter(IRemotePad(pad).ethRouter()).buyWithEth{value: value}(token, minTokens, block.timestamp);","title":"Buys through PepesFamilyRemote mark the Remote contract active, not the buyer: a repeat cross-chain buyer's rewards expire and anyone can recycle them to feeRecipient"},{"citation":"resolved","description":"withdraw() pays min(value at request, value now), where 'value now' is cash minus owedHigh from LevLens.liabilityRange() (perps/contracts/src/LevLens.sol:109-145). owedHigh marks every open position at whichever of the live tick, the fast price and the slow price is worst for the pool (ticks[i] = [spot, fast, slow], owedHigh += _max3(...)), and each long's pnl is capped at its max win. The live tick is the coin pool's current slot0, which anyone can move inside one block (buy, let the withdrawal execute, sell back; the cost is the coin pool's round-trip LP fee, since fast and slow are untouched). A push of +49% on a coin carrying a 10x long marks that long at its full 400% max win, so owedHigh jumps to the sum of the longs' max wins (up to coverageBps = 50% of cash) and the leaving LP is paid that much less. The withdrawn shares are burned in full (line 131), so the unpaid ETH stays with the remaining shareholders, which the pusher can be (they need LP shares and the ability to order two swaps around the victim's withdraw in one block, e.g. by being or bundling through the sequencer). The LP has no slippage parameter: withdraw() takes no minAssets and the request-time snapshot only caps the payout from above. Compare deposit(), which prices at owedLow, a minimum over the three marks, so a push can never improve a depositor's price. Compounding effect (reported separately by the economics specialist as a low, merged here because it is the same function, root cause and fix): line 127 reads the stress-fee tier from the same momentarily reduced 'low', so the LP also pays a 0.5% to 2.5% stress fee computed from a ratio that the push created, on a pool in which no trader won anything. In the reproduction the mark costs 0.8 ETH and the fee adds 0.108 ETH (ratio 7.2025/8.0025 = 0.90 -> 150 bps). perps/AUDIT.md invariant 4 says LP share pricing can't be gamed. Fix: value withdrawals (and the stress-fee ratio) at the fast and slow marks only, keeping the live tick out of owedHigh; or add a minAssets argument to withdraw() so an LP can refuse a repriced payout. The proof asserts the first fix.","line":125,"path":"perps/contracts/src/LevPool.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {LevExchange} from \"src/LevExchange.sol\";\nimport {LevExchangeAdmin} from \"src/LevExchangeAdmin.sol\";\nimport {LevPool} from \"src/LevPool.sol\";\nimport {LevLens} from \"src/LevLens.sol\";\nimport {RiskConfig} from \"src/LevTypes.sol\";\nimport {ILevPool, ILevExchange, ILevNav} from \"src/interfaces/ILevPool.sol\";\nimport {MockOracle} from \"src/mocks/MockOracle.sol\";\n\n/// @dev An LP's matured withdrawal is repriced by whatever the coin pool's live tick shows in the block the\n///      withdrawal executes. Fast and slow are untouched, so this is a one-block spot push, not a market move.\n///      The LP has no minimum-payout parameter, so whatever the push takes stays with the remaining shares.\ncontract LpWithdrawSpotPushTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    LevLens lens;\n    MockOracle o;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address lp = makeAddr(\"lp\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_750_000_000);\n        pool = new LevPool{value: 0.001 ether}(owner);\n        ex = new LevExchange(ILevPool(address(pool)), owner, treasury, address(new LevExchangeAdmin()));\n        exa = LevExchangeAdmin(payable(address(ex)));\n        lens = new LevLens(ex);\n        vm.startPrank(owner);\n        pool.setExchange(ILevExchange(address(ex)));\n        pool.setNavSource(ILevNav(address(lens)));\n        exa.setRiskConfig(\n            RiskConfig({\n                openBandSpotTicks: 30_000,\n                openBandTrendTicks: 30_000,\n                pumpBandTicks: 30_000,\n                spreadBaseBps: 0,\n                spreadImpactBps: 0,\n                spreadMaxBps: 0,\n                sizeTier5xBps: 500,\n                sizeTier2xBps: 500,\n                hourlyGrowthBps: 60_000,\n                platformSizeBps: 100_000\n            })\n        );\n        vm.stopPrank();\n        o = new MockOracle(0, 400 ether);\n        vm.prank(owner);\n        exa.listMarket(o, 10, 5 ether);\n\n        vm.deal(lp, 100 ether);\n        vm.prank(lp);\n        pool.deposit{value: 8 ether}();\n        vm.deal(alice, 100 ether);\n        vm.prank(alice);\n        ex.deposit{value: 10 ether}();\n    }\n\n    function test_withdrawRepricedByOneBlockSpotPush() public {\n        // alice: 10x long, 0.2 ETH stake (size 2 ETH = the coin cap), max win 400% = 0.8 ETH reserved\n        vm.prank(alice);\n        ex.open(0, true, 10, 0.2 ether, type(int24).max);\n\n        // LP asks to leave with every share; the request snapshots the value at today's prices\n        uint256 shares = pool.balanceOf(lp);\n        vm.prank(lp);\n        pool.requestWithdraw(shares);\n        (, uint256 snapshot,) = pool.withdrawRequests(lp);\n\n        vm.warp(block.timestamp + pool.withdrawCooldown());\n        // nothing changed on the market: fast and slow still at tick 0\n        (uint256 lowBefore,) = pool.navRange();\n        uint256 honest = (shares * lowBefore) / pool.totalSupply();\n        if (honest > snapshot) honest = snapshot;\n\n        // one-block live-tick push of +49% (fast and slow untouched): every long marks at its max win\n        o.set(4000, 0, 0);\n        uint256 balBefore = lp.balance;\n        vm.prank(lp);\n        pool.withdraw();\n        uint256 paid = lp.balance - balBefore;\n        o.set(0, 0, 0); // the push is undone right after\n\n        // Expected: the LP is paid what the request was worth (fast and slow never moved). Actual: 0.8 ETH less.\n        assertEq(paid, honest, \"withdrawal repriced by a one-block spot push\");\n    }\n}","reproduction":"Fresh LevPool seeded with 0.001 ETH, LevExchange + LevLens as navSource, risk config with bands/spread switched off (as perps/contracts/test/Base.t.sol does), one market on a MockOracle at tick 0 with 400 ETH depth, maxLeverage 10, netCap 5 ETH. lp deposits 8 ETH. alice deposits 10 ETH and opens a 10x long with stake 0.2 ETH (size 2 ETH, max win 400% = 0.8 ETH). lp calls requestWithdraw(all shares): snapshot = 8.0025 ETH. Warp 1 day. Fast and slow still at tick 0. Expected payout: shares*low/supply with low = cash - 0 = 8.0025 ETH (capped by the snapshot), fee 0 since ratio 1.0. Attacker sets only the live tick to +4000 (~+49%) for that block (MockOracle.set(4000, 0, 0)); lp calls withdraw(). Actual payout: 7.0946 ETH (owedHigh = 0.8 ETH max win; pool.withdrawFeeBps() reads 150 in that block, so 0.108 ETH is kept as a stress fee as well). The live tick is reset right after; 0.908 ETH stays in the pool for the remaining shares. Reproduced: perps/contracts/test/scratch/LpWithdrawSpotPush.t.sol, run with `cd perps/contracts && forge test --match-path test/scratch/LpWithdrawSpotPush.t.sol`; fails on this code with '7094560679915010624 != 8002499687539057617'.","severity":"medium","snippet":"        assets = (r.shares * low) / totalSupply();","title":"LevPool.withdraw is repriced by the coin pool's live tick in the execution block, with no minimum payout: a one-block spot push moves part of the leaving LP's money (mark plus stress fee) to the remai"},{"citation":"resolved","description":"liabilityRange() computes owedLow, from which LevPool prices deposits at high = cash - owedLow (perps/contracts/src/LevPool.sol:86-88, 172-176). The doc comment says the fee credit is 'A floor, so deposits never pay for fees that might not arrive'. _feesOwedLowerBound credits every open position on a side with the pool's share of a size-based close fee (size * openFeeSizeBps * poolFeeShareBps / BPS^2, line 163) plus accrued holding fees, regardless of the position's equity. But a position whose equity is <= 0 settles through LevExchange._outcome's liquidation branch with no fee and no holding fee beyond the stake+margin the pool already gets; liabilityRange already counts that position at -withMargin (line 138). So the same position is credited twice: once as the full stake+margin loss and again as a close-fee share and holding fee that will never be paid. The same over-credit applies to liquidations with small positive equity (fees = left - reward, which can be below the size-based fee) and to keeper max-win/trigger closes (the pool share is taken after keeperRewardBps). Effect: while such positions are open, previewDeposit/deposit price LP shares above any value the pool can realise, so new LPs overpay and existing LPs (and DEAD house shares) capture the difference. Magnitude per bankrupt position: 0.125% of size (defaults openFeeSizeBps 25, poolFeeShareBps 5000) plus any holding fee above the free waiver; bounded, and it grows with bankrupt open interest (e.g. on a stale feed). Fix: only credit closeFeeShare and holding fees for positions with positive equity at the pool-favourable price, i.e. compute the fee credit per position inside the liabilityRange loop and cap each position's total (pnl clamp + hold + fee share) at its stake+margin (lo += max(v - hold_i - feeShare_i, -withMargin)), and apply the keeper-reward haircut to the fee share.","line":163,"path":"perps/contracts/src/LevLens.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {LevExchange} from \"src/LevExchange.sol\";\nimport {LevExchangeAdmin} from \"src/LevExchangeAdmin.sol\";\nimport {LevPool} from \"src/LevPool.sol\";\nimport {LevLens} from \"src/LevLens.sol\";\nimport {RiskConfig} from \"src/LevTypes.sol\";\nimport {ILevPool, ILevExchange, ILevNav} from \"src/interfaces/ILevPool.sol\";\nimport {MockOracle} from \"src/mocks/MockOracle.sol\";\n\n/// A bankrupt, not-yet-liquidated position: liabilityRange's \"lower bound\" still credits the pool with that\n/// position's close-fee share, so the deposit price (cash - owedLow) is above what the pool can ever book.\ncontract NavFloorTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    LevLens lens;\n    MockOracle o0;\n    address owner = makeAddr(\"owner\");\n    address lp = makeAddr(\"lp\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_750_000_000);\n        pool = new LevPool{value: 0.001 ether}(owner);\n        ex = new LevExchange(ILevPool(address(pool)), owner, makeAddr(\"treasury\"), address(new LevExchangeAdmin()));\n        exa = LevExchangeAdmin(payable(address(ex)));\n        lens = new LevLens(ex);\n        vm.startPrank(owner);\n        pool.setExchange(ILevExchange(address(ex)));\n        pool.setNavSource(ILevNav(address(lens)));\n        exa.setRiskConfig(\n            RiskConfig({\n                openBandSpotTicks: 30_000,\n                openBandTrendTicks: 30_000,\n                pumpBandTicks: 30_000,\n                spreadBaseBps: 0,\n                spreadImpactBps: 0,\n                spreadMaxBps: 0,\n                sizeTier5xBps: 500,\n                sizeTier2xBps: 500,\n                hourlyGrowthBps: 60_000,\n                platformSizeBps: 100_000\n            })\n        );\n        o0 = new MockOracle(0, 400 ether);\n        exa.listMarket(o0, 10, 5 ether);\n        vm.stopPrank();\n        vm.deal(lp, 100 ether);\n        vm.prank(lp);\n        pool.deposit{value: 8 ether}();\n        vm.deal(alice, 10 ether);\n        vm.prank(alice);\n        ex.deposit{value: 10 ether}();\n    }\n\n    function test_depositPriceCountsCloseFeeOfBankruptPosition() public {\n        vm.prank(alice);\n        uint256 id = ex.open(0, true, 10, 0.2 ether, type(int24).max); // size 2 ETH\n        vm.warp(block.timestamp + 60);\n        o0.setAll(-1625); // about -15%: pnl about -0.3 ETH on a 0.2 ETH stake, equity < 0\n\n        (, uint256 highBefore) = pool.navRange();\n        // Liquidating books exactly the stake to the pool (equity <= 0: no fees, no reward).\n        vm.prank(makeAddr(\"keeper\"));\n        ex.liquidate(id);\n        (uint256 lowAfter, uint256 highAfter) = pool.navRange();\n        assertEq(lowAfter, highAfter, \"no positions left\");\n\n        // The deposit price must never be above what the pool really ends up holding for that position.\n        assertLe(highBefore, highAfter, \"deposit-side NAV overstated before the liquidation\");\n    }\n}","reproduction":"Default config, neutral risk config, market listed at tick 0 with 400 ETH liquidity, LP deposits 8 ETH. Alice opens a 10x long with stake 0.2 ETH (size 2 ETH). After 60 s the oracle moves all ticks to -1625 (~-15%): pnl ~ -0.3 ETH, equity < 0, position not yet liquidated. pool.navRange() high = 8.206000 ETH. A keeper calls liquidate(id): equity <= 0 so the pool books exactly the 0.2 ETH stake and no fee. navRange() afterwards (no positions left) = 8.203500 ETH. Expected: high before liquidation <= 8.2035 ETH. Actual: 8.2060 ETH, overstated by 0.0025 ETH = 2 ETH * 25 * 5000 / 1e8, the close-fee share of a position that pays no fee. Reproduced: perps/contracts/test/scratch/NavFloor.t.sol, run with `cd perps/contracts && forge test --match-path test/scratch/NavFloor.t.sol`; fails on this code with '8206000000000000000 > 8203500000000000000'.","severity":"low","snippet":"        uint256 closeFeeShare = (s.size * c.openFeeSizeBps * c.poolFeeShareBps) / (BPS * BPS);","title":"LevLens._feesOwedLowerBound is not a lower bound: deposit-side NAV credits close fees (and holding fees) that bankrupt or liquidated positions never pay"},{"citation":"resolved","description":"Two handover paths exist for a remote-launched token: handOver (line 166, proposes creatorOf[token] as the launchpad's creatorPayout via pad.setCreatorPayout, which the creator later accepts) and transferCreator (line 174, changes creatorOf in the remote only). They are not mirrored: transferCreator does not cancel a proposal the old creator already made on the launchpad (pad.pendingCreatorPayout[token]). After handOver then transferCreator(to), the old creator can still call pad.acceptCreatorPayout(token) (contracts/src/PepesFamily.sol:511-516) and becomes the launchpad's creatorPayout, taking every future creator fee directly. The new creator's handOver then reverts on the launchpad (PepesFamily.setCreatorPayout checks msg.sender == creatorPayout, contracts/src/PepesFamily.sol:506, which is now the old creator), and remote.collectCreatorFees forwards nothing because the launchpad no longer pays the remote. Low because the old creator must have been the creator to set it up, so the loss is confined to whoever bought or was handed the role from them. Fix: in transferCreator, if pad.pendingCreatorPayout(token) != address(0) call pad.setCreatorPayout(token, address(0)) to cancel it (the remote is still the launchpad payout at that point); and revert transferCreator/handOver when pad.creatorPayout(token) != address(this), since the role has already left the remote.","line":174,"path":"contracts/src/PepesFamilyRemote.sol","reproduction":"State: token launched via remote.launchFor with creator alice, FeeSplit(100,100,100). Sequence: (1) alice calls remote.handOver(token): pad.pendingCreatorPayout(token) == alice. (2) alice calls remote.transferCreator(token, bob): remote.creatorOf(token) == bob. (3) alice calls pad.acceptCreatorPayout(token). Expected: bob holds the creator role and receives creator fees; alice's accept should fail. Actual: pad.creatorPayout(token) == alice; bob's remote.handOver(token) reverts with PepesFamily.NotCreator; after a 10 WETH buy, remote.collectCreatorFees(token) returns 0 and pad.collectCreatorFees(token) pays alice 0.1 WETH. Reproduced with a scratch test (contracts/test/scratch/RemoteTransferCreator.t.sol, asserting the actual behaviour above) that passes on this code, run with `cd contracts && forge test --match-path test/scratch/RemoteTransferCreator.t.sol`.","severity":"low","snippet":"    function transferCreator(address token, address to) external {","title":"PepesFamilyRemote.transferCreator leaves a pending launchpad-side handover in place, so the old creator can keep the fee role after passing it on"}],"hash":"d1b6311f93af8d08e7efea8824ccedb3e47cc325c70d9460221bf2f8f804ab05","nodeId":"23fba90e-109e-4f94-abda-a636d7b9f1fd","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds five findings, four of them with proofs that fail on the current code.\n\n**What I did.** I ran all four specialist proofs in their Foundry projects. Each fails for exactly the reason its finding states. I then checked the reasoning against the source for every finding, wrote my own scratch test for the one low that came without a proof, merged duplicates, and dropped one item that is not a defect.\n\n**Kept, with reproductions:**\n\n- **High, PepesFamilyRemote.collectCreatorFees:** anyone can call the launchpad's public collector first, which pays the remote directly. The remote only forwards the balance delta of its own call and has no sweep, so those creator fees are stuck forever. Proof fails with `forwarded amount: 0 != 100000000000000000`.\n- **Medium, PepesFamilyRemote._buy:** the remote buys as itself, so the hook marks the remote active rather than the end buyer. A repeat cross-chain buyer's rewards expire and anyone can recycle them. Two specialists (flow and permissions) reported this; merged into one finding. Proof fails at the lastActive assertion.\n- **Medium, LevPool.withdraw:** the live tick enters the withdrawal valuation, so a one-block spot push cuts the leaving LP's payout and triggers the stress fee on top. The economics specialist's separate low on the stress-fee tier is the same function, root cause and fix, so I folded it in. Proof fails with `7094560679915010624 != 8002499687539057617`.\n- **Low, LevLens._feesOwedLowerBound:** deposit-side NAV credits a close-fee share that a bankrupt position never pays. Proof fails with the 0.0025 ETH overstatement.\n- **Low, PepesFamilyRemote.transferCreator:** a pending launchpad handover survives the transfer, so the old creator can still accept and keep the fee role. Reproduced with my own scratch test that confirms the actual behaviour.\n\n**Dropped:** the info item on the rug guard only firing at an 80% crash. The code matches the specialist's numbers, but the threshold is documented in `perps/DESIGN.md` as the intended design, and the specialist proposed no change. That is a design boundary, not a defect.\n\n**Not done:** I did not run a fresh twelve-specialty pass of my own beyond verifying what the specialists reported. The working tree is untouched apart from the findings file and the two `test/scratch/` directories.","treeHash":null,"usage":{"cachedInputTokens":1209437,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":36428,"runtime":"claude","turns":32,"wallClockMs":897955}}],"verification":[]}