{"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":"1425b92e-2a79-4c8e-9ba8-fa2163df4825","kind":"audit","nodes":[{"acceptedSubmissionHash":"605b16b7e774dfc10ed82938bc16b5b2e2c7f63859e4be5301b0df832496dc15","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":"858a1020c5bd214f3f50964432bea3581228352228aa99af3d8015515a08ee09","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":"f65d4fac26381e9b4c77422d35664da92e26cbcb9a29f5cfdac1bb8069a9bcba","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":"6098b4de0bf3fb7b316b38b07f349b9dcd6cebedbf9671adc09b72626f7b9a38","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":"f309da255d2b840d6dddc2a22fe24c23bf9fe5902ff5f866f2406ceffe990cfd","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 brief (pepesfamily/perps/AUDIT.md). It tells the auditors:\nhow the system works and which files are in scope;\nthe eight things that must always hold, e.g. the money always adds up and no trader can take more than the rules allow;\nevery admin power, and the known limits so they aren’t reported again;\nwhere to look hardest.\nScope: the exchange, the pool, the one-click router, the three price feeds, the shared math and the deploy scripts. The keeper, the relayer and the website are left out.\nBuilds from a fresh copy: the contracts’ libraries are now linked properly. I cloned the repo from GitHub fresh, as an auditor would, and all 211 tests pass.","parentJobId":null,"planHash":"660ea5ddff00e2ddf03bd7e7de09866b528cd47251b49c05c902b47d0eec74dc","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"1425b92e-2a79-4c8e-9ba8-fa2163df4825","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":"52548","feedbackHash":"396703e41d8a243096266b28a3c0e809a002644e7bed3ee4d70ac88d86d6f68a","nodeKey":"audit_economics","submissionHash":"605b16b7e774dfc10ed82938bc16b5b2e2c7f63859e4be5301b0df832496dc15","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52623","feedbackHash":"8a1243b54f66cbc95932c460cbe33dcd690b1db5fcd9b1b5fa6012c14e7a53e2","nodeKey":"audit_flow","submissionHash":"858a1020c5bd214f3f50964432bea3581228352228aa99af3d8015515a08ee09","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51438","feedbackHash":"1ea4559527ded2a17205b56540b665fe365db4d916e6ef552efa1a314af7cc8f","nodeKey":"audit_judge","submissionHash":"f65d4fac26381e9b4c77422d35664da92e26cbcb9a29f5cfdac1bb8069a9bcba","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52625","feedbackHash":"0589b122b0e0db2f2a0fff77403af568154345a08ee9bc6eb47a0b759288c124","nodeKey":"audit_math","submissionHash":"6098b4de0bf3fb7b316b38b07f349b9dcd6cebedbf9671adc09b72626f7b9a38","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52622","feedbackHash":"648abf2f4c9a8f6ce5fe0c549ebf63a0da07adc940de501536b83e0114969e26","nodeKey":"audit_permissions","submissionHash":"f309da255d2b840d6dddc2a22fe24c23bf9fe5902ff5f866f2406ceffe990cfd","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"4e5435e7512fe74f751528a30b4f53ef20641c3faec1b4fc1784649d4b092630","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9b657b3675e34ec5","findings":[{"citation":"resolved","description":"A plain close prices a long at the worse of the live and the fast price (LevExchange._closePosition, `_min24(spot, fast)`). A trigger close instead goes through LevLogic.triggerExit, which clamps the live price to at most openBandSpotTicks (488 ticks, ~5%) past the fast price. The round-3 fix (R3-1) only refuses trigger levels the fast price has *already* reached (LevExchangeAdmin.setTriggers, lines 127-130); a level one tick past the current fast price is accepted and is reached as soon as the fast average moves one tick, which during a crash happens at the next V3 TWAP block or the next V4 sample. closeByTrigger may be called by the owner (LevExchange.sol:143-167, only the keeper share is withheld). So a long's owner who sees the live price gap down can arm a stop one tick under the fast price, wait one tick, and exit at fast-5% while the live price is 30% lower; a close would have liquidated the position. The pool pays the whole gap beyond 5%. The same holds for shorts on a pump (floor at fast+band) and for any ordinary stop-loss user on a V3-fed coin: every stop becomes a guaranteed stop within 5% of the lagging average, with the pool underwriting the gap. This breaks the brief's rules that fills take the worse of live and fast and that a trigger 'can't be used as a close at the lagging fast price', and lets a trader profit from a pushed or crashed live price at the pool's expense. The regression test test_R3_1_triggerExitCountsTheLivePrice runs with the neutral 30k-tick band and so never exercises the clamp. Fix (preserves the keeper-push protection the clamp was added for): in closeByTrigger, when `_dist(spot, fast) > r.openBandSpotTicks` revert NotTriggered (the trigger waits until live and fast agree, exactly as opens do under bandsOk), and otherwise price the exit as _closePosition does (worse of spot and fast, pump band, spread). Alternatively keep the clamp but never pay more than the close price: exit = isLong ? min(triggerExit, closeExit) : max(...). Either way, add a regression test under the default RiskConfig.","line":121,"path":"perps/contracts/src/libraries/LevLogic.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 long's owner arms a stop-loss one tick under the fast price while the live price has already crashed far\n/// below it. One tick of fast movement later, closeByTrigger prices the exit at fast - openBandSpotTicks, far\n/// better than the \"worse of live and fast\" a close gives. Fails while the trigger payout exceeds the close payout.\ncontract TriggerBeatsCloseTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    LevLens lens;\n    MockOracle o0;\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        // the production risk rules: a 5% live-vs-fast band\n        exa.setRiskConfig(\n            RiskConfig({\n                openBandSpotTicks: 488,\n                openBandTrendTicks: 1823,\n                pumpBandTicks: 4055,\n                spreadBaseBps: 10,\n                spreadImpactBps: 20_000,\n                spreadMaxBps: 300,\n                sizeTier5xBps: 10,\n                sizeTier2xBps: 25,\n                hourlyGrowthBps: 5000,\n                platformSizeBps: 30_000\n            })\n        );\n        vm.stopPrank();\n        o0 = new MockOracle(0, 4000 ether);\n        vm.prank(owner);\n        exa.listMarket(o0, 10, 100 ether);\n        vm.deal(lp, 100 ether);\n        vm.prank(lp);\n        pool.deposit{value: 50 ether}();\n        vm.deal(alice, 10 ether);\n        vm.prank(alice);\n        ex.deposit{value: 5 ether}();\n    }\n\n    function test_stopLossArmedOneTickAwayBeatsClose() public {\n        vm.prank(alice);\n        uint256 id = ex.open(0, true, 5, 1 ether, type(int24).max);\n        vm.warp(block.timestamp + 60);\n\n        // The coin crashes 30% live; the fast average has not moved yet.\n        o0.set(-3567, 0, 0);\n        // The stop is below the fast price, so setTriggers accepts it.\n        vm.prank(alice);\n        exa.setTriggers(id, false, 0, true, -1);\n        // One tick of fast movement arms it. The live price is still -30%.\n        o0.set(-3567, -1, 0);\n\n        uint256 snap = vm.snapshotState();\n        uint256 b0 = ex.balanceOf(alice);\n        uint256 poolCash0 = pool.cash();\n        vm.prank(alice);\n        ex.close(id, type(int24).min);\n        uint256 viaClose = ex.balanceOf(alice) - b0;\n        uint256 poolAfterClose = pool.cash();\n\n        vm.revertToState(snap);\n        vm.prank(alice);\n        ex.closeByTrigger(id);\n        uint256 viaTrigger = ex.balanceOf(alice) - b0;\n        uint256 poolAfterTrigger = pool.cash();\n\n        emit log_named_uint(\"payout via close\", viaClose);\n        emit log_named_uint(\"payout via trigger\", viaTrigger);\n        emit log_named_uint(\"pool cash after close\", poolAfterClose);\n        emit log_named_uint(\"pool cash after trigger\", poolAfterTrigger);\n        assertLe(viaTrigger, viaClose, \"a trigger close must not pay more than a close at the worse of live and fast\");\n        assertGe(poolAfterTrigger, poolAfterClose, \"the pool must not lose more through the trigger path\");\n        poolCash0;\n    }\n}","reproduction":"Default RiskConfig (openBandSpotTicks 488), pool 50 ETH, coin liquidity 4000 ETH, netCap 100 ETH. Alice deposits 5 ETH and opens a 5x long, stake 1 ETH, at tick 0. 60 s later the oracle reports spot -3567 (-30%), fast 0, slow 0. Alice calls setTriggers(id, false, 0, true, -1): accepted because -1 < fast. Oracle then reports spot -3567, fast -1, slow 0. (a) ex.close(id, MIN_TICK): exit = min(-3567,-1) - spread = about -3600 -> pnl = 5 x -30% < -100% -> liquidation, payout 0, pool cash 51.007 ETH. (b) ex.closeByTrigger(id) by alice: exit = max(min(-3567,-1), -1-488) = -489, minus spread -> pnl about -0.25 ETH, payout 0.7157 ETH, pool cash 50.285 ETH. Expected: the trigger payout is never above the close payout (brief 1/2/8). Actual: the trigger path pays 0.7157 ETH more and the pool is 0.72 ETH poorer. Run: forge test --match-path test/scratch/TriggerBeatsClose.t.sol (fails on this code with 'a trigger close must not pay more than a close').","severity":"high","snippet":"        if (isLong && e < int256(fast) - band) e = int256(fast) - band;\n        if (!isLong && e > int256(fast) + band) e = int256(fast) + band;","title":"closeByTrigger floors the exit at fast - openBandSpotTicks, so a stop-loss armed one tick from the fast price pays far more than a close during a live-price crash"},{"citation":"resolved","description":"LevLens.liabilityRange marks the valuation `blocked` whenever any market with open size has a feed that reverts, and LevPool.deposit, requestWithdraw and withdraw all revert with ValuationPaused while blocked (LevPool.sol:86, 100, 123). The exchange itself treats a feed that has failed for staleSeconds (10 min) as settleable at the last good fast price (LevExchange._requireStale), and the lens already substitutes lastGoodFast/lastGoodTick for the prices when !ok, yet it still blocks. A position that is not liquidatable at the last good price (e.g. 1x, or any leverage with margin added), not at max win and without a trigger can only be closed by its owner. There is no owner-side remedy: setMarket cannot delist or force-close, the oracle is immutable per market, and no admin function moves positions. So a single 0.01 ETH position (plus 1 ETH margin, which pushes liquidation by holding fees out by hundreds of days) on a coin whose V4 keepers have stopped sampling freezes the whole pool's LP cash. Withdraw requests that matured during the freeze also expire after withdrawWindow (2 days) and must be cancelled and re-requested, restarting the 24 h cooldown. This weakens 'LP withdrawals only come from cash not reserved' into 'LP withdrawals depend on every listed feed staying alive'. Fix: once the market meets the exchange's own stale condition (downSince != 0, now >= downSince + staleSeconds, now <= lastFailAt + STALE_PING_GAP, lastGoodAt <= downSince), value it at lastGoodFast/lastGoodTick without setting blocked, exactly as the exchange settles it; keep blocking only during the first staleSeconds and for rugs. Optionally add an owner/permissionless 'settle at stale price' for positions on a market that has been stale for longer than some bound, so a dead coin can be wound down.","line":112,"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 {LevExchangeBase} from \"src/LevExchangeBase.sol\";\nimport {LevPool} from \"src/LevPool.sol\";\nimport {LevLens} from \"src/LevLens.sol\";\nimport {ILevPool, ILevExchange, ILevNav} from \"src/interfaces/ILevPool.sol\";\nimport {MockOracle} from \"src/mocks/MockOracle.sol\";\n\n/// One position on a coin whose feed has died keeps LevLens.liabilityRange `blocked`, so every LP deposit and\n/// withdrawal reverts, for as long as that position stays open. The exchange itself settles such positions at the\n/// last good fast price once the feed is stale, but the valuation never does. The position is not liquidatable\n/// (margin), not at max win, has no trigger, and no admin function can close it.\ncontract StaleMarketFreezesLPsTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    LevLens lens;\n    MockOracle o0;\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        vm.stopPrank();\n        o0 = new MockOracle(0, 400 ether);\n        vm.prank(owner);\n        exa.listMarket(o0, 10, 5 ether);\n        vm.deal(lp, 100 ether);\n        vm.prank(lp);\n        pool.deposit{value: 20 ether}();\n        vm.deal(alice, 10 ether);\n        vm.prank(alice);\n        ex.deposit{value: 5 ether}();\n    }\n\n    function test_oneStalePositionBlocksLPWithdrawalsIndefinitely() public {\n        vm.prank(alice);\n        uint256 id = ex.open(0, true, 1, 0.01 ether, type(int24).max);\n        vm.prank(alice);\n        exa.addMargin(id, 1 ether);\n\n        // The feed dies for good. Keepers keep pinging, so the stale clock runs and the exchange settles at the\n        // last good fast price.\n        o0.setFailing(true);\n        exa.refresh(0);\n        for (uint256 i; i < 864; ++i) {\n            // a day of failed reads, pinged every 100 s (inside STALE_PING_GAP)\n            vm.warp(vm.getBlockTimestamp() + 100);\n            exa.refresh(0);\n        }\n\n        // Nobody but the owner can close it.\n        vm.expectRevert(LevExchangeBase.NotLiquidatable.selector);\n        ex.liquidate(id);\n        vm.expectRevert(LevExchangeBase.NotAtMaxWin.selector);\n        ex.closeAtMaxWin(id);\n        vm.expectRevert(LevExchangeBase.NotTriggered.selector);\n        ex.closeByTrigger(id);\n        // Pausing the coin changes nothing for valuation.\n        vm.prank(owner);\n        exa.setMarket(0, false, 10, 5 ether);\n\n        // The exchange considers the market settleable at the last good price...\n        assertTrue(lens.getPosition(id).action == lens.ACTION_NONE());\n        // ...but every LP is locked out: 20 ETH of LP cash against a 0.01 ETH position on a dead coin.\n        (,, bool blocked) = lens.liabilityRange();\n        assertFalse(blocked, \"a stale market should be valued at its last good price, not block the pool\");\n        vm.prank(lp);\n        pool.requestWithdraw(pool.balanceOf(lp));\n    }\n}","reproduction":"Pool with 20 ETH of LP cash, market 0 on a MockOracle. Alice opens a 1x long, stake 0.01 ETH, then addMargin(id, 1 ether). The oracle starts reverting; refresh(0) is called every 100 s for a day (an unbroken run, so the exchange's stale path is live). Then: liquidate(id) reverts NotLiquidatable (equity 1.01 ETH vs 0.0015 threshold), closeAtMaxWin(id) reverts NotAtMaxWin, closeByTrigger(id) reverts NotTriggered; owner setMarket(0,false,...) changes nothing. lens.liabilityRange() returns blocked == true and pool.requestWithdraw(shares) by the LP reverts ValuationPaused. Expected: a market the exchange settles at its last good price is valued at that price and LPs can withdraw; actual: all LP flows revert until alice chooses to close. Run: forge test --match-path test/scratch/StaleMarketFreezesLPs.t.sol (fails on this code at 'a stale market should be valued at its last good price').","severity":"medium","snippet":"            if (!ok || m.ruggedPending != 0 || LevLogic.isRug(c, spot, slow) || LevLogic.isRug(c, fast, slow)) {\n                blocked = true;\n            }","title":"One open position on a coin whose feed has died blocks every LP deposit and withdrawal for as long as it stays open; no permissionless or admin path can clear it"},{"citation":"resolved","description":"closeByTrigger withholds the keeper share only when msg.sender == p.owner. The brief's rule 8 ('The owner gets no keeper share for closing their own position') is therefore enforced only against the position owner's own EOA. A trader who routes the call through any other address they control (a second wallet, or a contract) receives keeperRewardBps (25%) of the close fee into that address's exchange balance (LevExchange._outcome, lines 406-409), and that balance is withdrawable at once. Combined with the trigger pricing issue reported separately, the trader's preferred exit path also costs them 25% less in fees than a plain close. The impact is a fee leak (25% of the trigger close fee, i.e. about 0.25-0.75% of stake per close, split away from treasury and pool) rather than a loss of principal, hence low. Fix: either drop the keeper share for EXIT_TRIGGER when the trigger was set within the last N seconds, or accept that the keeper share is a general rebate and document it; the msg.sender check gives no real protection and should not be described as a guarantee.","line":166,"path":"perps/contracts/src/LevExchange.sol","reproduction":"Base setup (test/Base.t.sol). Alice opens a 2x long, stake 0.1 ETH, sets a take-profit at +10% (953 ticks), oracle moves to 1000. (a) alice calls closeByTrigger(id): treasuryBalance rises by the full treasury share of the fee, keeper reward 0. (b) Instead, an address eve that alice controls calls closeByTrigger(id): ex.balanceOf(eve) increases by 25% of the close fee (about 0.0003 ETH on this trade), treasury and pool receive 25% less. Expected per rule 8: no keeper share flows back to the position's owner for closing their own position; actual: any second address collects it.","severity":"low","snippet":"        _finish(id, p, m, c, LevLogic.pnl(p, exit), EXIT_TRIGGER, msg.sender == p.owner ? address(0) : msg.sender);","title":"The 'owner gets no keeper share' rule in closeByTrigger is bypassed by calling from any second address, turning it into a 25% discount on the close fee"}],"hash":"605b16b7e774dfc10ed82938bc16b5b2e2c7f63859e4be5301b0df832496dc15","nodeId":"0676798a-daf7-4a83-8d64-5f8f4a0220ee","outcome":"completed","summary":"The review is complete. Three findings are recorded in `.imd-findings.json`, two of them with Foundry proofs under `test/scratch/` that fail on the current code.\n\n**What I checked.** The economics passes (Economic Security, Invariant, Flow Gap) over the exchange, pool, lens, shared math, oracles and deploy script, against the eight guarantees in the brief. The build from the fresh tree and all 211 tests pass. The books invariant, max-win coverage, LP share pricing under spot pushes, the hourly growth giveback, the rug cap flow, the breaker and the stale path all held up under the concrete cases I traced. Pushing a coin's price to hit a max win costs about what it earns under the current caps.\n\n**Findings.**\n\n- **High. Trigger closes beat the \"worse of live and fast\" rule.** `LevLogic.triggerExit` floors a long's exit at the fast price minus 5%. The round-3 fix only refuses trigger levels the fast price has already reached, so the owner arms a stop one tick under the fast price during a live crash, waits one tick, and calls `closeByTrigger` themselves. In the proof a 5x long with 1 ETH stake is liquidated through `close` (payout 0) but receives 0.716 ETH through the trigger path, and the pool is 0.72 ETH poorer. Every ordinary stop-loss on a V3-fed coin becomes a guaranteed stop within 5% of the lagging average, underwritten by the pool. The existing regression test runs with the neutral 30k-tick band and never exercises the clamp. Proposed fix: make the trigger wait while live and fast disagree by more than the band, as opens already do, and otherwise price it like a close.\n\n- **Medium. One position on a dead feed freezes all LP flows.** `LevLens.liabilityRange` sets `blocked` whenever a market with open size has a reverting feed, so deposits, withdraw requests and withdrawals all revert. The exchange itself settles that market at the last good price once stale, and the lens already substitutes those prices, yet still blocks. A 0.01 ETH position with 1 ETH of margin cannot be liquidated or closed by anyone but its owner, and the owner has no delist or force-close. The proof shows 20 ETH of LP cash locked behind it after a day of stale pings. Fix: value a market that meets the exchange's own stale condition at its last good price instead of blocking.\n\n- **Low. The \"owner gets no keeper share\" rule is bypassed by any second address.** `closeByTrigger` withholds the share only when the caller is the position owner's address, so routing through another wallet collects 25% of the close fee.\n\n**Not reported** as defects, since the brief lists them as design or known: fast-only pricing for `closeAtMaxWin`, the capped-winner NAV overstatement, keeper trust on V4 sampling, and the stress-fee tier. Router EIP-712 and permission checks were read but not audited in depth, as they sit outside the economics area.","treeHash":null,"usage":{"cachedInputTokens":2336278,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":55163,"runtime":"claude","turns":42,"wallClockMs":1197245}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"96ece23c63b791a1","findings":[{"citation":"resolved","description":"LevLogic.sideLiability clamps the whole side's P&L at -(sum of stakes), not each position at -(its own stake). A position's loss can never exceed its stake (+margin), but once one position's P&L is past its stake (a gap move or a liquidation the keepers haven't reached yet), its excess loss is netted against the wins of other positions on the same side. LevLens.liabilityRange then reports owedHigh too low, so LevPool.nav()/previewRedeem (cash - owedHigh), the value withdrawals pay out at, is too high. Invariant 4 ('withdrawals pay the lowest plausible value') breaks, and remaining LPs pay the difference. This is the loss-floor side of the same aggregate clamp as the known 'capped winners' limit, but it moves value the other way (it overpays withdrawers, it doesn't underprice deposits), and the known limit's fix of bounding winners per position would not fix it. Boundary: the per-position floor at pnl = -stake. Assumption: the side sum of clamped P&L equals the sum of per-position clamped P&L. Actual: sum_i max(pnl_i, -stake_i) >= max(sum_i pnl_i, -sum_i stake_i), and it is strictly greater whenever one position is below its floor while others are above it. Fix: keep the floor per position. For example, have the liability computation exclude (or floor individually) positions whose P&L at the tick is below -stake, or block LP withdrawals (valuationOk) while any position is liquidatable at the valuation ticks, much as rugs already block them. Preconditions: owedHigh is the max over spot, fast and slow, so the bankrupt position has to be past its stake at all three, slow included. Positions reach that state only when liquidation lags: the fast price gaps past the liquidation level (a sampled feed's median jumps once two crashed samples land), and nobody liquidates for a while. Nothing pushes third parties to clear these positions: in _outcome a liquidation with equity <= 0 has left = 0, so the keeper reward is 0 and only the protocol's own keeper has a reason to call liquidate(). A withdrawer who benefits from the inflated value can simply wait for this state and withdraw before anyone liquidates.","line":171,"path":"perps/contracts/src/libraries/LevLogic.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 long past its own floor (loss > stake) offsets other longs' wins in sideLiability, so the pool's\n/// withdrawal value (nav = cash - owedHigh) is overstated until the bankrupt position is liquidated.\ncontract NavFloorClampTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    LevLens lens;\n    MockOracle o;\n    address owner = makeAddr(\"owner\");\n    address lp = makeAddr(\"lp\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address keeper = makeAddr(\"keeper\");\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        o = new MockOracle(0, 4000 ether);\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        exa.listMarket(o, 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.deal(bob, 10 ether);\n        vm.prank(alice);\n        ex.deposit{value: 5 ether}();\n        vm.prank(bob);\n        ex.deposit{value: 5 ether}();\n    }\n\n    function test_bankruptLongInflatesWithdrawalNav() public {\n        // Alice: 2x long, stake 0.5 ETH, at tick 0.\n        vm.prank(alice);\n        ex.open(0, true, 2, 0.5 ether, type(int24).max);\n        // The coin doubles; Bob opens a 10x long, stake 0.1 ETH, at tick 6932.\n        o.setAll(6932);\n        vm.prank(bob);\n        uint256 bobId = ex.open(0, true, 10, 0.1 ether, type(int24).max);\n        // The coin falls to tick 2000 (x1.2214). Alice is up ~0.221 ETH; Bob's P&L is -0.389 ETH on a\n        // 0.1 ETH stake, so he can lose at most 0.1. Per position the pool owes +0.121 ETH; the side clamp\n        // books -0.168 ETH (Bob's loss past his stake cancels Alice's win).\n        o.setAll(2000);\n        uint256 navBefore = pool.nav();\n        // Liquidating Bob only turns his stake into pool cash; the pool's worst-case value must not fall.\n        vm.prank(keeper);\n        ex.liquidate(bobId);\n        uint256 navAfter = pool.nav();\n        assertLe(navBefore, navAfter, \"withdrawal nav was overstated while the bankrupt long was open\");\n    }\n}","reproduction":"Default config, neutral risk (no spread), one market (MockOracle, 4000 ETH liquidity, netCap 5 ETH). LP deposits 8 ETH. Alice opens a 2x long, stake 0.5 ETH, at tick 0. setAll(6932) (x2); Bob opens a 10x long, stake 0.1 ETH. setAll(2000) (x1.2214): Alice pnl = +0.2214 ETH, Bob pnl = 1 ETH*(1.2214/2-1) = -0.389 ETH, but he can only lose his 0.1 stake. Per position the long side owes +0.1214 ETH. sideLiability books 0.2214-0.389 = -0.168 ETH (above the side floor of -0.6). Expected: pool.nav() <= its value after Bob is liquidated. Actual: pool.nav() = 8.172675 ETH before liquidate(bob) and 7.883359 ETH after, so liquidating a bankrupt loser cuts the withdrawal value by 0.289 ETH (= 0.389 - 0.1). An LP with a matured request (r.assets above the inflated value) who calls withdraw() in that window takes their share of the 0.289 ETH from the remaining LPs. The proof test fails with '8172675273682695772 > 7883359454992142861'.","severity":"medium","snippet":"        int256 floor = -int256(s.stake + (withMargin ? s.margin : 0));","title":"Side-level loss floor lets a bankrupt long cancel other longs' wins, overstating the withdrawal NAV until it is liquidated"},{"citation":"resolved","description":"_outcome pays `keeperRewardBps` of the close and profit fees to `keeper` for EXIT_MAX_WIN, and closeAtMaxWin always passes msg.sender as keeper, including when the caller is the position's owner. closeByTrigger excludes the owner (`msg.sender == p.owner ? address(0) : msg.sender`, the R3-1 fix), but closeAtMaxWin has no such check. A trader at max win therefore calls closeAtMaxWin on their own position instead of close() and gets 25% (up to 50% within ConfigCheck) of their own fees back. That breaks the brief's rule 8 ('The owner gets no keeper share for closing their own position'), and the treasury and pool fee shares shrink by the same amount. For a long it also skips the worse-of-live-and-fast price and the spread, so once the capped fast price reaches max win the owner gets the full max win even when close() would come in under it. Fix: pass `msg.sender == p.owner ? address(0) : msg.sender`, as closeByTrigger does.","line":135,"path":"perps/contracts/src/LevExchange.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/// The owner of a position at max win gets a keeper share of its own fees by calling closeAtMaxWin.\ncontract OwnerMaxWinRewardTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    LevLens lens;\n    MockOracle o;\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        o = new MockOracle(0, 400 ether);\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        exa.listMarket(o, 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: 5 ether}();\n    }\n\n    function test_ownerGetsNoKeeperShareAtMaxWin() public {\n        vm.prank(alice);\n        uint256 id = ex.open(0, true, 10, 0.1 ether, type(int24).max);\n        o.setAll(4700); // x1.6: past the 0.4 ETH max win\n        vm.warp(block.timestamp + 60);\n\n        uint256 snap = vm.snapshotState();\n        uint256 b0 = ex.balanceOf(alice);\n        vm.prank(alice);\n        ex.close(id, type(int24).min);\n        uint256 viaClose = ex.balanceOf(alice) - b0;\n\n        vm.revertToState(snap);\n        b0 = ex.balanceOf(alice);\n        vm.prank(alice);\n        ex.closeAtMaxWin(id);\n        uint256 viaMaxWin = ex.balanceOf(alice) - b0;\n\n        // Same exit (max win), same fees: closing your own position must not pay you the keeper share.\n        assertEq(viaMaxWin, viaClose, \"owner took a keeper share of its own max-win fees\");\n    }\n}","reproduction":"Neutral risk (no spread), LP 8 ETH. Alice opens a 10x long, stake 0.1 ETH, at tick 0 (max win 0.4 ETH). setAll(4700) (x1.6, past max win); wait 60 s. close(id) credits Alice 0.455 ETH (equity 0.5 - fees 0.045 = 0.005 close + 0.04 profit fee). In the same state, Alice calling closeAtMaxWin(id) credits her 0.46625 ETH: the same 0.455 plus a keeper reward of 0.045*25% = 0.01125 ETH. Expected: equal. Actual: 466250000000000000 != 455000000000000000.","severity":"low","snippet":"        _finish(id, p, m, c, pnl, EXIT_MAX_WIN, msg.sender);","title":"closeAtMaxWin pays the position's owner the keeper share of its own fees"}],"hash":"6098b4de0bf3fb7b316b38b07f349b9dcd6cebedbf9671adc09b72626f7b9a38","nodeId":"25505c7b-796c-4cc6-a04d-fb2d6eaed22c","outcome":"completed","summary":"I found two defects and wrote them to `.imd-findings.json` in the repository root. Each has a Foundry test that fails on the current code for the reason given; I ran both with `forge test --match-path 'test/scratch/*'`. I changed no contract files. The only extra files are those two tests, in `perps/contracts/test/scratch/`.\n\n**Findings**\n\n1. **Medium: a bankrupt long makes the pool look richer to withdrawing LPs** (`perps/contracts/src/libraries/LevLogic.sol:171`). Pool value applies the \"can't lose more than the stake\" limit to a whole side at once, not to each position. So a long that has lost more than its stake cancels out other longs' wins, and withdrawals pay out too much until it is liquidated.\n   - **Example:** a 2x long with a 0.5 ETH stake opens at tick 0. Then a 10x long with a 0.1 ETH stake opens at tick 6932. The price falls to tick 2000. The first long is up 0.221 ETH; the second is down 0.389 ETH but can only lose its 0.1 ETH stake.\n   - **Result:** `pool.nav()` reads 8.1727 ETH before the loser is liquidated and 7.8834 ETH after. The gap of 0.289 ETH (0.389 − 0.1) goes to whoever withdraws in between, at the remaining LPs' expense.\n   - **Why it persists:** liquidating a position with nothing left pays the liquidator nothing, so outside keepers have no reason to clear it.\n   - **Limits:** the loss has to show at the slow price as well as the live and fast ones, so in practice it needs liquidations to lag (a price gap plus a slow or absent keeper).\n   - **How it differs from your known limit:** it's the same side-wide clamp as \"capped winners\", but on the loss side, and it overpays withdrawers rather than letting depositors buy in cheap. Bounding winners per position would not fix it.\n\n2. **Low: an owner calling `closeAtMaxWin` on their own position gets the keeper's cut of its fees** (`perps/contracts/src/LevExchange.sol:135`). This breaks rule 8 in the brief. `closeByTrigger` already excludes the owner; `closeAtMaxWin` doesn't.\n   - **Example:** a 10x long with a 0.1 ETH stake at max win pays out 0.455 ETH through `close` but 0.46625 ETH through `closeAtMaxWin`.\n   - **Fix:** pass `msg.sender == p.owner ? address(0) : msg.sender`, as `closeByTrigger` does.\n\n**What I checked and found sound**\n- **Tick math:** the price-ratio calculation (`TickRatio`) can't overflow anywhere it can saturate.\n- **Pool-value bookkeeping:** per-position amounts are added on open and subtracted exactly on close, so the running totals don't drift.\n- **Fees:** holding-fee growth and its pro-rata free first hour; the fee credit in pool value, which errs in the pool's favour.\n- **Settlement:** every close moves exactly what the position held, and a winner never costs the pool more than its own max win, so the pool can always cover open max wins.\n- **LP shares:** rounding on deposit and withdrawal shares, and first-depositor inflation (blocked by the seed shares and the zero-share revert).\n- **Price feeds:** averaging and rounding in the V3 feed and its depth reading; the median and depth handling in the sampled V4 feed; tick composition and depth conversion in the bridged feed; the deploy scripts' window settings.\n- **One-click trading:** the router's daily-budget refill.\n\n**Not reported, because they need unrealistic values**\n- If a narrowing cast in `fillOrder` wraps, it only blocks the order owner's own fill.\n- The `uint96` escrow cast would need more ETH than exists.\n- The square-root price rounds to zero only beyond tick −828,972.\n\n**Coverage:** I did the math, boundary and numerical-gap passes myself, without helpers. Access control, trust, economics and control flow were outside this assignment and got only incidental attention; finding 2 came from that. I didn't run the fork tests.","treeHash":null,"usage":{"cachedInputTokens":1953979,"inputTokens":42,"model":"claude-opus-5-5","outputTokens":42314,"runtime":"claude","turns":25,"wallClockMs":606150}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a800aae85243bc87","findings":[{"citation":"resolved","description":"triggerExit clamps the live price to at most openBandSpotTicks (488 ticks, ~5%) past the fast price, so a keeper can't push a trigger exit far. When the live price really has moved further, the same clamp prices the exit far better than close(), which takes the true worse of live and fast. setTriggers (LevExchangeAdmin.sol:127-130) refuses only a level already reached, so the owner can set a long's stopLoss = fast - 1. Any small dip of the fast price then fires it. closeByTrigger has no owner restriction (the owner just gets no reward), so the owner calls it. The result is a close at about fast - 5% while the live price is much lower. This defeats the guarantee in AUDIT.md 3.8 ('a take-profit or stop-loss ... can't be used as a close at the lagging fast price') and invariant 2 (no extraction beyond the rules), and the pool pays the difference. Fast prices lag by design: a 60 s V3 TWAP, or the median of 3 V4 samples when a slide spans samples. Every sudden crash on a listed coin therefore hands stop-loss holders an exit near the pre-crash price, and LPs pay for it. Shorts are affected symmetrically through the `fast + band` clamp on line 122. Suggested minimal fix that keeps the anti-push intent: in closeByTrigger, when the feed is live, revert NotTriggered unless |spot - fast| <= openBandSpotTicks, and drop the clamp. A keeper then still can't push the exit, and when the live price really has left the band the owner uses close() at the true worse-of price. The other option is to price trigger exits exactly like close().","line":121,"path":"perps/contracts/src/libraries/LevLogic.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/// closeAtMaxWin pays the keeper share to the position's own owner, although closeByTrigger withholds it\n/// (\"The owner gets no keeper share for closing their own position\").\ncontract StopLossLagTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\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        LevLens 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_stopLossOneTickBelowFastBeatsClose() public {\n        // the default risk rules (5% live band, 50% pump band, spread)\n        vm.prank(owner);\n        exa.setRiskConfig(\n            RiskConfig({\n                openBandSpotTicks: 488,\n                openBandTrendTicks: 1823,\n                pumpBandTicks: 4055,\n                spreadBaseBps: 0,\n                spreadImpactBps: 0,\n                spreadMaxBps: 0,\n                sizeTier5xBps: 10,\n                sizeTier2xBps: 25,\n                hourlyGrowthBps: 5000,\n                platformSizeBps: 30_000\n            })\n        );\n        vm.prank(alice);\n        uint256 id = ex.open(0, true, 2, 0.4 ether, type(int24).max);\n        // a stop-loss one tick under the fast price: allowed, it isn't reached yet\n        vm.prank(alice);\n        exa.setTriggers(id, false, 0, true, -1);\n        vm.warp(1_750_000_000 + 60);\n        // the coin crashes 50% live; the fast average has only moved 10% so far\n        o0.set(-6932, -1000, 0);\n\n        uint256 snap = vm.snapshotState();\n        vm.prank(alice);\n        ex.close(id, type(int24).min);\n        uint256 viaClose = ex.balanceOf(alice);\n        vm.revertToState(snap);\n\n        vm.prank(alice);\n        ex.closeByTrigger(id);\n        uint256 viaTrigger = ex.balanceOf(alice);\n        // a trigger close is meant to be no better than a close at the same moment\n        assertLe(viaTrigger, viaClose, \"stop-loss exit beat a close at the live price\");\n    }\n}","reproduction":"Default risk bands (openBandSpotTicks 488, pumpBandTicks 4055) with zero spread; pool 8 ETH; market liquidity 400 ETH. Alice opens a 2x long with stake 0.4 ETH at tick 0 and calls setTriggers(id, false, 0, true, -1) (accepted, since -1 < fast 0). After 60 s the coin crashes 50% live while the fast average has moved only 10%: oracle (spot, fast, slow) = (-6932, -1000, 0). Liquidation isn't possible yet: equity is 0.8x the stake at the fast price. Expected: a trigger close is no better than close(), which exits at min(spot, fast) = -6932, pnl = 2 x -50% = -100%, payout 0 (Alice's balance stays 9.596 ETH). Actual: Alice calls closeByTrigger(id) and exits at max(-6932, -1000 - 488) = -1488 (~-13.8%), payout ~0.2865 ETH (balance 9.8825 ETH). The pool loses ~0.29 ETH, 72% of the stake. The attached test fails with 9882504302670567881 > 9596000000000000000.","severity":"medium","snippet":"        if (isLong && e < int256(fast) - band) e = int256(fast) - band;","title":"A stop-loss set 1 tick under the fast price lets the owner exit a crash at the lagging fast price minus 5%, far better than close()"},{"citation":"resolved","description":"AUDIT.md section 3.8 says the owner gets no keeper share for closing their own position, and closeByTrigger enforces this with `msg.sender == p.owner ? address(0) : msg.sender` (LevExchange.sol:166). closeAtMaxWin passes `msg.sender` as the keeper without that check. _outcome (LevExchange.sol:406-409) then pays `keeperRewardBps` (25%) of the closing fees to the owner whenever kind == EXIT_MAX_WIN and keeper != 0. A trader whose position has reached max win calls closeAtMaxWin instead of close and recovers a quarter of the fees, taken out of the treasury's and the pool's share. The close path is the same in every other respect: pnl is capped at max win, and closeAtMaxWin skips the spread and the worse-of-live rule because pnl is already at the cap. liquidate (line 121) has the same gap. An owner whose equity is below the close fee gets 0 from close (fees are capped at equity), but self-liquidating pays them 25% of the leftover equity. Fix: pass `msg.sender == p.owner ? address(0) : msg.sender` in closeAtMaxWin and liquidate, as closeByTrigger does. A second address still gets around this, as it does for closeByTrigger. That is an accepted limit of the rule, but the owner's own address should not be paid.","line":135,"path":"perps/contracts/src/LevExchange.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/// closeAtMaxWin pays the keeper share to the position's own owner, although closeByTrigger withholds it\n/// (\"The owner gets no keeper share for closing their own position\").\ncontract MaxWinOwnerRewardTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\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        LevLens 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_ownerGetsNoKeeperShareOnOwnMaxWinClose() public {\n        vm.prank(alice);\n        uint256 id = ex.open(0, true, 5, 0.4 ether, type(int24).max); // max win 400% = 1.6 ETH\n        vm.warp(1_750_000_000 + 60);\n        o0.setAll(7000); // +101%: 5x long is past its 1.6 ETH max win\n\n        uint256 before = ex.balanceOf(alice);\n        vm.prank(alice);\n        ex.closeAtMaxWin(id);\n        uint256 got = ex.balanceOf(alice) - before;\n\n        // equity 2 ETH; fees = max(1% * 2, 0.25% * 2) + 10% * 1.6 = 0.18 ETH; payout 1.82 ETH.\n        // Actual: 1.82 + 25% * 0.18 = 1.865 ETH, the keeper share paid to the owner.\n        assertEq(got, 1.82 ether, \"owner was paid the keeper share for closing their own position\");\n    }\n}","reproduction":"Default config, neutral risk config, pool 8 ETH, market liquidity 400 ETH. Alice opens a 5x long with stake 0.4 ETH at tick 0 (max win 400% = 1.6 ETH). After 60 s the price goes to tick 7000 (+101%). Alice calls closeAtMaxWin(id) herself. Expected, per invariant 8: equity 2.0 ETH, fees = max(1%*2, 0.25%*2) + 10%*1.6 = 0.18 ETH, payout 1.82 ETH. Actual: Alice's balance rises by 1.865 ETH, because 25% of the 0.18 ETH fees (0.045 ETH) is paid to her as the keeper reward. The attached test fails with 1865000000000000000 != 1820000000000000000.","severity":"low","snippet":"        _finish(id, p, m, c, pnl, EXIT_MAX_WIN, msg.sender);","title":"closeAtMaxWin pays the keeper share to the position's own owner (closeByTrigger withholds it)"}],"hash":"858a1020c5bd214f3f50964432bea3581228352228aa99af3d8015515a08ee09","nodeId":"40ce9160-b5f5-4dc3-9a1c-cfa80adc622a","outcome":"completed","summary":"I found two defects in the control-flow review, both confirmed with Foundry tests that fail on the current code. They're in `.imd-findings.json`, each with its test source as proof. No repository files were changed; the scratch tests are deleted.\n\n**1. Medium: a stop-loss set 1 tick under the fast price beats `close()` in a crash** (`perps/contracts/src/libraries/LevLogic.sol:121`, `closeByTrigger`)\n- **Cause:** the trigger exit only lets the live price count up to about 5% (488 ticks) past the fast price. `setTriggers` refuses only a level already reached, so a trader can set `stopLoss = fast - 1`, and the owner can call `closeByTrigger` themselves.\n- **Example:** a 2x long with a 0.4 ETH stake. The live price falls 50% while the fast price has moved 10%: (spot, fast, slow) = (-6932, -1000, 0).\n  - `close()` pays 0.\n  - `closeByTrigger` exits at tick -1488 and pays about 0.2865 ETH. The pool loses about 72% of the stake.\n- **Why it matters:** this is the \"close at the lagging fast price\" that invariant 8 is meant to prevent. Shorts are affected the same way.\n- **Fix:** while the feed is live, revert `closeByTrigger` when |spot − fast| exceeds the band, and drop the clamp. A keeper still can't push the exit, and the owner uses `close()` at the real worse-of price.\n\n**2. Low: `closeAtMaxWin` pays the keeper share to the position's owner** (`perps/contracts/src/LevExchange.sol:135`)\n- `closeByTrigger` gives nothing to an owner closing their own position. `closeAtMaxWin` passes `msg.sender` without that check.\n- **Example:** 5x long, 0.4 ETH stake, price +101%. The owner calls `closeAtMaxWin` and receives 1.865 ETH instead of 1.82 ETH, because 25% of the 0.18 ETH fees comes back to them.\n- `liquidate` has the same gap: an owner whose equity is below the close fee gets 0 from `close` but 25% of what's left by liquidating themselves.\n- **Fix:** use `msg.sender == p.owner ? address(0) : msg.sender` in both functions.\n\n**What I covered:** everything in scope in the brief, read with the Execution Trace, Periphery and First Principles guides. That is the exchange (both halves of the delegatecall split), the pool, the lens, the router, the three oracles, the shared libraries and the two deploy scripts.\n\n**Checked, with no defect found:**\n- Storage layout and `nonReentrant` across the delegatecall split.\n- That the money adds up on every open, close and settlement path.\n- Rug epochs and the count of unsettled rugged shorts.\n- The stale-feed clock, including whether limiting a call's gas can force the failed-read path (not feasible at these oracle costs).\n- Order escrow and fills.\n- The hourly growth limit and the size a closing position gives back.\n- The router's EIP-712 hashing, nonces, generations, budget refill and revocation while paused.\n- The bridged oracle's tick composition and depth conversion.\n- The deploy scripts' ownership handoff and V3 observation-slot sizing.\n\n**Leads I didn't report:**\n- Pool value can also be overstated by losing positions that are past liquidation but not yet liquidated. They offset winners on the same side, which inflates what withdrawing LPs are paid. This mirrors the known \"capped winners\" limit, so I didn't report it separately.\n- Overflow or precision loss in pool valuation needs price moves of more than about 10^9× from listing, which isn't realistic.\n\n**Not covered:** I didn't run the full 211-test suite or the fork tests, and no static-analysis tools were run.","treeHash":null,"usage":{"cachedInputTokens":1767401,"inputTokens":40,"model":"claude-opus-5-5","outputTokens":47972,"runtime":"claude","turns":23,"wallClockMs":539948}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"91d3f9816008ce9f","findings":[{"citation":"resolved","description":"Pair compared: close() vs closeByTrigger() (Asymmetry / Trust Gap, economics x asymmetry). close() prices a long's exit at min(spot, fast) with no floor (LevExchange.sol:330). closeByTrigger() prices it with LevLogic.triggerExit, which floors the live price at fast - openBandSpotTicks (LevLogic.sol:121; mirror for shorts at :122). The clamp is meant to stop a third-party caller pushing the exit, but it also caps the trader's exposure to genuine moves: when the coin really crashes, the live price is far below the fast price (a 60 s TWAP or a median of 3 samples taken 30 s apart), and the fast price only has to move a few ticks to hit a stop-loss placed 1 tick under it (setTriggers accepts any level the fast price hasn't reached). The owner (keeper = address(0)) or anyone then closes at fast - 4.9% instead of the live price. The difference comes out of the pool (_finish books held - payout against LevPool). This breaks invariant 2 (fills/exits at the worse of live and fast price) and invariant 8 (a trigger can't be used as a close at the lagging fast price). Every long with a stop-loss set 1 tick under the fast price holds a put at fast - 5%, and every short a call at fast + 5%, during the fast-price lag. Fix: price the trigger exit exactly like close() (no floor/cap on the live price), and stop third-party push by refusing the trigger (revert NotTriggered) while |spot - fast| > openBandSpotTicks, instead of clamping the price.","line":121,"path":"perps/contracts/src/libraries/LevLogic.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 {ILevPool, ILevExchange} from \"src/interfaces/ILevPool.sol\";\nimport {MockOracle} from \"src/mocks/MockOracle.sol\";\n\n/// A stop-loss exit must be priced like a close (worse of live and fast price). In a real crash the live price is\n/// far under the lagging fast price; closeByTrigger clamps the live price to fast - openBandSpotTicks, so the owner\n/// exits ~45% of the move better than close() and the pool pays the difference.\ncontract TriggerClampTest is Test {\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address lp = makeAddr(\"lp\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    MockOracle o;\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        o = new MockOracle(0, 400 ether);\n        vm.startPrank(owner);\n        pool.setExchange(ILevExchange(address(ex)));\n        exa.listMarket(o, 10, 5 ether); // default risk config: openBandSpotTicks = 488 (~5%)\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.deal(bob, 10 ether);\n        vm.prank(alice);\n        ex.deposit{value: 1 ether}();\n        vm.prank(bob);\n        ex.deposit{value: 1 ether}();\n    }\n\n    function test_stopLossBeatsCloseInACrash() public {\n        // two identical 5x longs of 0.1 ETH, both with a stop-loss just under the fast price\n        vm.prank(alice);\n        uint256 a = ex.open(0, true, 5, 0.1 ether, type(int24).max);\n        vm.prank(bob);\n        uint256 b = ex.open(0, true, 5, 0.1 ether, type(int24).max);\n        assertEq(exa.positionData(a).entryTick, exa.positionData(b).entryTick);\n        vm.warp(block.timestamp + 20);\n        vm.prank(alice);\n        exa.setTriggers(a, false, 0, true, -5);\n        vm.prank(bob);\n        exa.setTriggers(b, false, 0, true, -5);\n\n        // the coin crashes 50%: live price at -6932, the fast average has only begun to move\n        o.set(-6932, -10, 0);\n\n        uint256 bobBefore = ex.balanceOf(bob);\n        vm.prank(bob);\n        ex.close(b, type(int24).min);\n        uint256 closePayout = ex.balanceOf(bob) - bobBefore;\n\n        uint256 aliceBefore = ex.balanceOf(alice);\n        vm.prank(alice);\n        try ex.closeByTrigger(a) {} catch {}\n        uint256 triggerPayout = ex.balanceOf(alice) - aliceBefore;\n\n        // expected: the trigger exit is never better than a close at the same moment\n        assertLe(triggerPayout, closePayout, \"stop-loss paid more than close at the same prices\");\n    }\n}","reproduction":"Default RiskConfig (openBandSpotTicks 488). Oracle at tick 0, 400 ETH liquidity. Alice and Bob each open an identical 5x long, stake 0.1 ETH (entry tick 35 after spread), wait 20 s, and setTriggers(id,false,0,true,-5). The coin crashes: oracle set(spot=-6932 (-50%), fast=-10, slow=0). Bob calls close(b, MIN_TICK): exit = min(-6932,-10) - spread -> equity < 0 -> payout 0. Alice calls closeByTrigger(a): fast -10 <= -5 hits; exit = max(-6932, -10-488) - 35 = -533 -> pnl ~ -0.028 ETH -> payout 0.071142845041110919 ETH. Expected: trigger payout <= close payout (0). Actual: 0.0711 ETH, paid by the pool. The attached test fails on the current code with '71142845041110919 > 0'.","severity":"high","snippet":"        if (isLong && e < int256(fast) - band) e = int256(fast) - band;","title":"closeByTrigger clamps the live price to fast - openBandSpotTicks, so a stop-loss is a free put at the lagging fast price during a real crash (beats close(), pool pays)"},{"citation":"resolved","description":"Asymmetry between the two keeper-paid exits. closeByTrigger passes `msg.sender == p.owner ? address(0) : msg.sender` as the keeper (LevExchange.sol:166), implementing the brief's rule 'The owner gets no keeper share for closing their own position'. closeAtMaxWin passes msg.sender unconditionally, and _outcome then pays keeperRewardBps (25%) of the close fees to the caller for EXIT_MAX_WIN. So a trader whose position is at max win calls closeAtMaxWin on it themselves and gets 25% of their own close fees back, while close() charges the full fee; closeAtMaxWin also prices at the fast price with no spread or worse-of-live. The treasury and pool get 25% less fee than from the same close done through close(). (Separately, the owner check in closeByTrigger is only an address check: the owner calling from a second address gets the share there too, so the rule can only be enforced economically, e.g. by paying the reward only when the position actually needed clearing.) Fix: in closeAtMaxWin pass `msg.sender == p.owner ? address(0) : msg.sender`, as closeByTrigger does.","line":135,"path":"perps/contracts/src/LevExchange.sol","reproduction":"Default config, oracle at tick 0 with 400 ETH liquidity, pool cash 8 ETH. Alice and Bob each open a 5x long with 0.1 ETH stake (max win 400%). Wait 20 s; oracle setAll(6000) (+82%, both positions capped at +0.4 ETH). Bob calls close(b, MIN_TICK): payout 0.455 ETH (fees 0.045). Alice calls closeAtMaxWin(a) on her own position: payout 0.46625 ETH. Expected: no keeper share for the owner, payout <= 0.455 ETH. Actual: +0.01125 ETH (25% of the 0.045 ETH fee) credited to the owner as 'keeper' reward; the treasury and pool receive 0.03375 ETH instead of 0.045 ETH. Verified with a Foundry test (assertLe fails: 466250000000000000 > 455000000000000000).","severity":"low","snippet":"        _finish(id, p, m, c, pnl, EXIT_MAX_WIN, msg.sender);","title":"closeAtMaxWin pays the keeper share to the position's own owner (closeByTrigger excludes the owner, closeAtMaxWin does not)"}],"hash":"f309da255d2b840d6dddc2a22fe24c23bf9fe5902ff5f866f2406ceffe990cfd","nodeId":"83ede724-47b4-4297-88a1-afe5e1175b6c","outcome":"completed","summary":"I reviewed the permissions area (Access Control, Trust Gap and Asymmetry) and found two problems, both confirmed with Foundry tests that fail on the current code. They're in `.imd-findings.json`; no repository files were changed, and I deleted the scratch tests afterwards.\n\n### Findings\n\n**1. High: a stop-loss lets a trader dodge a real crash at the pool's expense** (`perps/contracts/src/libraries/LevLogic.sol:121`)\n- **What happens:** when a take-profit or stop-loss fires, `closeByTrigger` uses the live price but never lets it count more than about 5% (`openBandSpotTicks`) beyond the fast average. A normal `close()` has no such limit and uses the worse of the live and fast price.\n- **Why it matters:** the limit is meant to stop someone else pushing the price to worsen a trader's exit. In a real crash, though, it shields the trader: the fast price lags, so it only has to slip a few ticks to hit a stop-loss set just under it. The trader then exits at the fast price minus about 5%, not at the crashed live price. The pool pays the difference. This breaks rules 2 and 8 in the brief.\n- **Reproduction:** two identical 5x longs of 0.1 ETH, each with a stop-loss 5 ticks below the price. The live price drops 50% while the fast price has only moved 10 ticks. `close()` pays **0**; `closeByTrigger` pays **0.0711 ETH**. The test is attached to the finding.\n- **Suggested fix:** price trigger exits exactly like `close()`. To keep the anti-push protection, refuse the trigger while the live and fast prices are more than the band apart, instead of clamping the price.\n\n**2. Low: a trader can claim the keeper reward on their own max-win close** (`perps/contracts/src/LevExchange.sol:135`)\n- **What happens:** `closeByTrigger` refuses to pay the owner the keeper reward, as the brief requires (rule 8). `closeAtMaxWin` has no such check and pays the reward to whoever calls it.\n- **Reproduction:** with the same two 5x longs at max win, `close()` pays 0.455 ETH, while the owner calling `closeAtMaxWin` on their own position gets 0.46625 ETH. That is 25% of the 0.045 ETH fee taken from the treasury and pool.\n- **Suggested fix:** pass `msg.sender == p.owner ? address(0) : msg.sender` here, as `closeByTrigger` does. Note that `closeByTrigger`'s own check only compares addresses, so the owner can still collect the reward by calling from a second wallet.\n\n### Checked and found sound\n- **Admin powers:** every owner, guardian, router and oracle-keeper function has the guard it should, and the one-time setters can't be reset. The guardian can pause but can't unpause.\n- **Ownership at deploy:** `Deploy.s.sol` and `DeployBridgedOracle.s.sol` hand every role over through two-step ownership.\n- **Code split:** the exchange and its admin code share one storage layout, the reentrancy lock covers both halves, and the payable forwarding works correctly.\n- **One-click router:** signatures, nonces, generations and the three kinds of revocation behave as described. Withdrawals always go to the owner's own address, and the daily budget refills as intended.\n- **Pool:** deposits are priced at the high pool value and withdrawals at the low one, and only the exchange can move pool cash.\n- **Closes can't be frozen:** I found no way for an admin to block them. The checked config limits cap the delays, and the pool always keeps enough cash for open max wins.\n- **Not reported:**\n  - A third party can still push a triggered exit by up to the 5% band. The code's own comment says this is intended.\n  - Order fills are charged the open fee at fill time, and pool settings apply to pending withdrawals. The brief already says config changes apply at once.\n\n### What this review didn't cover\n- I ran the three permissions passes myself, with no helpers. The math, economics and control-flow passes weren't done beyond what these findings needed.\n- I didn't run the fork tests or the full 211-test suite; I only ran the two scratch proof tests.\n- Off-chain risks, such as someone front-running a kee","treeHash":null,"usage":{"cachedInputTokens":1972312,"inputTokens":48,"model":"claude-opus-5-5","outputTokens":40437,"runtime":"claude","turns":29,"wallClockMs":461497}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a93cc1aa32976b68","findings":[{"citation":"resolved","description":"Merged from audit_economics (6a37599d), audit_permissions (628d9759) and audit_flow (7d873693): all three describe the same mechanism and fix. close() prices a long's exit at min(spot, fast) with no floor (LevExchange._closePosition). closeByTrigger prices it with LevLogic.triggerExit, which floors the live price at fast - openBandSpotTicks (488 ticks, ~5% by default). Line 122 does the same for shorts with a cap at fast + band. The clamp exists so a third-party keeper can't push the exit price. But setTriggers only refuses a level the fast price has already reached, so the owner can set stopLoss = fast - 1. closeByTrigger has no owner restriction (the owner just gets no reward). So when the live price crashes far below the lagging fast price (a 60 s V3 TWAP, or a median of 3 V4 samples), the first one-tick dip in fast fires the trigger. The owner then exits at about fast - 5% when close() would pay at the live price, which is often a full loss. The pool pays the difference (_finish books held - payout against the pool). This breaks invariant 2 (the worse-of-live-and-fast fill) and invariant 8 (a trigger can't be used as a close at the lagging fast price). Any ordinary stop-loss user on a lagging feed gets the same guaranteed-stop subsidy. Fix (keeps the anti-push intent): in closeByTrigger, while the feed is live, revert NotTriggered if |spot - fast| > openBandSpotTicks, and price the exit like _closePosition (worse of spot and fast, pump band, spread) with no clamp. Or keep the clamp but never pay more than the close price: long exit = min(triggerExit, closeExit), mirrored for shorts. Add a regression test under the default RiskConfig; test_R3_1_triggerExitCountsTheLivePrice uses the neutral 30k-tick band and never reaches the clamp.","line":121,"path":"perps/contracts/src/libraries/LevLogic.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 long's owner arms a stop-loss one tick under the fast price while the live price has already crashed far\n/// below it. One tick of fast movement later, closeByTrigger prices the exit at fast - openBandSpotTicks, far\n/// better than the \"worse of live and fast\" a close gives. Fails while the trigger payout exceeds the close payout.\ncontract TriggerBeatsCloseTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    LevLens lens;\n    MockOracle o0;\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        // the production risk rules: a 5% live-vs-fast band\n        exa.setRiskConfig(\n            RiskConfig({\n                openBandSpotTicks: 488,\n                openBandTrendTicks: 1823,\n                pumpBandTicks: 4055,\n                spreadBaseBps: 10,\n                spreadImpactBps: 20_000,\n                spreadMaxBps: 300,\n                sizeTier5xBps: 10,\n                sizeTier2xBps: 25,\n                hourlyGrowthBps: 5000,\n                platformSizeBps: 30_000\n            })\n        );\n        vm.stopPrank();\n        o0 = new MockOracle(0, 4000 ether);\n        vm.prank(owner);\n        exa.listMarket(o0, 10, 100 ether);\n        vm.deal(lp, 100 ether);\n        vm.prank(lp);\n        pool.deposit{value: 50 ether}();\n        vm.deal(alice, 10 ether);\n        vm.prank(alice);\n        ex.deposit{value: 5 ether}();\n    }\n\n    function test_stopLossArmedOneTickAwayBeatsClose() public {\n        vm.prank(alice);\n        uint256 id = ex.open(0, true, 5, 1 ether, type(int24).max);\n        vm.warp(block.timestamp + 60);\n\n        // The coin crashes 30% live; the fast average has not moved yet.\n        o0.set(-3567, 0, 0);\n        // The stop is below the fast price, so setTriggers accepts it.\n        vm.prank(alice);\n        exa.setTriggers(id, false, 0, true, -1);\n        // One tick of fast movement arms it. The live price is still -30%.\n        o0.set(-3567, -1, 0);\n\n        uint256 snap = vm.snapshotState();\n        uint256 b0 = ex.balanceOf(alice);\n        uint256 poolCash0 = pool.cash();\n        vm.prank(alice);\n        ex.close(id, type(int24).min);\n        uint256 viaClose = ex.balanceOf(alice) - b0;\n        uint256 poolAfterClose = pool.cash();\n\n        vm.revertToState(snap);\n        vm.prank(alice);\n        ex.closeByTrigger(id);\n        uint256 viaTrigger = ex.balanceOf(alice) - b0;\n        uint256 poolAfterTrigger = pool.cash();\n\n        emit log_named_uint(\"payout via close\", viaClose);\n        emit log_named_uint(\"payout via trigger\", viaTrigger);\n        emit log_named_uint(\"pool cash after close\", poolAfterClose);\n        emit log_named_uint(\"pool cash after trigger\", poolAfterTrigger);\n        assertLe(viaTrigger, viaClose, \"a trigger close must not pay more than a close at the worse of live and fast\");\n        assertGe(poolAfterTrigger, poolAfterClose, \"the pool must not lose more through the trigger path\");\n        poolCash0;\n    }\n}","reproduction":"Ran the attached proof (from specialist 6a37599d). Default RiskConfig (openBandSpotTicks 488), pool 50 ETH, liquidity 4000 ETH. Alice opens a 5x long with 1 ETH stake at tick 0. 60 s later the oracle reports spot -3567 (-30%), fast 0. Alice calls setTriggers(id,false,0,true,-1), which is accepted. Then fast = -1. close(id, MIN_TICK) pays 0 (a liquidation). closeByTrigger(id) called by Alice exits at max(-3567, -1-488) = -489 minus spread and pays 0.7157 ETH from the pool. Result: FAIL 'a trigger close must not pay more than a close at the worse of live and fast: 715681687027590845 > 0'. The other two specialist proofs also fail for the same reason: 0.0711 ETH vs 0 at a 50% crash, and 9.8825 vs 9.596 ETH balance with spread disabled.","severity":"high","snippet":"        if (isLong && e < int256(fast) - band) e = int256(fast) - band;","title":"closeByTrigger clamps the live price to within openBandSpotTicks of the fast price, so an owner-armed stop-loss exits a crash at about fast-5% and the pool pays the rest of the gap"},{"citation":"resolved","description":"From audit_math (4c60629c). sideLiability floors the whole side's P&L at -(sum of stakes), not each position's P&L at -(its own stake). A position whose loss is past its stake (liquidation lagging a gap move) nets its unpayable extra loss against other same-side winners. LevLens.liabilityRange then understates owedHigh, so LevPool.nav() (cash - owedHigh), the value withdrawals pay out at, is overstated. This breaks invariant 4: withdrawals should pay the lowest plausible value. It is the loss-floor counterpart of the known 'capped winners' limit, but it moves value the other way (it overpays withdrawers). Bounding winners per position would not fix it. Nothing pushes third parties to clear these positions: liquidating a position with equity <= 0 pays a keeper reward of 0. Fix: floor each position individually, or treat the market as blocked (valuationOk false) while any position is past its stake at the valuation ticks.","line":171,"path":"perps/contracts/src/libraries/LevLogic.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test, console} 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\ncontract SideFloorTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    MockOracle o0;\n    address owner = makeAddr(\"owner\");\n    address lp = makeAddr(\"lp\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\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        LevLens lens = new LevLens(ex);\n        vm.startPrank(owner);\n        pool.setExchange(ILevExchange(address(ex)));\n        pool.setNavSource(ILevNav(address(lens)));\n        exa.setRiskConfig(RiskConfig({openBandSpotTicks: 30_000, openBandTrendTicks: 30_000, pumpBandTicks: 30_000,\n            spreadBaseBps: 0, spreadImpactBps: 0, spreadMaxBps: 0, sizeTier5xBps: 500, sizeTier2xBps: 500,\n            hourlyGrowthBps: 60_000, platformSizeBps: 100_000}));\n        o0 = new MockOracle(0, 4000 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        vm.deal(bob, 10 ether);\n        vm.prank(bob);\n        ex.deposit{value: 10 ether}();\n    }\n\n    function test_sideFloor() public {\n        vm.prank(alice);\n        ex.open(0, true, 2, 0.5 ether, type(int24).max);\n        o0.setAll(6932);\n        vm.warp(block.timestamp + 60);\n        vm.prank(bob);\n        uint256 b = ex.open(0, true, 10, 0.1 ether, type(int24).max);\n        o0.setAll(2000);\n        vm.warp(block.timestamp + 60);\n        uint256 before = pool.nav();\n        ex.liquidate(b);\n        uint256 afterL = pool.nav();\n        console.log(before, afterL);\n        assertLe(before, afterL, \"nav overstated while bankrupt long is open\");\n    }\n}","reproduction":"Neutral risk, LP 8 ETH, MockOracle with 4000 ETH liquidity. Alice opens a 2x long with 0.5 ETH stake at tick 0. setAll(6932); Bob opens a 10x long with 0.1 ETH stake. setAll(2000): Bob's pnl is -0.389 ETH against a 0.1 ETH stake. pool.nav() is 8172675273682695772 before liquidate(bob) and 7883359454992142861 after. Expected: liquidating a bankrupt loser doesn't lower the withdrawal value, because his loss is capped at his stake either way. Actual: NAV was overstated by 0.289 ETH (= 0.389 - 0.1). Proof fails with 'nav overstated while bankrupt long is open: 8172675273682695772 > 7883359454992142861'.","severity":"medium","snippet":"        int256 floor = -int256(s.stake + (withMargin ? s.margin : 0));","title":"Side-level loss floor lets a bankrupt long's excess loss cancel other longs' wins, so the NAV withdrawals pay at is too high until it is liquidated"},{"citation":"resolved","description":"From audit_economics (c73d0c8c). The brief says valuation pauses while a feed is down, so the pause itself is intended. The defect is that the pause has no end. liabilityRange sets blocked whenever a market with open size has a reverting feed, even after the exchange itself treats the market as settleable at lastGoodFast (_requireStale). The lens already values it at that price. A position that is not liquidatable, not at max win and has no trigger can only be closed by its owner: setMarket can't force-close, the oracle is immutable per market, and no admin function moves positions. So while any such position stays open, deposit, requestWithdraw and withdraw all revert with ValuationPaused (LevPool.valuationOk), freezing all LP cash. Matured withdraw requests also expire after withdrawWindow and restart the cooldown. Fix: once the market meets the exchange's own stale condition, value it at lastGoodFast/lastGoodTick without setting blocked. Keep blocking for the first staleSeconds and for rugs. Optionally add a permissionless settle-at-stale-price for positions on long-dead feeds.","line":112,"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 {LevExchangeBase} from \"src/LevExchangeBase.sol\";\nimport {LevPool} from \"src/LevPool.sol\";\nimport {LevLens} from \"src/LevLens.sol\";\nimport {ILevPool, ILevExchange, ILevNav} from \"src/interfaces/ILevPool.sol\";\nimport {MockOracle} from \"src/mocks/MockOracle.sol\";\n\n/// One position on a coin whose feed has died keeps LevLens.liabilityRange `blocked`, so every LP deposit and\n/// withdrawal reverts, for as long as that position stays open. The exchange itself settles such positions at the\n/// last good fast price once the feed is stale, but the valuation never does. The position is not liquidatable\n/// (margin), not at max win, has no trigger, and no admin function can close it.\ncontract StaleMarketFreezesLPsTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    LevLens lens;\n    MockOracle o0;\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        vm.stopPrank();\n        o0 = new MockOracle(0, 400 ether);\n        vm.prank(owner);\n        exa.listMarket(o0, 10, 5 ether);\n        vm.deal(lp, 100 ether);\n        vm.prank(lp);\n        pool.deposit{value: 20 ether}();\n        vm.deal(alice, 10 ether);\n        vm.prank(alice);\n        ex.deposit{value: 5 ether}();\n    }\n\n    function test_oneStalePositionBlocksLPWithdrawalsIndefinitely() public {\n        vm.prank(alice);\n        uint256 id = ex.open(0, true, 1, 0.01 ether, type(int24).max);\n        vm.prank(alice);\n        exa.addMargin(id, 1 ether);\n\n        // The feed dies for good. Keepers keep pinging, so the stale clock runs and the exchange settles at the\n        // last good fast price.\n        o0.setFailing(true);\n        exa.refresh(0);\n        for (uint256 i; i < 864; ++i) {\n            // a day of failed reads, pinged every 100 s (inside STALE_PING_GAP)\n            vm.warp(vm.getBlockTimestamp() + 100);\n            exa.refresh(0);\n        }\n\n        // Nobody but the owner can close it.\n        vm.expectRevert(LevExchangeBase.NotLiquidatable.selector);\n        ex.liquidate(id);\n        vm.expectRevert(LevExchangeBase.NotAtMaxWin.selector);\n        ex.closeAtMaxWin(id);\n        vm.expectRevert(LevExchangeBase.NotTriggered.selector);\n        ex.closeByTrigger(id);\n        // Pausing the coin changes nothing for valuation.\n        vm.prank(owner);\n        exa.setMarket(0, false, 10, 5 ether);\n\n        // The exchange considers the market settleable at the last good price...\n        assertTrue(lens.getPosition(id).action == lens.ACTION_NONE());\n        // ...but every LP is locked out: 20 ETH of LP cash against a 0.01 ETH position on a dead coin.\n        (,, bool blocked) = lens.liabilityRange();\n        assertFalse(blocked, \"a stale market should be valued at its last good price, not block the pool\");\n        vm.prank(lp);\n        pool.requestWithdraw(pool.balanceOf(lp));\n    }\n}","reproduction":"Ran the attached proof (from specialist c73d0c8c). LP cash 20 ETH. Alice opens a 1x long with 0.01 ETH stake, then addMargin(id, 1 ether). The oracle starts reverting, and refresh(0) is called every 100 s for a day, so the stale path is live. liquidate reverts NotLiquidatable, closeAtMaxWin reverts NotAtMaxWin, closeByTrigger reverts NotTriggered, and setMarket changes nothing. lens.liabilityRange() returns blocked == true and pool.requestWithdraw reverts ValuationPaused. Result: FAIL 'a stale market should be valued at its last good price, not block the pool'.","severity":"medium","snippet":"            if (!ok || m.ruggedPending != 0 || LevLogic.isRug(c, spot, slow) || LevLogic.isRug(c, fast, slow)) {","title":"One open position on a market whose feed has died blocks every LP deposit and withdrawal indefinitely; no permissionless or admin path can clear it"},{"citation":"resolved","description":"Merged from audit_permissions (8c014e23), audit_flow (f186a539) and audit_math (e789cc1d). Rule 8 says the owner gets no keeper share for closing their own position. closeByTrigger enforces this with `msg.sender == p.owner ? address(0) : msg.sender`, but closeAtMaxWin passes msg.sender unconditionally. _outcome then pays keeperRewardBps (25%) of the fees to the owner, cutting the treasury and pool fee shares by the same amount. liquidate (LevExchange.sol:121) has the same pattern: a self-liquidation with leftover equity pays the owner 25% of it. That case was not reproduced separately. Related, and also reported by audit_economics (b7a6a9ec): the owner check on any path is only an address check, so the owner calling from a second address still collects the share. That is inherent to the rule and is best documented as a limit rather than claimed as a guarantee. Fix: pass `msg.sender == p.owner ? address(0) : msg.sender` in closeAtMaxWin and liquidate.","line":135,"path":"perps/contracts/src/LevExchange.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test, console} 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\ncontract MaxWinSelfKeeperTest is Test {\n    LevPool pool;\n    LevExchange ex;\n    LevExchangeAdmin exa;\n    MockOracle o0;\n    address owner = makeAddr(\"owner\");\n    address lp = makeAddr(\"lp\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\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        LevLens lens = new LevLens(ex);\n        vm.startPrank(owner);\n        pool.setExchange(ILevExchange(address(ex)));\n        pool.setNavSource(ILevNav(address(lens)));\n        exa.setRiskConfig(RiskConfig({openBandSpotTicks: 30_000, openBandTrendTicks: 30_000, pumpBandTicks: 30_000,\n            spreadBaseBps: 0, spreadImpactBps: 0, spreadMaxBps: 0, sizeTier5xBps: 500, sizeTier2xBps: 500,\n            hourlyGrowthBps: 60_000, platformSizeBps: 100_000}));\n        o0 = new MockOracle(0, 4000 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        vm.deal(bob, 10 ether);\n        vm.prank(bob);\n        ex.deposit{value: 10 ether}();\n    }\n\n    function test_maxWinSelfKeeper() public {\n        vm.prank(alice);\n        uint256 id = ex.open(0, true, 10, 0.1 ether, type(int24).max);\n        o0.setAll(4700);\n        vm.warp(block.timestamp + 60);\n        uint256 b0 = ex.balanceOf(alice);\n        uint256 snap = vm.snapshotState();\n        vm.prank(alice);\n        ex.close(id, type(int24).min);\n        uint256 viaClose = ex.balanceOf(alice) - b0;\n        vm.revertToState(snap);\n        vm.prank(alice);\n        ex.closeAtMaxWin(id);\n        uint256 viaMax = ex.balanceOf(alice) - b0;\n        console.log(viaClose, viaMax);\n        assertEq(viaMax, viaClose, \"owner got a keeper share via closeAtMaxWin\");\n    }\n}","reproduction":"Neutral risk, LP 8 ETH. Alice opens a 10x long with 0.1 ETH stake at tick 0 (max win 0.4 ETH). setAll(4700), wait 60 s. close(id) credits Alice 0.455 ETH. In the same state, Alice calling closeAtMaxWin(id) credits 0.46625 ETH: the extra is 25% of the 0.045 ETH fee, paid as a keeper reward. Proof fails with 'owner got a keeper share via closeAtMaxWin: 466250000000000000 != 455000000000000000'.","severity":"low","snippet":"        _finish(id, p, m, c, pnl, EXIT_MAX_WIN, msg.sender);","title":"closeAtMaxWin pays the keeper share to the position's own owner (closeByTrigger withholds it)"}],"hash":"f65d4fac26381e9b4c77422d35664da92e26cbcb9a29f5cfdac1bb8069a9bcba","nodeId":"5c2467e2-4831-4440-842d-d588916feb43","outcome":"completed","summary":"I reproduced every specialist finding that I kept, merged the nine reports into four, and wrote the result to `.imd-findings.json`. Each kept finding has a Foundry proof under `perps/contracts/test/scratch/`, and each proof fails on the current code for the reason its finding gives. I changed no project files.\n\n| Severity | Finding | Specialist reports merged | What the proof shows |\n|---|---|---|---|\n| high | **Stop-loss beats `close()` in a crash.** `LevLogic.sol:121`: a take-profit/stop-loss close limits how far the live price can drag the exit to about 5% below the lagging fast price. The owner can set a stop one tick under the fast price, which is allowed because it isn't reached yet. In a crash they then exit near the fast price, while `close()` would pay the live price. The pool pays the difference. | 3 (economics, permissions, flow) | All three proofs fail. With the default 5% band, a 30% crash pays 0.7157 ETH through the stop-loss against 0 through `close()`. |\n| medium | **Withdrawal value too high while a bankrupt position is open.** `LevLogic.sol:171`: losses are floored at the total stake of a whole side, not per position. A loss past one position's stake cancels out other positions' wins, so the pool value withdrawals pay at is overstated until that position is liquidated. | math | Pool value is 8.1727 ETH before liquidating the bankrupt long and 7.8834 ETH after: 0.289 ETH too high. |\n| medium | **One position on a dead price feed freezes all LP deposits and withdrawals, with no way out.** `LevLens.sol:112`: the brief does say valuation pauses while a feed is down. The defect is that the pause never ends: nobody but the owner can close a healthy position, and the admin has no remedy. | economics | After a day of feed outage, `requestWithdraw` still reverts with `ValuationPaused`. |\n| low | **`closeAtMaxWin` pays the keeper share to the position's own owner.** `LevExchange.sol:135`: this breaks rule 8, which `closeByTrigger` enforces. | 3 (permissions, flow, math) | The owner gets 0.46625 ETH instead of `close()`'s 0.455 ETH. |\n\nTwo related points sit inside the low finding's description rather than as findings of their own:\n- **Self-liquidation (`liquidate`, line 121):** it has the same missing owner check. I did not test this one.\n- **Second-address trick:** the economics specialist's low finding showed that an owner calling `closeByTrigger` from a second address still collects the keeper share. An address check can't prevent this, so I recommend documenting it as a known limit rather than a guarantee.\n\nI dropped no finding. The two claims I didn't test separately are the self-liquidation case and the second-address case; the second follows directly from the code.\n\nI re-checked the specialists' findings rather than running a fresh pass through all twelve review areas. Areas no specialist reported on, such as the router's session keys and the bridged price feed, were not reviewed independently.","treeHash":null,"usage":{"cachedInputTokens":495702,"inputTokens":23,"model":"claude-opus-5-5","outputTokens":11085,"runtime":"claude","turns":12,"wallClockMs":274300}}],"verification":[]}