{"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":"b2cad286-240a-4884-b169-187e7d838051","kind":"audit","nodes":[{"acceptedSubmissionHash":"3b82cd59cd6a947e3b5570cb7eda2e3707349d145cd488fc7e037e2d0390015d","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"d719f416dc9d2abdb5f93b381fb271572b070307761340acc4c8d871908fa2fe","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"329d054a15a7f7307beb099f7c9201576d96b74b44932dfe20974fb79a7764c9","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"167a1cbe11b12c4415d72eb4b8cdc8abf5b756e68ca370642977a4c878ca2cf0","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"facc1bf2501c9b9922ff52d613d0ec17f5b1a96cf04aeb31eb9a465b739def8e","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Final re-check of src/IMDBuyAndBurn.sol at the commit that is now deployed on Base mainnet at 0xcf943c7787e1f4503594e04fbd3b969157f34120 (Sourcify exact match). Earlier IMD audits: de9e10c6 on 4e49d40 and dca64686 on 2042849; their findings are fixed or documented here (see README.md, Audits). Verify the fixes for dca64686: (1) poke() now opens a separate pending window (pendUsdcWeth, pendWethImd, pendTime) only when the burn window is older than STALE_AFTER, and a pending window can only be replaced once it is older than STALE_AFTER itself; buyAndBurn() tries the burn window first and falls back to the pending window; a burn resets the burn window and clears the pending one (proof test/PokeGrief.t.sol); (2) a capped burn leaves exactly THRESHOLD behind when the remainder would be smaller (lines in _quote); (3) DEPTH_BPS = 25 so a sandwich inside MAX_DEVIATION_BPS does not pay (proof test/SandwichWithinTolerance.t.sol). Look for anything these changes introduced: a burn using a stale or wrong window, the pending window being griefed or used to bypass the price check, an underflow or a zero-size burn in the remainder rule, preview() reverting, and any way USDC or IMD leaves other than as intended. Tests run on a Base fork (BASE_RPC) and with mock pairs. Report each finding with severity, location and a reproduction.","parentJobId":null,"planHash":"a01dc62cf051509b72e97c8b096b5223bc0abe86934f8cc03ee32ad45e916f82","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"b2cad286-240a-4884-b169-187e7d838051","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":"52182","feedbackHash":"43689e57a0c7bae363c5520aa659a3d7d182815475c0ac973f65fc926d36e0c3","nodeKey":"audit_economics","submissionHash":"3b82cd59cd6a947e3b5570cb7eda2e3707349d145cd488fc7e037e2d0390015d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53924","feedbackHash":"20cd475c1276f4e21f1b73d8a360f0a65aef6123a4e873d3a31d2efe2e3481fd","nodeKey":"audit_flow","submissionHash":"d719f416dc9d2abdb5f93b381fb271572b070307761340acc4c8d871908fa2fe","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52188","feedbackHash":"d0fc9deab0590277807871002cabef8ed97ab4602cc6b1662de1626796dd6aad","nodeKey":"audit_judge","submissionHash":"329d054a15a7f7307beb099f7c9201576d96b74b44932dfe20974fb79a7764c9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53925","feedbackHash":"dc6c484820b176aaaac80a66d017129b6f1b4637ca295bb52731c9144dd32e09","nodeKey":"audit_math","submissionHash":"167a1cbe11b12c4415d72eb4b8cdc8abf5b756e68ca370642977a4c878ca2cf0","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51342","feedbackHash":"15e72ac7e337645d8cfc8df0539dba6789782ad610e7cde84a8be6a7ebd6ac6f","nodeKey":"audit_permissions","submissionHash":"facc1bf2501c9b9922ff52d613d0ec17f5b1a96cf04aeb31eb9a465b739def8e","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"d68b6e22a3621b19f74520f5ebb9c8fa968b7a268fad75bd2fa442c921376684","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"96ecbe9236c400bb","findings":[{"citation":"resolved","description":"twapOut multiplies the USDC/WETH window average (avgA = WETH per USDC) by the WETH/IMD window average (avgB = IMD per WETH). The product of two time averages is not the time average of the route price avgA*avgB: when the two hops move against each other (ETH changes in USD while IMD holds its USD price, so P_A = 1/E falls as P_B = IMD per WETH rises) Chebyshev's inequality gives E[P_A]*E[P_B] >= E[P_A*P_B], so twapOut over-states the route TWAP and the burn fails the 3% check although the route price (IMD per USDC) never moved. With a step at mid-window from E1 to E2 = r*E1 the over-statement is (2 + r + 1/r)/4, which exceeds 1/0.97 for r >= 1.42: any ETH move of about 42% within one window (windows run from the last burn and are often days long when payouts are rare) makes every burn revert with PriceOffTwap until poke() is allowed (6 h after the window opened) plus MIN_WINDOW, and the pending window suffers the same distortion if ETH keeps moving. In the opposite correlation (IMD moving more than ETH in the same direction) the product under-states the route TWAP, which widens the sandwich headroom by the same factor (harmless at DEPTH_BPS = 25, see the sandwich arithmetic). Impact is liveness only: no USDC or IMD leaves wrongly. Fix: accumulate a route observation instead of multiplying hop averages is not possible with two independent v2 oracles; the practical fixes are to loosen the check only for this case, to anchor the WETH/IMD leg to ETH (compare IMD-per-WETH against its own TWAP and USDC-per-WETH against its own TWAP, each within tolerance, i.e. per-hop tolerance instead of a tolerance on the product), or to document that burns pause for up to 6.5 h after a >= 42% ETH move.","line":220,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs (same reserves/cumulative rules as UniswapV2Pair). Pair A 200 WETH / 400,000 USDC ($2,000/ETH); pair B 100 WETH / 20,000,000 IMD (200,000 IMD per WETH, so 100 IMD per USDC). Deploy, fund 10 USDC, warp +15 min; set pair A to 200 WETH / 600,000 USDC and pair B to 100 WETH / 30,000,000 IMD (ETH $3,000, IMD still $0.01, route still 100 IMD per USDC) and sync both; warp +15 min + 1 s. preview() returns usdcIn 10e6, spotOut 999.9999e18 (the true route price), twapOut 1041.6666e18 (avgA = 1/2400 ETH per USDC times avgB = 250,000 IMD per WETH = 104.17 IMD per USDC); spot/twap = 9600 bps < 9700. Expected: a burn at the unchanged route price succeeds. Actual: buyAndBurn() reverts PriceOffTwap(1041666653820861361299, 999999999999999900000). Scratch test test_RoutePriceFlatButBurnReverts (mock, no fork) shows exactly this.","severity":"low","snippet":"            q.twapOut = _mulQ112(_mulQ112(q.usdcIn, avgA), avgB);","title":"Two-hop TWAP is the product of per-hop averages, not the route's average: a flat IMD/USD price reverts the burn after a 42%+ ETH move inside the window"},{"citation":"resolved","description":"The rule (fix for dca64686 finding 2) assumes the depth cap is at least THRESHOLD. When depthA = rUsdc * 25 / 10,000 (or the WETH-side depthB) is below 5e6, i.e. the USDC/WETH reserve is under $2,000 or the WETH/IMD pair holds under about 0.8 WETH, a balance in (THRESHOLD, THRESHOLD + cap) is first trimmed to leave exactly THRESHOLD, as intended; the next burn then has balance == THRESHOLD, the condition `balance > THRESHOLD` is false, usdcIn = cap < THRESHOLD, and THRESHOLD - cap stays behind below the threshold. The comment on line 208 (\"a capped burn leaves at least THRESHOLD behind, so the remainder can always be burned\") does not hold at this boundary. No funds are lost: the dust becomes burnable again as soon as new USDC arrives, and today's reserves ($438k / 166 WETH) are far from the boundary. Minimal fix: when cap < THRESHOLD and balance - cap < THRESHOLD, burn the whole balance only if balance <= cap, otherwise keep the remainder rule but let a balance <= THRESHOLD + cap be burned in one go (e.g. compute usdcIn = balance when balance - cap < THRESHOLD and balance <= cap + THRESHOLD and depth allows), or accept and document dust under $5 in thin pools, as test/ThinPoolLock.t.sol already does.","line":209,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs: pair A 0.5 WETH / 1,000 USDC (depthA = 2.5e6 < THRESHOLD), pair B 100 WETH / 20,000,000 IMD. Fund the contract with 5,200,000 (= $5.20); warp +31 min. Burn 1: usdcIn = 0.2e6 (5.2e6 - 2.5e6 = 2.7e6 < THRESHOLD, so balance - THRESHOLD), leaving exactly 5,000,000. Warp +31 min. Burn 2: balance == THRESHOLD, usdcIn = cap = 2,500,500 (reserve now 1,000.2 USDC), leaving 2,499,500. Expected per line 208: the remainder is always burnable. Actual: every later buyAndBurn() reverts BelowThreshold(2499500) until new USDC arrives. Scratch test test_RemainderRuleStrandsDustWhenCapBelowThreshold (mock, no fork) shows this.","severity":"info","snippet":"        if (q.usdcIn < balance && balance - q.usdcIn < THRESHOLD && balance > THRESHOLD) q.usdcIn = balance - THRESHOLD;","title":"Remainder rule leaves exactly THRESHOLD only when cap >= THRESHOLD: in a pool under about $2,000 a balance of exactly THRESHOLD strands THRESHOLD - cap below the threshold"},{"citation":"resolved","description":"Verified: with the exact Uniswap v2 formulas at today's pair A reserves (438,092 USDC / 179.7 WETH) the largest pump that keeps spot/twap >= 0.97 is 6,726.40 USDC and the sandwich around a $1,000 burn loses 9.86 USDC; at 200,000 USDC a side (burn 507.68, 0.25%) it loses 2.98 USDC, matching test/SandwichWithinTolerance.t.sol. However those losses are the 0.3% fees of the attacker's two trades plus the victim's fee, which accrue to the pair's LPs (5/6 of them, since the Base factory has feeTo = 0x9bD25e67bF390437C8fAF480AC735a27BcF6168c). Fees paid into the pool during the bundle are 43.70 USDC (438k reserves) / 20.11 USDC (200k reserves). An attacker who is also an LP with share f recovers 5/6 * f of that: break-even f = 9.86 / (43.70 * 5/6) = 27% today, and 2.98 / (20.11 * 5/6) = 18% when the burn is a full 0.25% of the reserve. At f = 50% the net gain is about 8 USDC per $1,000 burn (0.8%) today and 5.4 USDC per $508 burn (1.1%) at $200k reserves. The README's sentence \"Such a sandwich does not pay its author\" therefore holds only for a non-LP author; the extractable amount is about 1% of each burn for an actor holding roughly a fifth to a quarter of the pair's liquidity (about $160k-$240k of LP today), which is why this is informational. If a tighter bound is wanted, DEPTH_BPS <= 15 (half the round-trip fee) keeps the sandwich unprofitable for any LP share below 50%, at the cost of more burn calls.","line":46,"path":"src/IMDBuyAndBurn.sol","reproduction":"Exact integer arithmetic with getAmountOut (997/1000) on pair A = 438,092e6 USDC / 179.7e18 WETH, pair B = 165.58e18 WETH / 66,004e18 IMD, flat TWAP: pump x = 6,726.40 USDC (bisected so spot >= 0.97 * twap, times 0.999), burn v = 1,000 USDC at the pumped reserves, back-run sells the WETH: attacker USDC delta = -9.86; fees paid into pair A by the three trades = 43.70 USDC. With LP share f = 0.3: -9.86 + 0.3 * 5/6 * 43.70 = +1.07 USDC; f = 0.5: +8.35 USDC per burn. Expected (README): the sandwich never pays its author. Actual: it pays an LP-author above 27% share. The same bundle is the one test/SandwichWithinTolerance.t.sol runs; only the attacker's LP share is missing from its accounting.","severity":"info","snippet":"    uint256 public constant DEPTH_BPS = 25; // one burn spends at most 0.25% of either pair's input-side reserve","title":"The sandwich-does-not-pay bound (DEPTH_BPS 25 < 30 fee bps) counts the LP fee as a pure cost: an attacker holding >= ~27% of the USDC/WETH LP (18% when the $1,000 cap does not bind) nets a small profi"}],"hash":"167a1cbe11b12c4415d72eb4b8cdc8abf5b756e68ca370642977a4c878ca2cf0","nodeId":"e84c890a-5b63-4f70-85ff-f3ff72713f7b","outcome":"completed","summary":"All 18 project tests pass on the Base fork and with mock pairs, the findings file is written with snippets that match the tree, and the scratch directory is removed. The tree is clean apart from the ignored findings file.\n\n**Outcome.** No high or critical defects. The three dca64686 fixes work as described. I recorded one low and two informational findings in `.imd-findings.json`, each with a concrete failing state.\n\n**Verification of the three fixes**\n\n- **Pending window.** poke() reverts NotStale while the burn window is 6 h old or younger, reverts PendingWindow while a pending window is 6 h old or younger, and never writes the burn window. buyAndBurn() and preview() use the burn window when it is ready and only otherwise fall back to a ready pending window. A burn stores the current cumulatives and zeroes pendTime, so a stale pending observation can never be read again. Same-block manipulation before a poke adds zero seconds to the pending average, and holding a manipulated price through the 30 minute window costs the same fee as an atomic sandwich, so the pending window gives no bypass. Worst-case blocking after a step move is the designed 6.5 hours.\n- **Remainder rule.** The trimmed size is always positive and below the cap, and a zero-size burn needs a reserve under 400 base units, where the pair reverts anyway. preview() cannot divide by zero on any reserve state because usdcIn is zero whenever a divisor would be.\n- **DEPTH_BPS = 25.** Exact Uniswap v2 arithmetic at today's reserves (438k USDC) gives the attacker a loss of 9.86 USDC on the largest in-tolerance pump, and a loss of 2.98 USDC at 200k reserves. The sign does not depend on the deviation, so a lenient TWAP after a real crash does not help the attacker at current pool sizes. Both pairs are official Uniswap v2 pairs on Base with the 0.3% fee the contract hardcodes.\n\n**Findings**\n\n1. **Low, line 220.** The two-hop TWAP multiplies the per-hop averages. When ETH moves against a USD-stable IMD, that product over-states the route average. A 42% or larger ETH move inside a window makes every burn revert PriceOffTwap even though the route price is unchanged. Mock reproduction: ETH 2000 to 3000 at mid-window, spot/twap lands at 9600 bps. Liveness only, up to about 6.5 h.\n2. **Info, line 209.** When the depth cap is below THRESHOLD (pool under about $2,000), a balance of exactly THRESHOLD burns only the cap and strands the rest below the threshold until new USDC arrives. The comment on line 208 does not hold there.\n3. **Info, line 46.** The \"sandwich does not pay\" bound treats the LP fee as a pure cost. An attacker holding about 27% of the USDC/WETH LP today (18% when the $1,000 cap does not bind) recovers enough fee to net roughly 1% of each burn.\n\n**Not found.** No path for USDC or IMD to leave other than through the two swaps to the pair and 0x…dEaD, no overflow in the UQ112x112 chain at any reachable reserve state, no window wrap-around error, and no state where preview() reverts.","treeHash":null,"usage":{"cachedInputTokens":704984,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":41899,"runtime":"claude","turns":21,"wallClockMs":600339}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d4b53d656a3fbe07","findings":[{"citation":"resolved","description":"buyAndBurn() (lines 105-109) and preview() (173-177) always take the burn window when _ready(q) holds and consult the pending window only as a fallback. The burn window starts at the last burn or at deployment and has no maximum age (on mainnet today it starts at deployment with no USDC received yet). _withinTolerance() (line 190) only rejects a spot that is dearer than the window TWAP. After IMD has fallen inside a long window the TWAP sits far above the market, so the honest spot passes with slack TWAP/spot and an attacker can pump the route by (TWAP/spot - 3%) in the burn's block instead of 3%. A pending window opened by poke() after the drop (allowed once the burn window is older than STALE_AFTER) would reject the pump, but it is ignored because the stale window passes, and the burn then clears it (pendTime = 0). This contradicts the README ('An attacker can move the route price by up to 3% before a burn without making it revert', 'realizes up to about 4% less IMD') and the line 47 comment. Economics: a non-LP attacker pays about 0.6% of the pump in fees and gains only the burn's impact, so at today's reserves the pump is griefing at a loss (roughly 1.3-2.3 USDC of attacker cost per USDC the burn loses); an LP of the pumped pair recovers 5/6 of those fees and can profit (see the LP-share finding). The pump also raises the depth cap (see the depth-cap finding), so a capped burn spends more USDC at the worse price: in the flow specialist's variant usdcIn grows from 807.92 to 989.70 USDC. Merged from audit_economics (medium), audit_flow (low) and audit_permissions (low); the three describe the same mechanism and fix. Minimal fix preserving the design: when a pending window exists and is at least MIN_WINDOW old, require the burn to pass it too (or use it instead of the burn window), in both buyAndBurn() and preview(); optionally never use a burn window older than STALE_AFTER without a pending window. Verified: with `if (pendTime != 0) { p = _quote(..pend..); if (p.elapsed >= MIN_WINDOW && (!_ready(q) || !_withinTolerance(p))) q = p; }` in both functions, both specialists' proofs pass.","line":106,"path":"src/IMDBuyAndBurn.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// Finding: the price check is one-sided (only \"spot dearer than TWAP\" reverts) and buyAndBurn() uses the burn window\n// whenever it passes, even when a fresher pending window exists. After IMD has fallen inside a long burn window, the\n// TWAP sits far above the market, so an attacker can pump the route by far more than MAX_DEVIATION_BPS (here ~35%)\n// and the burn still runs, at a price the fresh pending window would have rejected. Mock pairs with the real Uniswap\n// v2 K and fee rules, no fork. Fails on this code; passes once the burn is rejected or bounded near the market price.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IMDBuyAndBurn} from \"src/IMDBuyAndBurn.sol\";\n\ncontract MockToken {\n    mapping(address => uint256) public balanceOf;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function transfer(address to, uint256 a) external returns (bool) {\n        balanceOf[msg.sender] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\ncontract MockPair {\n    address public token0;\n    address public token1;\n    uint112 r0;\n    uint112 r1;\n    uint32 last;\n    uint256 public price0CumulativeLast;\n    uint256 public price1CumulativeLast;\n\n    function init(address t0, address t1) external {\n        token0 = t0;\n        token1 = t1;\n        _update();\n    }\n    function getReserves() external view returns (uint112, uint112, uint32) { return (r0, r1, last); }\n    function _update() internal {\n        uint32 now32 = uint32(block.timestamp);\n        unchecked {\n            uint32 dt = now32 - last;\n            if (dt > 0 && r0 != 0 && r1 != 0) {\n                price0CumulativeLast += ((uint256(r1) << 112) / r0) * dt;\n                price1CumulativeLast += ((uint256(r0) << 112) / r1) * dt;\n            }\n        }\n        r0 = uint112(MockToken(token0).balanceOf(address(this)));\n        r1 = uint112(MockToken(token1).balanceOf(address(this)));\n        last = now32;\n    }\n    function swap(uint256 out0, uint256 out1, address to, bytes calldata) external {\n        (uint256 o0, uint256 o1) = (r0, r1);\n        if (out0 > 0) MockToken(token0).transfer(to, out0);\n        if (out1 > 0) MockToken(token1).transfer(to, out1);\n        uint256 b0 = MockToken(token0).balanceOf(address(this));\n        uint256 b1 = MockToken(token1).balanceOf(address(this));\n        uint256 in0 = b0 > o0 - out0 ? b0 - (o0 - out0) : 0;\n        uint256 in1 = b1 > o1 - out1 ? b1 - (o1 - out1) : 0;\n        require(in0 > 0 || in1 > 0, \"INSUFFICIENT_INPUT\");\n        require((b0 * 1000 - in0 * 3) * (b1 * 1000 - in1 * 3) >= o0 * o1 * 1e6, \"K\");\n        _update();\n    }\n}\n\ncontract StaleWindowPumpTest is Test {\n    address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;\n    address constant WETH = 0x4200000000000000000000000000000000000006;\n    address constant IMD = 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105;\n    address constant PAIR_A = 0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C; // WETH/USDC, WETH is token0\n    address constant PAIR_B = 0xDE66c35e01ED8e619Bf092352338ef94f2327337; // WETH/IMD, WETH is token0\n    address constant DEAD = 0x000000000000000000000000000000000000dEaD;\n\n    IMDBuyAndBurn burner;\n    address seller = makeAddr(\"seller\");\n    address attacker = makeAddr(\"attacker\");\n\n    function _out(uint256 x, uint256 rIn, uint256 rOut) internal pure returns (uint256) {\n        uint256 f = x * 997;\n        return (f * rOut) / (rIn * 1000 + f);\n    }\n\n    function setUp() public {\n        vm.warp(1_760_000_000);\n        address tok = address(new MockToken());\n        vm.etch(USDC, tok.code);\n        vm.etch(WETH, tok.code);\n        vm.etch(IMD, tok.code);\n        address pair = address(new MockPair());\n        vm.etch(PAIR_A, pair.code);\n        vm.etch(PAIR_B, pair.code);\n        // Pair A at its Base reserves (block 52644997); pair B at the Base price with 2.5x the depth, so that the\n        // burn size stays MAX_PER_BURN before and after the moves below (the depth cap does not change the size)\n        MockToken(WETH).mint(PAIR_A, 179.7e18);\n        MockToken(USDC).mint(PAIR_A, 438_092e6);\n        MockPair(PAIR_A).init(WETH, USDC);\n        MockToken(WETH).mint(PAIR_B, 414e18);\n        MockToken(IMD).mint(PAIR_B, 165_010e18);\n        MockPair(PAIR_B).init(WETH, IMD);\n        burner = new IMDBuyAndBurn(); // the burn window starts here, with no USDC to burn yet\n    }\n\n    /// Sells IMD into pair B so that IMD gets about `pct` percent cheaper (one genuine market move, not unwound)\n    function _imdFalls(uint256 pct) internal {\n        (uint112 w, uint112 i,) = MockPair(PAIR_B).getReserves();\n        // price falls by pct% when the IMD reserve grows by 1/sqrt(1 - pct/100): approximate with a fixed-point sqrt\n        uint256 target = (uint256(i) * 1e9) / _sqrt((100 - pct) * 1e16);\n        uint256 imdIn = target - i;\n        MockToken(IMD).mint(seller, imdIn);\n        uint256 wethOut = _out(imdIn, i, w);\n        vm.startPrank(seller);\n        MockToken(IMD).transfer(PAIR_B, imdIn);\n        MockPair(PAIR_B).swap(wethOut, 0, seller, \"\");\n        vm.stopPrank();\n    }\n\n    function _sqrt(uint256 y) internal pure returns (uint256 z) {\n        if (y > 3) {\n            z = y;\n            uint256 x = y / 2 + 1;\n            while (x < z) { z = x; x = (y / x + x) / 2; }\n        } else if (y != 0) z = 1;\n    }\n\n    function test_StaleBurnWindowLetsALargePumpThrough() public {\n        // 7 hours flat, then IMD falls 50% and stays there for another 7 hours (14 hours of burn window in total)\n        vm.warp(block.timestamp + 7 hours);\n        _imdFalls(50);\n        vm.warp(block.timestamp + 6 hours);\n        burner.poke(); // the burn window is stale: a fresh pending window opens at the new, lower price\n        vm.warp(block.timestamp + 1 hours);\n        MockToken(USDC).mint(address(burner), 1_000e6); // the first payout arrives\n\n        (uint256 spend, uint256 twapOut, uint256 spotHonest, uint256 honestPoolOut, uint32 elapsed) = burner.preview();\n        assertEq(elapsed, 14 hours, \"preview reports the burn window, not the 1 hour pending one\");\n        emit log_named_uint(\"honest spot / burn-window TWAP, bps\", (spotHonest * 10_000) / twapOut);\n\n        // Attacker: buy IMD with WETH in pair B so that the route is ~35% dearer than the market\n        (uint112 w, uint112 i,) = MockPair(PAIR_B).getReserves();\n        uint256 wethIn = (uint256(w) * 162) / 1000; // (1.162)^2 = 1.35\n        MockToken(WETH).mint(attacker, wethIn);\n        uint256 imdGot = _out(wethIn, w, i);\n        vm.startPrank(attacker);\n        MockToken(WETH).transfer(PAIR_B, wethIn);\n        MockPair(PAIR_B).swap(0, imdGot, attacker, \"\");\n        vm.stopPrank();\n\n        (uint256 spend2, , uint256 spotPumped, , ) = burner.preview();\n        assertEq(spend2, spend, \"same burn size before and after the pump\");\n        emit log_named_uint(\"pumped spot / honest spot, bps\", (spotPumped * 10_000) / spotHonest);\n        assertLt(spotPumped * 100, spotHonest * 80, \"the route is more than 20% dearer than the market\");\n\n        // Victim: the burn either reverts (fixed) or must realize close to the market price\n        (bool ok, bytes memory ret) = address(burner).call(abi.encodeCall(IMDBuyAndBurn.buyAndBurn, ()));\n        if (ok) {\n            uint256 burned = abi.decode(ret, (uint256));\n            emit log_named_uint(\"burn loss vs no pump, bps\", ((honestPoolOut - burned) * 10_000) / honestPoolOut);\n            assertEq(burner.pendTime(), 0, \"the burn cleared the fresh pending window it ignored\");\n            // MAX_DEVIATION_BPS (3%) plus fees and impact (under 1% at this size): anything beyond 5% is the defect\n            assertGe(burned * 100, honestPoolOut * 95, \"a burn must not run more than ~3% off the market price\");\n        }\n        assertEq(spend, 1_000e6, \"the burn is sized by MAX_PER_BURN, not by the pools' depth\");\n    }\n}","reproduction":"Mock pairs with the Uniswap v2 K, fee and cumulative-price rules, no fork (the attached proof, run with `forge test --match-path test/scratch/<file> -vv`). Pair A at Base reserves (179.7 WETH / 438,092 USDC), pair B at the Base price with 414 WETH / 165,010 IMD. Deploy (burn window starts, no USDC). +7h: a seller dumps IMD so it is 50% cheaper and it stays there. +13h: poke() opens a pending window at the new price. +14h: 1,000 USDC arrives. preview() reports the 14-hour burn window (elapsed = 50,400) with spot/TWAP = 13,329 bps. Attacker buys IMD with 16.2% of pair B's WETH reserve: pumped spot / honest spot = 7,409 bps (the route is 26% dearer than one block earlier). Expected: buyAndBurn() reverts PriceOffTwap (the 1-hour pending window rejects it), or realizes within ~3% plus fees of the market. Actual: buyAndBurn() succeeds, burns 25.9% fewer IMD than without the pump (23,976.99 vs 30,737.25 IMD, 2,589 bps of a $1,000 burn), and sets pendTime to 0. The second specialist proof (Base reserves for both pairs, 3-day idle, 25% IMD sell, pump of 22.5% of pair B's WETH) gives the same outcome at 3,335 bps and usdcIn growing from 807.919756 to 989.701702. Both proofs fail on this code and pass with the fix above.","severity":"medium","snippet":"        if (!_ready(q) && pendTime != 0) {","title":"Burn uses the unbounded-age burn window before a ready pending window, and the one-sided check lets a pump far beyond MAX_DEVIATION_BPS through after a price drop"},{"citation":"resolved","description":"The DEPTH_BPS = 25 argument (line 27-28 comment, README 'Trust assumptions') compares the burn (at most 0.25% of the USDC/WETH reserve) against the 0.6% round-trip fee the pump pays. Those fees go to the pair's LPs (5/6 of them; the Base factory's feeTo takes 1/6), so an attacker holding a fraction f of the LP supply recovers 5/6 * f of them. Uniswap v2 charges nothing to add or remove liquidity, so f can also be created in the same transaction with flash liquidity: adding liquidity raises the total reserve, which lifts the DEPTH_BPS cap until MAX_PER_BURN = $1,000 binds, while the fee the attacker really pays is 0.6% of the pump times the outside LPs' share. Net per burn is about pump * (2 * burn / reserve - 0.006 * (1 - f)). With the 1/6 protocol fee modelled: at today's outside liquidity ($438k) a flash-LP attacker loses 15.27 USDC per $1,000 burn, but a standing 50% LP gains 6.84 USDC per burn, and a flash-LP attacker gains 6.27 USDC once the outside USDC liquidity falls to $200k (break-even near $250k; the permissions specialist's +3.58 at $300k did not model the protocol fee and is -2.75 with it). Each such sandwich makes the burn buy about 3% fewer IMD (2.98% at the tolerance edge). The README statement 'Such a sandwich does not pay its author' therefore holds only for a non-LP author while the pair's outside liquidity stays above roughly $250k. The comment on line 27 also calls the round trip a '0.3%' fee; it is 0.6% (0.3% per leg). Merged from audit_math (info), audit_flow (info) and audit_permissions (low). Fix options (design decision): lower DEPTH_BPS (<= 15 keeps a standing LP below 50% share unprofitable) or MAX_DEVIATION_BPS, size the burn from the reserve observed at the start of the window rather than the current reserve so flash liquidity cannot lift the cap, or document the residual: an LP of 0x88A4...bB9C above ~25-50% share, or anyone once outside liquidity falls under ~$250k, can take up to ~3% of each burn.","line":46,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs with Uniswap v2 K, fee, mint/burn and the 1/6 protocol fee (test/scratch/Judge.t.sol, no fork). Pair B 165.58 WETH / 66,004 IMD; contract holds 1,000 USDC; 31 minutes pass; attacker adds proportional liquidity to pair A, buys WETH with the largest USDC amount that keeps spotOut >= 0.97 * twapOut (bisection, times 0.999), calls buyAndBurn(), sells the WETH back, removes the liquidity; net = USDC delta + WETH delta at the pool price. (a) others 200,000 USDC, attacker adds 400,000 USDC: pump 9,212.31 USDC, burn spends 1,000 USDC (the cap rose from $500 to MAX_PER_BURN), attacker net +6.271302 USDC. (b) others 438,092 USDC (Base today), attacker holds 50% as a standing position: pump 6,726.40 USDC, net +6.844687 USDC. (c) others 438,092, attacker flash-adds 400,000: net -15.269440 USDC. (d) others 200,000, no LP: net -2.976002 USDC (the repo's test/SandwichWithinTolerance.t.sol number). Expected per README line 32: the sandwich never pays its author. Actual: it pays in (a) and (b).","severity":"low","snippet":"    uint256 public constant DEPTH_BPS = 25; // one burn spends at most 0.25% of either pair's input-side reserve","title":"The 'sandwich inside the tolerance does not pay' bound ignores the attacker's LP share: an LP of the USDC/WETH pair (standing or flash-added) profits from a within-3% sandwich"},{"citation":"resolved","description":"twapOut multiplies the window average of pair A's price (WETH per USDC) by the window average of pair B's price (IMD per WETH). The time average of a product is not the product of the time averages. When the two hops move against each other (ETH changes in USD while IMD keeps its USD price, so WETH-per-USDC falls as IMD-per-WETH rises) the product over-states the route TWAP: for a step from E1 to E2 = r * E1 at mid-window the over-statement is (2 + r + 1/r) / 4, which exceeds 1 / 0.97 for r >= 1.42. Every burn then reverts PriceOffTwap although IMD per USDC never moved, and poke() is refused (NotStale) until the burn window is 6 hours old, then MIN_WINDOW more must pass: a liveness delay of up to about 6.5 hours after any ETH move of about 42% or more inside one window (windows run from the last burn and can be days long). The opposite correlation under-states the TWAP, which widens the sandwich slack by the same factor. No USDC or IMD leaves wrongly. From audit_math. Fix options: check each hop against its own TWAP (per-hop tolerance) instead of the product, or document the pause.","line":220,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs (test/scratch/Judge.t.sol test_RoutePriceFlatButBurnReverts, no fork). Pair A 200 WETH / 400,000 USDC ($2,000/ETH); pair B 100 WETH / 20,000,000 IMD (100 IMD per USDC). Deploy, fund 10 USDC, warp +15 min; set pair A to 200 WETH / 600,000 USDC and pair B to 100 WETH / 30,000,000 IMD (ETH $3,000, IMD still $0.01, route still 100 IMD per USDC) and sync both; warp +15 min + 1 s. preview() returns usdcIn 10e6, spotOut 999999999999999900000, twapOut 1041666653820861361299, spot/twap = 9,600 bps < 9,700. Expected: a burn at an unchanged route price succeeds. Actual: buyAndBurn() reverts PriceOffTwap(1041666653820861361299, 999999999999999900000) and poke() reverts NotStale.","severity":"low","snippet":"            q.twapOut = _mulQ112(_mulQ112(q.usdcIn, avgA), avgB);","title":"Two-hop TWAP is the product of per-hop averages, not the route average: a flat IMD/USD price reverts burns for up to 6.5 hours after a large ETH move inside the window"},{"citation":"resolved","description":"poke() may be called by anyone as soon as the burn window is older than STALE_AFTER, even when the contract holds no USDC, and the pending window it opens cannot be replaced for another STALE_AFTER. Its TWAP anchors to the price at the poke. If USDC arrives later and the market has risen more than MAX_DEVIATION_BPS since both anchors, both windows fail the price check and nobody can open a fresh one until the pending window is 6 hours old. The README's 'poke() cannot delay a burn' holds for a burn available at poke time, not for one that becomes available afterwards. Cost to the griefer: one transaction per 6 hours. No funds are lost; the burn waits at most about STALE_AFTER + MIN_WINDOW. From audit_economics. Possible fix: allow a pending window to be replaced once it is at least MIN_WINDOW old and a burn against it currently fails the price check (a griefer would then have to hold the price moved for 30 minutes), or keep a second pending slot.","line":147,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs at Base reserves (test/scratch/Judge.t.sol test_EarlyPokeDelaysTheBurn, no fork). Deploy at t0 with no USDC. With the grief: t0+6h+1s griefer calls poke(). t0+7h and t0+7h30: two genuine buys of 5% of pair B's WETH each (IMD about 21% dearer, then flat). t0+8h: 1,000 USDC arrives. A keeper that tries buyAndBurn() then poke() every minute: with the early poke the burn runs 16,260 s (4h31) after the payout (buyAndBurn() reverts PriceOffTwap on both windows and poke() reverts PendingWindow until the pending window is 6 h old, then 30 min more); without the early poke the same keeper pokes at t0+8h and burns 1,800 s later. Expected per README line 18: poke() cannot delay a burn. Actual: 4 hours of extra delay.","severity":"low","snippet":"        if (pendTime != 0 && pendElapsed <= STALE_AFTER) revert PendingWindow(pendElapsed);","title":"A pre-emptive poke() with no USDC to burn locks the pending slot for 6 hours and can turn a 30-minute wait into a 4.5-hour one"},{"citation":"resolved","description":"The rule (fix for dca64686 finding 2) only applies when balance > THRESHOLD and assumes the depth cap is at least THRESHOLD. When the cap is below 5e6 (USDC/WETH reserve under $2,000, or the WETH/IMD pair's WETH side worth under $2,000) and the balance is exactly THRESHOLD, the burn spends the cap and leaves balance - cap < THRESHOLD, which BelowThreshold rejects until new USDC arrives; the line 208 comment and README line 16 ('always leaves at least $5 behind') do not hold at this boundary. When the balance is THRESHOLD + 1 wei the rule sets usdcIn = 1: the burn runs for one micro-USDC, resets obsTime and clears the pending window, and the remaining $5 waits another MIN_WINDOW. No underflow is reachable (balance - q.usdcIn is guarded by q.usdcIn < balance, balance - THRESHOLD by balance > THRESHOLD) and usdcIn is 0 only for an empty pool, where the pair's swap reverts. Not reachable at today's reserves ($438k and about $400k); no funds are lost. Merged from audit_math, audit_flow, audit_permissions and audit_economics (all info). Fix if wanted: when the cap is below THRESHOLD let the burn spend the whole balance, or require a minimum usdcIn, and reword the comment and README.","line":209,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs (test/scratch/Judge.t.sol, no fork): pair A 0.5 WETH / 1,000 USDC (depthA = 2.5e6), pair B 100 WETH / 20,000,000 IMD. (a) test_RemainderDustWhenCapBelowThreshold: fund 5,200,000; warp +31 min; burn 1 spends 200,000 and leaves exactly 5,000,000; warp +31 min; burn 2 has balance == THRESHOLD, usdcIn = 2,500,500, leaves 2,499,500; warp +31 min; buyAndBurn() reverts BelowThreshold(2499500). (b) test_OneWeiBurnResetsWindow: fund 5,000,001; warp +31 min; preview() usdcIn = 1; buyAndBurn() burns 99,400,899,800,105 wei of IMD for 1 wei of USDC, obsTime moves to the current block, 5,000,000 remains and the next buyAndBurn() reverts WindowTooShort. Expected per line 208: at least THRESHOLD left or everything burned.","severity":"info","snippet":"        if (q.usdcIn < balance && balance - q.usdcIn < THRESHOLD && balance > THRESHOLD) q.usdcIn = balance - THRESHOLD;","title":"Remainder rule excludes balance == THRESHOLD: in a thin pool a capped burn leaves sub-THRESHOLD dust, and a balance of THRESHOLD + 1 wei burns 1 micro-USDC and resets the window"},{"citation":"resolved","description":"depthA and depthB are DEPTH_BPS of the reserves as they stand in the burn's block. A front-run that buys WETH from pair A or IMD from pair B grows that pair's input-side reserve by the pump fraction p, so a depth-capped burn grows by (1 + p) at the same time as its price worsens. Adding flash liquidity has the same effect. On its own this is bounded by the 3% tolerance (p of about 1.5%) and by MAX_PER_BURN; it matters together with the stale-window slack (medium finding) and the LP-share finding, where the pump is large. From audit_flow. Fix: size the burn from the reserves at the window start (the cumulative price does not give them, so this needs storing the reserves at each observation), or keep MAX_PER_BURN as the only cap when the pool is deep and document the factor.","line":203,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs at Base reserves (the second specialist proof, test/scratch, no fork): after a 3-day idle window and a 25% IMD sell, preview() reports usdcIn = 807.919756 USDC (depthB binds); the attacker buys 22.5% of pair B's WETH reserve in the burn's block; buyAndBurn() then spends 989.701702 USDC (totalUsdcSpent after the call). In test/scratch/Judge.t.sol test_LpSandwich_200k_others_400k_attacker the flash-added liquidity lifts usdcIn from 507.68 USDC to 1,000 USDC. Expected: a burn is no larger than one block earlier. Actual: 22.5% larger at a 33% worse price.","severity":"info","snippet":"        uint256 depthA = (rUsdc * DEPTH_BPS) / 10_000;\n        uint256 depthB = rWethA == 0 ? 0 : (((rWethB * DEPTH_BPS) / 10_000) * rUsdc) / rWethA;","title":"Depth cap is sized from the current, possibly pumped, reserves, so a pump that inflates the input-side reserve also enlarges the burn it sandwiches"}],"hash":"329d054a15a7f7307beb099f7c9201576d96b74b44932dfe20974fb79a7764c9","nodeId":"a0999f0c-421a-4d62-8c22-330cb84ff2de","outcome":"completed","summary":"The review is complete. The findings file holds six substantiated findings and the tracked tree is unchanged (only the ignored findings file, scratch tests and build output were written).\n\n**Outcome.** The three dca64686 fixes are in place and work as described: the pending window, the burnable remainder, and the 0.25% depth bound all behave as the README says in the normal case. The repo's 14 fork tests pass against the public Base RPC and the 4 mock tests pass. What the changes did not cover is that the burn window has no maximum age and is always preferred, which is the one finding I rate above low.\n\n**Findings written to `.imd-findings.json`:**\n\n- **Medium, line 106.** The burn window is consulted first and has no maximum age, and the tolerance check is one-sided. After IMD falls inside a long window, the TWAP sits far above the market and an atomic pump of up to the whole gap passes. A ready pending window that would reject it is ignored and then cleared. Both specialist proofs fail on this code (burn loses 25.9% and 33.4% versus one block earlier) and pass with a fallback that also requires a ready pending window. Merged from economics, flow and permissions. The economics proof is attached.\n- **Low, line 46.** The \"sandwich does not pay\" bound ignores the attacker's LP share. With the 1/6 protocol fee modelled, a standing 50% LP of the USDC/WETH pair nets about 6.8 USDC per $1,000 burn today, and flash-added liquidity makes it profitable once outside liquidity drops to about $250k. Merged from math, flow and permissions.\n- **Low, line 220.** The product of two per-hop TWAPs is not the route TWAP. A 50% ETH move with IMD flat in USD reverts every burn and poke is refused for 6 hours. Reproduced at 9,600 bps.\n- **Low, line 147.** A pre-emptive poke with no USDC present locks the pending slot for 6 hours. With a later 21% rise, the burn waits 4h31 instead of 30 minutes.\n- **Info, line 209.** The remainder rule skips balance == THRESHOLD, so a thin pool strands dust below the threshold, and THRESHOLD + 1 wei triggers a 1-wei burn that resets the window.\n- **Info, line 203.** The depth cap is sized from current reserves, so a pump or flash liquidity enlarges the burn it sandwiches.\n\n**Checked and not found.** No underflow in the remainder rule, no reachable zero-size burn that gets past the pair's swap, no preview() revert at realistic reserves, and no path for USDC or IMD to leave other than through the two swaps to the pairs and 0x...dEaD. Dropped nothing: every specialist claim reproduced, and I merged four duplicate groups into single findings.","treeHash":null,"usage":{"cachedInputTokens":933655,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":29413,"runtime":"claude","turns":23,"wallClockMs":391595}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d11ea2b5e05fa7a8","findings":[{"citation":"resolved","description":"buyAndBurn() takes the burn window whenever _ready(q) is true and only falls back to the pending window otherwise (lines 105-109; preview() mirrors it at 173-177). _withinTolerance() (line 190) only rejects a spot price that is dearer than the TWAP. Together these mean the burn window can be arbitrarily old (it starts at the last burn or at deployment; on mainnet today it starts at deployment, with no USDC received yet) and, once IMD has fallen inside that window, the TWAP sits far above the market: the honest spot passes with a lot of slack, and an attacker can pump the route by (TWAP/spot - 3%) instead of 3% and the burn still runs. A fresh pending window opened by poke() that would reject the pump is ignored because the stale window passes, and the burn then clears it (pendTime = 0). The README guarantee that an attacker can move the route price by at most 3% before a burn without a revert does not hold after any decline inside the window. Economics: a non-LP attacker pays about 0.6% of the pump in fees and gains the burn's impact (profit/X = V(2R+X)/(R+X)^2 < 2V/R < 0.6%), so the sandwich is still a net loss for them (the extra IMD the burn should have received goes to the pair's LPs as fees and to the attacker's unwind); but a dominant LP of the WETH/FP pair (its 2,776 LP tokens are not burned or locked) recovers those fees and profits up to roughly 0.3-0.5 of the burn, and a plain griefer loses only about 0.1-0.2x of the value they take from the burn. Minimal fix that preserves the design: when a pending window exists and is at least MIN_WINDOW old, use it instead of the burn window (the fresher reference wins), i.e. replace the fallback with `if (pendTime != 0) { p = _quote(...pend...); if (p.elapsed >= MIN_WINDOW) q = p; }` in both buyAndBurn() and preview(); the proof passes with that change. Additionally consider not using a burn window older than STALE_AFTER at all (requiring a pending window after a long idle), so the reference TWAP is never more than 6 hours stale.","line":106,"path":"src/IMDBuyAndBurn.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// Finding: the price check is one-sided (only \"spot dearer than TWAP\" reverts) and buyAndBurn() uses the burn window\n// whenever it passes, even when a fresher pending window exists. After IMD has fallen inside a long burn window, the\n// TWAP sits far above the market, so an attacker can pump the route by far more than MAX_DEVIATION_BPS (here ~35%)\n// and the burn still runs, at a price the fresh pending window would have rejected. Mock pairs with the real Uniswap\n// v2 K and fee rules, no fork. Fails on this code; passes once the burn is rejected or bounded near the market price.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IMDBuyAndBurn} from \"src/IMDBuyAndBurn.sol\";\n\ncontract MockToken {\n    mapping(address => uint256) public balanceOf;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function transfer(address to, uint256 a) external returns (bool) {\n        balanceOf[msg.sender] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\ncontract MockPair {\n    address public token0;\n    address public token1;\n    uint112 r0;\n    uint112 r1;\n    uint32 last;\n    uint256 public price0CumulativeLast;\n    uint256 public price1CumulativeLast;\n\n    function init(address t0, address t1) external {\n        token0 = t0;\n        token1 = t1;\n        _update();\n    }\n    function getReserves() external view returns (uint112, uint112, uint32) { return (r0, r1, last); }\n    function _update() internal {\n        uint32 now32 = uint32(block.timestamp);\n        unchecked {\n            uint32 dt = now32 - last;\n            if (dt > 0 && r0 != 0 && r1 != 0) {\n                price0CumulativeLast += ((uint256(r1) << 112) / r0) * dt;\n                price1CumulativeLast += ((uint256(r0) << 112) / r1) * dt;\n            }\n        }\n        r0 = uint112(MockToken(token0).balanceOf(address(this)));\n        r1 = uint112(MockToken(token1).balanceOf(address(this)));\n        last = now32;\n    }\n    function swap(uint256 out0, uint256 out1, address to, bytes calldata) external {\n        (uint256 o0, uint256 o1) = (r0, r1);\n        if (out0 > 0) MockToken(token0).transfer(to, out0);\n        if (out1 > 0) MockToken(token1).transfer(to, out1);\n        uint256 b0 = MockToken(token0).balanceOf(address(this));\n        uint256 b1 = MockToken(token1).balanceOf(address(this));\n        uint256 in0 = b0 > o0 - out0 ? b0 - (o0 - out0) : 0;\n        uint256 in1 = b1 > o1 - out1 ? b1 - (o1 - out1) : 0;\n        require(in0 > 0 || in1 > 0, \"INSUFFICIENT_INPUT\");\n        require((b0 * 1000 - in0 * 3) * (b1 * 1000 - in1 * 3) >= o0 * o1 * 1e6, \"K\");\n        _update();\n    }\n}\n\ncontract StaleWindowPumpTest is Test {\n    address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;\n    address constant WETH = 0x4200000000000000000000000000000000000006;\n    address constant IMD = 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105;\n    address constant PAIR_A = 0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C; // WETH/USDC, WETH is token0\n    address constant PAIR_B = 0xDE66c35e01ED8e619Bf092352338ef94f2327337; // WETH/IMD, WETH is token0\n    address constant DEAD = 0x000000000000000000000000000000000000dEaD;\n\n    IMDBuyAndBurn burner;\n    address seller = makeAddr(\"seller\");\n    address attacker = makeAddr(\"attacker\");\n\n    function _out(uint256 x, uint256 rIn, uint256 rOut) internal pure returns (uint256) {\n        uint256 f = x * 997;\n        return (f * rOut) / (rIn * 1000 + f);\n    }\n\n    function setUp() public {\n        vm.warp(1_760_000_000);\n        address tok = address(new MockToken());\n        vm.etch(USDC, tok.code);\n        vm.etch(WETH, tok.code);\n        vm.etch(IMD, tok.code);\n        address pair = address(new MockPair());\n        vm.etch(PAIR_A, pair.code);\n        vm.etch(PAIR_B, pair.code);\n        // Pair A at its Base reserves (block 52644997); pair B at the Base price with 2.5x the depth, so that the\n        // burn size stays MAX_PER_BURN before and after the moves below (the depth cap does not change the size)\n        MockToken(WETH).mint(PAIR_A, 179.7e18);\n        MockToken(USDC).mint(PAIR_A, 438_092e6);\n        MockPair(PAIR_A).init(WETH, USDC);\n        MockToken(WETH).mint(PAIR_B, 414e18);\n        MockToken(IMD).mint(PAIR_B, 165_010e18);\n        MockPair(PAIR_B).init(WETH, IMD);\n        burner = new IMDBuyAndBurn(); // the burn window starts here, with no USDC to burn yet\n    }\n\n    /// Sells IMD into pair B so that IMD gets about `pct` percent cheaper (one genuine market move, not unwound)\n    function _imdFalls(uint256 pct) internal {\n        (uint112 w, uint112 i,) = MockPair(PAIR_B).getReserves();\n        // price falls by pct% when the IMD reserve grows by 1/sqrt(1 - pct/100): approximate with a fixed-point sqrt\n        uint256 target = (uint256(i) * 1e9) / _sqrt((100 - pct) * 1e16);\n        uint256 imdIn = target - i;\n        MockToken(IMD).mint(seller, imdIn);\n        uint256 wethOut = _out(imdIn, i, w);\n        vm.startPrank(seller);\n        MockToken(IMD).transfer(PAIR_B, imdIn);\n        MockPair(PAIR_B).swap(wethOut, 0, seller, \"\");\n        vm.stopPrank();\n    }\n\n    function _sqrt(uint256 y) internal pure returns (uint256 z) {\n        if (y > 3) {\n            z = y;\n            uint256 x = y / 2 + 1;\n            while (x < z) { z = x; x = (y / x + x) / 2; }\n        } else if (y != 0) z = 1;\n    }\n\n    function test_StaleBurnWindowLetsALargePumpThrough() public {\n        // 7 hours flat, then IMD falls 50% and stays there for another 7 hours (14 hours of burn window in total)\n        vm.warp(block.timestamp + 7 hours);\n        _imdFalls(50);\n        vm.warp(block.timestamp + 6 hours);\n        burner.poke(); // the burn window is stale: a fresh pending window opens at the new, lower price\n        vm.warp(block.timestamp + 1 hours);\n        MockToken(USDC).mint(address(burner), 1_000e6); // the first payout arrives\n\n        (uint256 spend, uint256 twapOut, uint256 spotHonest, uint256 honestPoolOut, uint32 elapsed) = burner.preview();\n        assertEq(elapsed, 14 hours, \"preview reports the burn window, not the 1 hour pending one\");\n        emit log_named_uint(\"honest spot / burn-window TWAP, bps\", (spotHonest * 10_000) / twapOut);\n\n        // Attacker: buy IMD with WETH in pair B so that the route is ~35% dearer than the market\n        (uint112 w, uint112 i,) = MockPair(PAIR_B).getReserves();\n        uint256 wethIn = (uint256(w) * 162) / 1000; // (1.162)^2 = 1.35\n        MockToken(WETH).mint(attacker, wethIn);\n        uint256 imdGot = _out(wethIn, w, i);\n        vm.startPrank(attacker);\n        MockToken(WETH).transfer(PAIR_B, wethIn);\n        MockPair(PAIR_B).swap(0, imdGot, attacker, \"\");\n        vm.stopPrank();\n\n        (uint256 spend2, , uint256 spotPumped, , ) = burner.preview();\n        assertEq(spend2, spend, \"same burn size before and after the pump\");\n        emit log_named_uint(\"pumped spot / honest spot, bps\", (spotPumped * 10_000) / spotHonest);\n        assertLt(spotPumped * 100, spotHonest * 80, \"the route is more than 20% dearer than the market\");\n\n        // Victim: the burn either reverts (fixed) or must realize close to the market price\n        (bool ok, bytes memory ret) = address(burner).call(abi.encodeCall(IMDBuyAndBurn.buyAndBurn, ()));\n        if (ok) {\n            uint256 burned = abi.decode(ret, (uint256));\n            emit log_named_uint(\"burn loss vs no pump, bps\", ((honestPoolOut - burned) * 10_000) / honestPoolOut);\n            assertEq(burner.pendTime(), 0, \"the burn cleared the fresh pending window it ignored\");\n            // MAX_DEVIATION_BPS (3%) plus fees and impact (under 1% at this size): anything beyond 5% is the defect\n            assertGe(burned * 100, honestPoolOut * 95, \"a burn must not run more than ~3% off the market price\");\n        }\n        assertEq(spend, 1_000e6, \"the burn is sized by MAX_PER_BURN, not by the pools' depth\");\n    }\n}","reproduction":"Mock pairs with Uniswap v2 K and fee rules (test/scratch/StaleWindowPump.t.sol, in the proof). Pair A at Base reserves (179.7 WETH / 438,092 USDC); pair B at the Base price with 414 WETH / 165,010 FP. Deploy (burn window starts, no USDC). +7h: a seller dumps FP so it is 50% cheaper and it stays there. +13h: poke() opens a pending window at the new price. +14h: 1,000 USDC arrives. preview() reports the 14-hour burn window with spot/TWAP = 13329 bps (33% of slack). Attacker buys FP with 16.2% of pair B's WETH: the route is now 26% dearer than the market (pumped spot / honest spot = 7409 bps). Expected: buyAndBurn() reverts with PriceOffTwap (the 1-hour pending window would reject it), or at worst realizes within about 3% plus fees of the market. Actual: buyAndBurn() succeeds, burns 25.9% fewer FP than without the pump (2589 bps loss on $1,000), and clears the pending window. Run: forge test --match-path test/scratch/StaleWindowPump.t.sol -vv.","severity":"medium","snippet":"        if (!_ready(q) && pendTime != 0) {","title":"Burn uses the stale burn window over a mature pending one, and the one-sided check lets a pump far beyond 3% through"},{"citation":"resolved","description":"poke() may be called by anyone as soon as the burn window is older than STALE_AFTER, even when the contract holds no USDC, and the pending window it opens cannot be replaced for another STALE_AFTER (6 hours). The pending TWAP then anchors to the price at the moment of the poke. If USDC arrives later while the market has risen more than MAX_DEVIATION_BPS since both anchors, both windows fail the price check and nobody can open a fresh one until the pending window is 6 hours old; the honest path (poke at the moment the burn fails, burn 30 minutes later) is closed. The README states poke() cannot delay a burn; that holds for a burn available at poke time but not for one that becomes available afterwards. Cost to the griefer: one poke transaction per 6 hours. No funds are lost, the burn only waits (at most about STALE_AFTER + MIN_WINDOW). Possible fix without reopening the pump-poke-unwind grief: allow a pending window to be replaced once it is at least MIN_WINDOW old and a burn against it would currently fail the price check (a griefer who wants to keep resetting it must then hold the price pumped across every block of a 30-minute span, which is what the TWAP is meant to make expensive); or keep a second pending slot so an honest poke is always possible once the first is MIN_WINDOW old.","line":147,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs at Base reserves (test/scratch/PendingLock.t.sol, test_EarlyPokeLocksThePendingSlot). Deploy at t0 with no USDC. t0+6h+1s: griefer calls poke() (pendTime = t0+6h+1). t0+7h and t0+7h30: two genuine buys of 5% of pair B's WETH each (FP about 21% dearer, then flat). t0+8h: 1,000 USDC arrives. buyAndBurn() reverts (PriceOffTwap against both windows); poke() reverts PendingWindow(7199). At pend+5h buyAndBurn() still reverts and poke() reverts PendingWindow(18000). Only at pend+6h+1s does poke() succeed, and the burn runs 30 minutes later: 4 hours after the payout. Expected (same scenario without the early poke, test_WithoutEarlyPokeTheBurnRunsIn30Minutes): poke() at t0+8h, burn at t0+8h30.","severity":"low","snippet":"        if (pendTime != 0 && pendElapsed <= STALE_AFTER) revert PendingWindow(pendElapsed);","title":"A pre-emptive poke() locks the pending slot for 6 hours and can delay a later burn by hours instead of 30 minutes"},{"citation":"resolved","description":"The comment above says a capped burn leaves at least THRESHOLD behind. The condition uses `balance > THRESHOLD`, so when the balance is exactly THRESHOLD and the depth cap is below it (USDC/WETH reserve under $2,000, or the WETH/FP pair's WETH side worth under $2,000 at the DEPTH_BPS = 25 bound), the burn spends the cap and leaves balance - cap < THRESHOLD, which cannot be burned until more USDC arrives. Also, for a balance in (THRESHOLD, THRESHOLD + cap) the rule can shrink a burn to as little as 1 unit of USDC while still resetting the burn window. Only reachable with very thin pools; no loss, the dust waits for the next deposit. Fix: use `balance >= THRESHOLD` and reject a zero-size burn, or skip the burn when the cap is below THRESHOLD.","line":209,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs, USDC/WETH reserve 1,500 USDC (depth cap $3.75), contract balance exactly 5,000,000 (THRESHOLD). After 31 minutes preview() returns usdcIn = 3,750,000; buyAndBurn() spends it and leaves 1,250,000 USDC. 31 minutes later buyAndBurn() reverts BelowThreshold(1250000). Expected per the comment at line 208: the burn leaves at least THRESHOLD, or does not run. test/scratch/PendingLock.t.sol, test_BalanceEqualToThresholdLeavesDust.","severity":"info","snippet":"        if (q.usdcIn < balance && balance - q.usdcIn < THRESHOLD && balance > THRESHOLD) q.usdcIn = balance - THRESHOLD;","title":"Remainder rule skips balance == THRESHOLD, so a capped burn can leave unburnable dust in a thin pool"}],"hash":"3b82cd59cd6a947e3b5570cb7eda2e3707349d145cd488fc7e037e2d0390015d","nodeId":"7380a82d-7585-428b-aea0-a79013d70c3f","outcome":"completed","summary":"The review is complete and the findings file is written. No tracked file was changed; only test/scratch/ and .imd-findings.json were added.\n\n**Verification of the dca64686 fixes at commit e70a156**\n\n- **Deployed code matches.** The runtime bytecode at 0xcf94…4120 on Base equals this commit's build except for the two immutable flags. On chain, the burn window started at deployment, no pending window is open, and no USDC has arrived yet.\n- **All 18 existing tests pass**, 14 on a Base fork and 4 with mock pairs, including the two proofs the re-audit supplied.\n- **Fix 1 (pending window)** is correct as described: poke() only opens a pending window when the burn window is older than 6 hours, cannot replace it for another 6 hours, never touches the burn window, and a burn clears it. Same-block pump-poke-unwind cannot taint the pending anchor because the pair's cumulative only moves across blocks.\n- **Fix 2 (remainder)** cannot underflow and cannot produce a zero-size burn in a live pool. preview() has no reverting path with non-empty pairs.\n- **Fix 3 (DEPTH_BPS = 25)** is sound: the sandwich gain per unit of pump is at most 2V/R, which is below the 0.6% round-trip fee for any pump size, on either pair and when split across both.\n\n**Findings written to .imd-findings.json**\n\n1. **Medium, line 106.** buyAndBurn() prefers the burn window whenever it passes, and the tolerance check only rejects \"dearer than TWAP\". After IMD falls inside a long burn window the TWAP sits far above the market, so an attacker can pump the route well past 3% and the burn still runs, even when a fresh pending window exists that would have rejected it. The proof in test/scratch/StaleWindowPump.t.sol shows a burn executing 26% below market and clearing the ignored pending window. It fails now and passes with a one-line change that uses the pending window once it is 30 minutes old. A non-LP attacker still loses money on this, but a dominant LP of the WETH/FP pair profits, and the README's 3% guarantee does not hold.\n2. **Low, line 147.** A poke() at the first stale second, while the contract is empty, locks the pending slot for 6 hours. A payout that arrives during a later uptrend then waits about 4 hours in the reproduction, against 30 minutes on the honest path.\n3. **Info, line 209.** The remainder rule uses a strict comparison, so a balance of exactly $5 against a cap below $5 leaves dust that cannot be burned until the next deposit. Thin pools only.\n\n**Not covered.** I could not enumerate who holds the WETH/FP pair's LP tokens, which decides whether the medium finding is profitable for anyone rather than only a paid grief. No Slither run, and no long fuzzing.","treeHash":null,"usage":{"cachedInputTokens":1140751,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":59958,"runtime":"claude","turns":26,"wallClockMs":846776}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a7ac9f7a7687c25e","findings":[{"citation":"resolved","description":"buyAndBurn() always tries the burn window first and only looks at the pending window when the burn window is not ready. The burn window starts at the last burn (or deployment) and has no upper bound on its age, so its TWAP can be dominated by a price that no longer exists. The tolerance check is spot >= 0.97 * TWAP, where TWAP is the window average, not the price just before the burn. When IMD has recently fallen after a long idle period (no USDC arrived, so no burn reset the window), the window average is far above the current price and the check accepts an atomic pump of the whole gap plus 3%. A pending window opened after the drop (poke() is allowed because the burn window is older than STALE_AFTER) would reject the pump, but it is only consulted when the burn window fails. This contradicts the README's statement that an attacker can move the route price by at most 3% before a burn and that a burn realizes at most about 4% less IMD. The pumper mostly loses money on today's pools (see the separate info finding on the depth cap and LP share for when it does not), so the direct effect is a worse execution of up to half the burn rather than a profitable theft. Minimal fix preserving the poke-cannot-delay property: when a pending window exists and is at least MIN_WINDOW old, require it to pass the tolerance as well (if (pendTime != 0) { p = _quote(..pending..); if (p.elapsed >= MIN_WINDOW && !_withinTolerance(p)) revert PriceOffTwap(...); }). A griefer cannot abuse that because the pending window needs 30 minutes of held manipulation to fail, and poke() cannot replace it for 6 hours. Alternatively bound the age of the burn window used for the check (for example, if the burn window is older than STALE_AFTER and a ready pending window exists, use the pending window's verdict).","line":106,"path":"src/IMDBuyAndBurn.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// Finding: the burn window has no maximum age and is always consulted first. After a long idle period, a recent price\n// drop leaves the window's average far above the current price, so an atomic pump much larger than MAX_DEVIATION_BPS\n// still passes the check, and a ready pending window that would reject it is never consulted. The pump also raises\n// the depth cap, so the burn spends more USDC at the worse price. Mock pairs with the Uniswap v2 K, fee and\n// cumulative-price rules, no fork. The test fails on this code (the burn runs 33% below the price one block earlier)\n// and passes once a burn must also satisfy a ready pending window (or the burn window's age is bounded).\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IMDBuyAndBurn} from \"src/IMDBuyAndBurn.sol\";\n\ncontract MockToken {\n    mapping(address => uint256) public balanceOf;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function transfer(address to, uint256 a) external returns (bool) {\n        balanceOf[msg.sender] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\ncontract MockPair {\n    address public token0;\n    address public token1;\n    uint112 r0;\n    uint112 r1;\n    uint32 last;\n    uint256 public price0CumulativeLast;\n    uint256 public price1CumulativeLast;\n\n    function init(address t0, address t1) external {\n        token0 = t0;\n        token1 = t1;\n        _update();\n    }\n    function getReserves() external view returns (uint112, uint112, uint32) { return (r0, r1, last); }\n    function _update() internal {\n        uint32 now32 = uint32(block.timestamp);\n        unchecked {\n            uint32 dt = now32 - last;\n            if (dt > 0 && r0 != 0 && r1 != 0) {\n                price0CumulativeLast += ((uint256(r1) << 112) / r0) * dt;\n                price1CumulativeLast += ((uint256(r0) << 112) / r1) * dt;\n            }\n        }\n        r0 = uint112(MockToken(token0).balanceOf(address(this)));\n        r1 = uint112(MockToken(token1).balanceOf(address(this)));\n        last = now32;\n    }\n    function swap(uint256 out0, uint256 out1, address to, bytes calldata) external {\n        require(out0 > 0 || out1 > 0, \"INSUFFICIENT_OUTPUT_AMOUNT\");\n        (uint256 o0, uint256 o1) = (r0, r1);\n        if (out0 > 0) MockToken(token0).transfer(to, out0);\n        if (out1 > 0) MockToken(token1).transfer(to, out1);\n        uint256 b0 = MockToken(token0).balanceOf(address(this));\n        uint256 b1 = MockToken(token1).balanceOf(address(this));\n        uint256 in0 = b0 > o0 - out0 ? b0 - (o0 - out0) : 0;\n        uint256 in1 = b1 > o1 - out1 ? b1 - (o1 - out1) : 0;\n        require(in0 > 0 || in1 > 0, \"INSUFFICIENT_INPUT\");\n        require((b0 * 1000 - in0 * 3) * (b1 * 1000 - in1 * 3) >= o0 * o1 * 1e6, \"K\");\n        _update();\n    }\n}\n\ncontract StaleWindowTest is Test {\n    address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;\n    address constant WETH = 0x4200000000000000000000000000000000000006;\n    address constant IMD = 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105;\n    address constant PAIR_A = 0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C; // WETH/USDC, WETH is token0 as on Base\n    address constant PAIR_B = 0xDE66c35e01ED8e619Bf092352338ef94f2327337; // WETH/IMD, WETH is token0 as on Base\n\n    IMDBuyAndBurn burner;\n    address seller = makeAddr(\"seller\");\n    address attacker = makeAddr(\"attacker\");\n\n    function _out(uint256 x, uint256 rIn, uint256 rOut) internal pure returns (uint256) {\n        uint256 f = x * 997;\n        return (f * rOut) / (rIn * 1000 + f);\n    }\n\n    function setUp() public {\n        vm.warp(1_760_000_000);\n        address tok = address(new MockToken());\n        vm.etch(USDC, tok.code);\n        vm.etch(WETH, tok.code);\n        vm.etch(IMD, tok.code);\n        address pair = address(new MockPair());\n        vm.etch(PAIR_A, pair.code);\n        vm.etch(PAIR_B, pair.code);\n        // Both pairs at their Base reserves (block 52644997)\n        MockToken(WETH).mint(PAIR_A, 179.7e18);\n        MockToken(USDC).mint(PAIR_A, 438_092e6);\n        MockPair(PAIR_A).init(WETH, USDC);\n        MockToken(WETH).mint(PAIR_B, 165.6e18);\n        MockToken(IMD).mint(PAIR_B, 66_004e18);\n        MockPair(PAIR_B).init(WETH, IMD);\n        burner = new IMDBuyAndBurn();\n    }\n\n    function _sellImd(address who, uint256 imdIn) internal {\n        (uint112 wB, uint112 iB,) = MockPair(PAIR_B).getReserves();\n        MockToken(IMD).mint(who, imdIn);\n        vm.startPrank(who);\n        MockToken(IMD).transfer(PAIR_B, imdIn);\n        MockPair(PAIR_B).swap(_out(imdIn, iB, wB), 0, who, \"\");\n        vm.stopPrank();\n    }\n\n    function _buyImd(address who, uint256 wethIn) internal {\n        (uint112 wB, uint112 iB,) = MockPair(PAIR_B).getReserves();\n        MockToken(WETH).mint(who, wethIn);\n        uint256 got = _out(wethIn, wB, iB);\n        vm.startPrank(who);\n        MockToken(WETH).transfer(PAIR_B, wethIn);\n        MockPair(PAIR_B).swap(0, got, who, \"\");\n        vm.stopPrank();\n    }\n\n    function test_StaleBurnWindowAcceptsA50PercentPump() public {\n        // Three idle days (no USDC arrived, so no burn and the burn window keeps growing). One hour before the burn\n        // IMD falls ~36% (a holder sells 25% of the IMD reserve) and stays there; a pending window is opened right\n        // after the drop, as poke() allows once the burn window is older than STALE_AFTER.\n        vm.warp(block.timestamp + 3 days - 1 hours);\n        _sellImd(seller, 66_004e18 / 4);\n        burner.poke();\n        vm.warp(block.timestamp + 1 hours);\n        MockToken(USDC).mint(address(burner), 1_000e6);\n\n        (uint256 usdcIn0, uint256 twapOut, uint256 spotOut, uint256 honestOut,) = burner.preview();\n        emit log_named_uint(\"burn window: spot/twap bps before the pump\", (spotOut * 10_000) / twapOut);\n        uint256 honestPerUsdc = (honestOut * 1e6) / usdcIn0;\n\n        // Attacker, in the burn's block: buy 22.5% of pair B's WETH reserve so IMD is ~50% dearer than one block ago\n        (uint112 wB,,) = MockPair(PAIR_B).getReserves();\n        _buyImd(attacker, (uint256(wB) * 225) / 1000);\n        (, twapOut, spotOut,,) = burner.preview();\n        emit log_named_uint(\"burn window: spot/twap bps after the pump\", (spotOut * 10_000) / twapOut);\n\n        // Expected: the burn reverts (PriceOffTwap) or executes near the price of one block earlier.\n        // Actual on this code: it passes the burn-window check and buys ~33% fewer IMD per USDC, spending more USDC\n        // than a block earlier because the pump raised the depth cap.\n        try burner.buyAndBurn() returns (uint256 burned) {\n            uint256 usdcSpent = burner.totalUsdcSpent();\n            uint256 perUsdc = (burned * 1e6) / usdcSpent;\n            emit log_named_decimal_uint(\"usdcIn one block earlier\", usdcIn0, 6);\n            emit log_named_decimal_uint(\"usdcIn spent\", usdcSpent, 6);\n            emit log_named_uint(\"IMD per USDC lost vs one block earlier, bps\", ((honestPerUsdc - perUsdc) * 10_000) / honestPerUsdc);\n            assertGe(perUsdc, (honestPerUsdc * 90) / 100, \"burn executed >10% below the price one block earlier\");\n        } catch {\n            emit log_string(\"burn reverted: the pump was rejected\");\n        }\n    }\n}","reproduction":"Mock pairs at the Base reserves (pair A 179.7 WETH / 438,092 USDC; pair B 165.6 WETH / 66,004 IMD). Deploy; no USDC for 3 days. At day 3 minus 1 hour a holder sells 16,501 IMD (25% of the reserve) into pair B: IMD falls to 0.64x. poke() right after (burn window is 3 days old, so allowed). One hour later 1,000 USDC arrives. preview() on the burn window: spot/twap = 15494 bps (spot gives 55% more IMD than the 3-day average), usdcIn = 807.92. Attacker, in the burn's block, buys 22.5% of pair B's WETH reserve (29.8 WETH): IMD is now ~50% dearer than one block earlier. Expected: PriceOffTwap, since the price moved 50% in one block and the 1-hour pending window would reject it (its TWAP is the pre-pump low price). Actual: the burn-window check reads spot/twap = 10331 bps and passes; buyAndBurn() spends 989.70 USDC (the pump raised the depth cap) and burns 166.67 IMD, which is 0.1684 IMD per USDC against 0.2527 one block earlier: 33.35% worse. Proof: test/scratch/StaleWindow.t.sol fails on this code and passes once a ready pending window must also pass.","severity":"low","snippet":"        if (!_ready(q) && pendTime != 0) {","title":"Burn window has no maximum age: after a recent price drop an atomic pump far beyond MAX_DEVIATION_BPS passes, and a ready pending window that would reject it is never consulted"},{"citation":"resolved","description":"depthA and depthB are DEPTH_BPS of the reserves as they stand in the burn's block. A front-run that buys WETH from pair A (or IMD from pair B) increases that pair's input-side reserve by the pump fraction p, so the victim's size grows by (1+p) at the same time its price gets worse. The README's no-profit argument (0.25% depth below the 0.3% round-trip fee) becomes net = p * R * (0.005 * (1+p) - 0.006), which is positive for p > 20% of the reserve when the depth cap binds (USDC reserve under ~$400k). Such a pump needs the tolerance slack described in the stale-window finding, since 3% allows only p = 1.5%. On Base today MAX_PER_BURN ($1,000) binds before depth does, so the growth is capped there and the sandwich still loses. The fix is to size the burn from reserves that the attacker cannot inflate in the same block, for example the smaller of the current depth and the depth implied one block earlier is not available in Uniswap v2; a simpler option is to keep MAX_PER_BURN as the only cap when the pool is deep and accept the (1+p) factor, documenting it alongside the stale-window fix which removes the slack that makes p > 1.5% reachable.","line":204,"path":"src/IMDBuyAndBurn.sol","reproduction":"Same scenario as test/scratch/StaleWindow.t.sol: before the pump preview() reports usdcIn = 807.919756 (depthB = 0.25% of 132.56 WETH valued at 2,438 USDC/WETH); after the attacker adds 29.8 WETH to pair B the burn spends 989.701702 USDC (totalUsdcSpent after the call). Expected: the burn is no larger than it was one block earlier. Actual: 22.5% larger, at a 33% worse price.","severity":"info","snippet":"        uint256 depthB = rWethA == 0 ? 0 : (((rWethB * DEPTH_BPS) / 10_000) * rUsdc) / rWethA;","title":"Depth cap is computed from the current (possibly pumped) reserves, so a sandwich that inflates the input-side reserve also enlarges the burn it sandwiches"},{"citation":"resolved","description":"The argument compares the attacker's gain (about 2 * pump * burn / reserve) with the 0.6% round-trip fee on the pump. Those fees are paid to the pair's LPs, so an attacker who holds a fraction f of the LP supply gets f of them back. Net = pump * (2 * burn / reserve - 0.006 * (1 - f)). With today's Base reserves (438,092 USDC) and MAX_PER_BURN binding (burn = $1,000) the sandwich pays for f > 24%; when the depth cap binds (reserve under $400k) it pays for f > 1/6. The gain is small: at f = 50% about $10 per burn at the 3% tolerance, more only with the stale-window slack. Flash-minted LP does not help (the recoverable fee and the gain both scale with the enlarged pool, and the net stays at 2 * burn / reserve - 0.006 per unit of pump), so only a standing LP position matters. The distribution of the pair's LP tokens (total supply 410,292,016,348,992 wei) was not checked. Suggested handling: document it as a trust assumption next to the existing sandwich paragraph in the README, or lower DEPTH_BPS / MAX_PER_BURN so that 2 * burn / reserve stays below 0.006 * (1 - f) for the largest plausible f.","line":28,"path":"src/IMDBuyAndBurn.sol","reproduction":"From the repository's own test/SandwichWithinTolerance.t.sol (mock pair A with 200,000 USDC, DEPTH_BPS = 25): the attacker pumps 3,070.77 USDC, pays about 18.4 USDC in pair fees over the two legs and ends at -2.976 USDC. An attacker holding 16.2% or more of that pair's LP tokens receives at least 2.98 USDC of those fees back and the same sandwich is profitable. Expected per the README: the sandwich never pays its author. Actual: it pays an LP of the pair above that share.","severity":"info","snippet":"/// USDC/WETH pair, so a sandwich that stays inside the tolerance costs its author more in fees than it takes.","title":"The 'sandwich inside the tolerance does not pay' argument assumes the sandwicher holds no LP position in the USDC/WETH pair"},{"citation":"resolved","description":"The rule only applies when balance > THRESHOLD. When the depth cap is below THRESHOLD (USDC/WETH reserve under about $20,000, or the WETH/IMD pair's WETH side worth less than that) and the balance is exactly THRESHOLD, the burn spends the cap and leaves balance - cap < THRESHOLD, which BelowThreshold then rejects until more USDC arrives; the README says a capped burn always leaves at least $5. When the balance is THRESHOLD + 1 wei, the rule sets usdcIn = 1 (one micro-USDC): the burn succeeds, resets obsTime and clears the pending window for a 1-wei trade, and the remaining $5 waits another MIN_WINDOW. No underflow is reachable: balance - q.usdcIn is guarded by q.usdcIn < balance and balance - THRESHOLD by balance > THRESHOLD, and usdcIn = 0 only when the cap is 0 (empty pool), where the pair's INSUFFICIENT_OUTPUT_AMOUNT check reverts the burn. Not reachable at today's reserves ($438k and about $400k). Fix if wanted: when the cap is below THRESHOLD let the burn spend the whole balance (if cap < THRESHOLD, q.usdcIn = balance <= MAX_PER_BURN ? balance : ...), or require q.usdcIn >= some minimum such as THRESHOLD / 10.","line":209,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs (same mocks as test/PokeGrief.t.sol): pair A 1,000 USDC / 0.41 WETH, pair B 0.41 WETH / 160 IMD, so depthA = 2.5 USDC. (a) Mint 5,000,000 wei USDC (exactly THRESHOLD) to the contract, warp 31 minutes, buyAndBurn(): usdcIn = 2.500000, 2.500000 USDC is left; 31 minutes later buyAndBurn() reverts BelowThreshold(2500000). Expected per the README: at least 5 USDC left or everything burned. (b) Mint 5,000,001 wei instead: preview() returns usdcIn = 1, buyAndBurn() burns 159,041,439,452 wei of IMD for 1 wei of USDC and sets obsTime to the current block; the 5 USDC left must wait another 30 minutes. Scratch test: test/scratch/Edge.t.sol (test_DustWhenBalanceEqualsThreshold, test_OneWeiBurn).","severity":"info","snippet":"        if (q.usdcIn < balance && balance - q.usdcIn < THRESHOLD && balance > THRESHOLD) q.usdcIn = balance - THRESHOLD;","title":"Remainder rule degenerates in a thin pool: balance exactly THRESHOLD leaves sub-threshold dust, balance THRESHOLD+1 wei burns one micro-USDC and resets the window"}],"hash":"d719f416dc9d2abdb5f93b381fb271572b070307761340acc4c8d871908fa2fe","nodeId":"7b31ef20-9878-4f62-b32e-c96d420186b0","outcome":"completed","summary":"The review is complete. The findings file is written, the source tree is untouched, and the proof test fails on the current code and passes under the candidate fix.\n\n**Result: no critical, high or medium defects. One low and three info findings, all in `.imd-findings.json`.**\n\nWhat I verified first:\n- The runtime bytecode at the Base address matches the local build of this commit except the two immutable bools, so the reviewed code is what is deployed. Both pairs are real Uniswap v2 pairs (0.3% fee), WETH is token0 of both, Fren Pet exempts 0x…dEaD from fees and its max transaction is far above any burn.\n- All 14 fork tests and the 4 mock-pair tests pass.\n- The three dca64686 fixes hold: poke() only opens a pending window when the burn window is older than 6 hours, cannot replace it for 6 hours, and never touches the burn window; a burn uses the burn window first and clears the pending one; the remainder rule has no underflow and no zero-size path, and preview() never reverts in any state I could construct (empty contract, pending open, empty pools).\n\nFindings:\n1. **Low, line 106.** The burn window has no maximum age and is always consulted first. After an idle period and a recent price drop, its average sits far above the current price, so an atomic pump of 50% passes the 3% check, and a ready pending window that would reject it is never consulted. In the mock scenario the burn executes 33% worse per USDC than one block earlier. Proof in `test/scratch/StaleWindow.t.sol`. A fix that also requires a ready pending window to pass makes it pass without breaking the existing suites.\n2. **Info, line 204.** The depth cap is read from the pumped reserves, so a front-run enlarges the burn it sandwiches. In the same scenario the burn grows from $808 to $990.\n3. **Info, line 28.** The no-profit sandwich argument ignores that fees go to LPs. A holder of more than roughly 24% of the USDC/WETH LP supply today profits by a few dollars per burn. LP holder distribution was not checked.\n4. **Info, line 209.** In a pool with under about $20k on the USDC side, a balance of exactly $5 leaves sub-threshold dust, and $5 plus 1 wei burns one micro-USDC and resets the window. Not reachable at today's reserves.\n\nLeads I rejected: pump-poke-unwind delay (fixed), same-block manipulation of the pending window's reference cumulative (the TWAP excludes anything before pendTime), the pending-window timing grief (a $66 dump held 15 minutes still left spot/twap at 9715 bps, so the burn passed), reentrancy and value leaks (USDC and IMD leave only through the two swaps to the fixed pair and 0x…dEaD, no approvals, no ETH path), and Q112 overflow (bounded by MAX_PER_BURN). Static analyzers did not run; Slither is not available here.","treeHash":null,"usage":{"cachedInputTokens":2157802,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":71260,"runtime":"claude","turns":35,"wallClockMs":1013977}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"cbc83f8151b8340d","findings":[{"citation":"resolved","description":"The re-audit fix for dca64686 finding 3 (DEPTH_BPS = 25) relies on the argument that one burn spends at most 0.25% of the USDC/WETH reserve while a pump sized for the 3% tolerance pays 0.6% round-trip fees, so the break-even burn is 0.3% of the reserve (comment on line 27, README 'Trust assumptions'). That argument compares the burn against the pair's total reserves but the fees only against liquidity the attacker does not own. Uniswap v2 charges nothing to add or remove liquidity, so an attacker can, in one transaction, add (flash-loaned) liquidity to the pair, pump WETH to just inside MAX_DEVIATION_BPS, call buyAndBurn() themselves, unwind, and remove the liquidity. Two effects break the bound: (a) the attacker gets their LP share of the pump fees back, so the fee they really pay is 0.6% * (others' liquidity), and (b) the larger total reserve lifts the DEPTH_BPS cap until MAX_PER_BURN = $1,000 binds. Attacker profit per burn is about P * (2 * V - 0.006 * R0) where V is the burn, R0 the other LPs' USDC reserve and P the pump fraction: positive whenever R0 < MAX_PER_BURN / 0.003 ~ $333k, independent of how much the attacker adds. Measured with the mock pairs (Uniswap v2 K, fee, mint and burn rules): R0 = $200k and $400k added -> attacker +$12.65 per burn (the same sandwich without the LP position: -$2.98); R0 = $300k -> +$3.58; R0 = $438k (Base today) -> -$8.98. The burn loses 2.98% of its IMD each time, so the README statement 'Such a sandwich does not pay its author' holds only while the outside USDC liquidity of 0x88A4...bB9C stays above ~$333k (today $438k, so not exploitable at this moment). The 0.6% round trip is also described as a '0.3% round-trip fee' on line 27 and in the README. Fix options (a design decision): bound a burn by DEPTH_BPS of the reserve observed at the start of the window rather than the current reserve (liquidity added atomically then does not raise the cap, though it still refunds fees), lower MAX_DEVIATION_BPS so the extractable share is small, or document the residual: an attacker can take up to MAX_DEVIATION_BPS of each burn at a small profit once the pair's outside liquidity falls under ~$333k.","line":203,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs with Uniswap v2 rules (test below, no fork). Pair A: other LPs hold 82.034 WETH / 200,000 USDC; pair B: 165.58 WETH / 66,004 IMD; contract holds 1,000 USDC; 31 minutes pass. Attacker: mint() 164.068 WETH + 400,000 USDC of liquidity into pair A (share 2/3); preview() now reports usdcIn = 1,000e6 (MAX_PER_BURN binds, DEPTH_BPS cap is 0.25% * 600k = $1,500); buy WETH with the largest USDC amount that keeps spotOut >= 0.97 * twapOut (bisection on the contract's own constants); buyAndBurn() succeeds and buys 2.98% less IMD per USDC than without the pump; sell the WETH back; burn() the LP shares. Expected (README, line 27): attacker's USDC + WETH value does not increase. Actual: +12.654516 USDC (WETH valued at the pool price). Same steps without the LP position: -2.976002 USDC. With other LPs at 300,000 USDC: +3.58; at 438,092 USDC (Base today): -8.98.","severity":"low","snippet":"        uint256 depthA = (rUsdc * DEPTH_BPS) / 10_000;\n        uint256 depthB = rWethA == 0 ? 0 : (((rWethB * DEPTH_BPS) / 10_000) * rUsdc) / rWethA;\n        uint256 depth = depthA < depthB ? depthA : depthB;\n        if (depth < cap) cap = depth;","title":"DEPTH_BPS does not make a within-tolerance sandwich unprofitable for an attacker who also supplies liquidity to the USDC/WETH pair"},{"citation":"resolved","description":"_withinTolerance only requires spotOut >= 0.97 * twapOut, where twapOut is the average over the burn window, and that window starts at the last burn with no upper bound on its age (test_BurnsAfterLongIdle relies on a 3-day window). When IMD fell during the window, the TWAP price sits above the spot price and the check allows IMD to be pumped up to TWAP/spot * 1.03 before a burn without reverting, not 3%. buyAndBurn() and preview() (lines 105-109 and 173-177) take the burn window whenever it is ready and only fall back to the pending window when the burn window fails, so after a keeper calls poke() and waits MIN_WINDOW, the fresh window is ignored while the stale one passes: there is no way for an honest party to make the burn use the tighter TWAP, and the laxer of the two windows always wins. Measured with mock pairs at the Base reserves: contract idle 3 days, a 20,000 IMD sell into the WETH/IMD pair after day 2 (IMD -44%), preview() shows spotOut/twapOut = 1.3769. poke() + 31 minutes: preview() still reports elapsed = 261,060 s (the old window). An attacker buys 83,204 USDC of WETH in pair A, calls buyAndBurn(), sells back: the burn gets 29.33% less IMD per USDC than without the pump (about $293 of a $1,000 burn), and the attacker pays $127 in fees and impact. The attacker does not profit at today's reserves (and the pump also raises depthB so the capped burn grows from $775 to $1,000), so this is griefing at roughly 2.3:1, but it contradicts the README's 'An attacker can move the route price by up to 3% before a burn without making it revert' and the line 47 comment, and combined with the LP-share issue it would scale the attacker's profit with the widened tolerance once outside liquidity is under ~$333k. Fix (preserves the design): when the burn window is older than STALE_AFTER and a pending window is ready, use the pending window; or when both windows are ready, use the one with the smaller twapOut (the stricter check); optionally also start a pending window on any burn attempt that finds the burn window older than STALE_AFTER.","line":105,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs with Uniswap v2 rules (test below, no fork). Pair A 179.7 WETH / 438,092 USDC, pair B 165.6 WETH / 66,004 IMD (Base reserves), contract holds 1,000 USDC. Warp 2 days; a seller sends 20,000e18 IMD to pair B and swaps out the WETH; warp 1 day. preview(): elapsed = 259,200, spotOut/twapOut = 13,769 bps. poke(); warp 31 minutes; preview(): elapsed = 261,060 (burn window still used, pending ignored). Attacker buys WETH with 83,204.48 USDC (largest amount keeping spotOut >= 0.97 * twapOut), calls buyAndBurn() (spends 1,000 USDC), sells the WETH back. Expected (README, line 47): the burn's IMD per USDC is at most ~3% (plus the pump's fee and impact, under 4%) below the no-pump rate. Actual: 2,933 bps less; attacker net -127.11 USDC.","severity":"low","snippet":"        Quote memory q = _quote(balance, obsUsdcWeth, obsWethImd, obsTime);\n        if (!_ready(q) && pendTime != 0) {\n            Quote memory p = _quote(balance, pendUsdcWeth, pendWethImd, pendTime);\n            if (_ready(p)) q = p;\n        }","title":"The burn window has no maximum age and is preferred over a fresh pending window, so after a price drop a sandwich can take far more than MAX_DEVIATION_BPS"},{"citation":"resolved","description":"The fix for dca64686 finding 2 promises that a capped burn leaves exactly THRESHOLD behind (README: 'A burn that is capped leaves at least $5 behind, so the remainder can always be burned later'). The rule excludes balance == THRESHOLD (the `balance > THRESHOLD` clause, needed to avoid a zero-size burn), so when the depth cap is below THRESHOLD (a WETH/IMD pool whose WETH side is worth under $2,000, or a USDC/WETH pool under $2,000 of USDC) and the balance is exactly 5 USDC, the burn spends the cap and leaves balance - cap < THRESHOLD, which cannot be burned until more USDC arrives. Behaviourally this is the better of the two options (the alternative is no burn at all), the state needs an extremely thin pool, and the dust is burnable as soon as the next payout lands, so this is informational: the README and the line 208 comment should say 'at least THRESHOLD or, when the cap itself is under THRESHOLD and the balance is THRESHOLD, the cap'. No underflow or zero-size burn was found: with balance > THRESHOLD the rule always yields usdcIn in [1, cap); a 3,000-run fuzz over realistic reserves (ETH $100..$100k, IMD/WETH 1e-6..1e9, balance up to $10M) found no preview() revert and no remainder in (0, THRESHOLD).","line":209,"path":"src/IMDBuyAndBurn.sol","reproduction":"Mock pairs: pair A 179.7 WETH / 438,092 USDC, pair B 0.5 WETH / 200 IMD (depthB = 0.25% * 0.5 WETH * 2,438 = 3.047384 USDC). Contract holds exactly 5,000,000 USDC-wei; warp 31 minutes; preview() usdcIn = 3,047,384; buyAndBurn() spends it and leaves 1,952,616 (< THRESHOLD). Warp 31 minutes; buyAndBurn() reverts BelowThreshold(1952616). Expected per README/line 208: 5,000,000 left (or the dust burnable); actual: 1.952616 USDC unburnable until more USDC arrives.","severity":"info","snippet":"        if (q.usdcIn < balance && balance - q.usdcIn < THRESHOLD && balance > THRESHOLD) q.usdcIn = balance - THRESHOLD;","title":"Remainder rule leaves sub-THRESHOLD dust when the depth cap is below THRESHOLD and the balance is exactly THRESHOLD"}],"hash":"facc1bf2501c9b9922ff52d613d0ec17f5b1a96cf04aeb31eb9a465b739def8e","nodeId":"f5b5be9f-a944-4723-98e3-0d7a7ca99a9f","outcome":"completed","summary":"The review is complete. The findings file is written and no repository source was changed.\n\n**Verified first.** The runtime bytecode at the deployed Base address matches this commit's build byte for byte outside the two immutable slots. All 18 existing tests pass, both on the Base fork and with the mock pairs. The three dca64686 fixes work as described: poke() opens a separate pending window only when the burn window is older than six hours and cannot replace a pending window younger than that, a burn clears it, the remainder rule never underflows or produces a zero-size burn for any balance above THRESHOLD, and preview() did not revert in a 3000-run fuzz over realistic reserves. The access surface is three public functions with no owner, no roles, no receive or fallback, and USDC leaves only through the two-hop swap.\n\n**Findings written to `.imd-findings.json`** (two low, one info, proofs attached to both lows):\n\n- **Low, depth cap vs. a liquidity-providing sandwicher.** The 0.25% bound measures the burn against the pair's total reserves, but the pump's fees only go to liquidity the attacker does not own. An attacker who flash-adds liquidity gets their fee share back and lifts the cap until the $1,000 MAX_PER_BURN binds. The sandwich then pays whenever outside USDC liquidity is below about $333k. Measured: +$12.65 per burn at $200k outside liquidity, +$3.58 at $300k, −$8.98 at today's $438k. So it is not exploitable today, but the README's \"does not pay its author\" claim depends on the pool staying deep.\n\n- **Low, stale burn window after a price drop.** The tolerance is relative to a TWAP of unbounded age, and the burn window is always preferred when it passes. After three idle days with a 44% IMD drop on day two, the TWAP sat 38% above spot. A keeper's poke() plus 31 minutes changed nothing, and an $83k pump let a burn lose 29% per dollar instead of 3%. The attacker paid $127 to destroy about $293 of buying power, so this is griefing, not profit, but it contradicts the documented 3% bound. Fix suggested: prefer a ready pending window over a stale burn window, or use the stricter of two ready windows.\n\n- **Info, dust at exactly THRESHOLD.** When the depth cap is under $5 and the balance is exactly $5, the burn leaves sub-threshold dust, which the README says cannot happen. This is the better of two bad options and only arises in a near-empty pool.\n\nEach finding carries exact inputs, expected versus actual numbers, and the two proof tests fail on the current code under `forge test --match-path test/scratch/...`.","treeHash":null,"usage":{"cachedInputTokens":1192693,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":61780,"runtime":"claude","turns":27,"wallClockMs":860340}}],"verification":[]}