# Audit report

> 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.

| | |
|---|---|
| Repository | https://github.com/pepegobig/imd-buy-and-burn.git |
| Commit | `e70a15692f670bdecdb0d5d515f66e0238081980` |
| Job | `b2cad286-240a-4884-b169-187e7d838051` |
| Judged | 2026-10-15 16:35 UTC |
| Findings | 1 medium · 3 low · 2 info |

Four agents audited the code as it is at `e70a156`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: 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

`src/IMDBuyAndBurn.sol:106`

```
        if (!_ready(q) && pendTime != 0) {
```

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.

**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.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

// Finding: the price check is one-sided (only "spot dearer than TWAP" reverts) and buyAndBurn() uses the burn window
// whenever it passes, even when a fresher pending window exists. After IMD has fallen inside a long burn window, the
// TWAP sits far above the market, so an attacker can pump the route by far more than MAX_DEVIATION_BPS (here ~35%)
// and the burn still runs, at a price the fresh pending window would have rejected. Mock pairs with the real Uniswap
// v2 K and fee rules, no fork. Fails on this code; passes once the burn is rejected or bounded near the market price.

import {Test} from "forge-std/Test.sol";
import {IMDBuyAndBurn} from "src/IMDBuyAndBurn.sol";

contract MockToken {
    mapping(address => uint256) public balanceOf;
    function mint(address to, uint256 a) external { balanceOf[to] += a; }
    function transfer(address to, uint256 a) external returns (bool) {
        balanceOf[msg.sender] -= a;
        balanceOf[to] += a;
        return true;
    }
}

contract MockPair {
    address public token0;
    address public token1;
    uint112 r0;
    uint112 r1;
    uint32 last;
    uint256 public price0CumulativeLast;
    uint256 public price1CumulativeLast;

    function init(address t0, address t1) external {
        token0 = t0;
        token1 = t1;
        _update();
    }
    function getReserves() external view returns (uint112, uint112, uint32) { return (r0, r1, last); }
    function _update() internal {
        uint32 now32 = uint32(block.timestamp);
        unchecked {
            uint32 dt = now32 - last;
            if (dt > 0 && r0 != 0 && r1 != 0) {
                price0CumulativeLast += ((uint256(r1) << 112) / r0) * dt;
                price1CumulativeLast += ((uint256(r0) << 112) / r1) * dt;
            }
        }
        r0 = uint112(MockToken(token0).balanceOf(address(this)));
        r1 = uint112(MockToken(token1).balanceOf(address(this)));
        last = now32;
    }
    function swap(uint256 out0, uint256 out1, address to, bytes calldata) external {
        (uint256 o0, uint256 o1) = (r0, r1);
        if (out0 > 0) MockToken(token0).transfer(to, out0);
        if (out1 > 0) MockToken(token1).transfer(to, out1);
        uint256 b0 = MockToken(token0).balanceOf(address(this));
        uint256 b1 = MockToken(token1).balanceOf(address(this));
        uint256 in0 = b0 > o0 - out0 ? b0 - (o0 - out0) : 0;
        uint256 in1 = b1 > o1 - out1 ? b1 - (o1 - out1) : 0;
        require(in0 > 0 || in1 > 0, "INSUFFICIENT_INPUT");
        require((b0 * 1000 - in0 * 3) * (b1 * 1000 - in1 * 3) >= o0 * o1 * 1e6, "K");
        _update();
    }
}

contract StaleWindowPumpTest is Test {
    address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
    address constant WETH = 0x4200000000000000000000000000000000000006;
    address constant IMD = 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105;
    address constant PAIR_A = 0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C; // WETH/USDC, WETH is token0
    address constant PAIR_B = 0xDE66c35e01ED8e619Bf092352338ef94f2327337; // WETH/IMD, WETH is token0
    address constant DEAD = 0x000000000000000000000000000000000000dEaD;

    IMDBuyAndBurn burner;
    address seller = makeAddr("seller");
    address attacker = makeAddr("attacker");

    function _out(uint256 x, uint256 rIn, uint256 rOut) internal pure returns (uint256) {
        uint256 f = x * 997;
        return (f * rOut) / (rIn * 1000 + f);
    }

    function setUp() public {
        vm.warp(1_760_000_000);
        address tok = address(new MockToken());
        vm.etch(USDC, tok.code);
        vm.etch(WETH, tok.code);
        vm.etch(IMD, tok.code);
        address pair = address(new MockPair());
        vm.etch(PAIR_A, pair.code);
        vm.etch(PAIR_B, pair.code);
        // Pair A at its Base reserves (block 52644997); pair B at the Base price with 2.5x the depth, so that the
        // burn size stays MAX_PER_BURN before and after the moves below (the depth cap does not change the size)
        MockToken(WETH).mint(PAIR_A, 179.7e18);
        MockToken(USDC).mint(PAIR_A, 438_092e6);
        MockPair(PAIR_A).init(WETH, USDC);
        MockToken(WETH).mint(PAIR_B, 414e18);
        MockToken(IMD).mint(PAIR_B, 165_010e18);
        MockPair(PAIR_B).init(WETH, IMD);
        burner = new IMDBuyAndBurn(); // the burn window starts here, with no USDC to burn yet
    }

    /// Sells IMD into pair B so that IMD gets about `pct` percent cheaper (one genuine market move, not unwound)
    function _imdFalls(uint256 pct) internal {
        (uint112 w, uint112 i,) = MockPair(PAIR_B).getReserves();
        // price falls by pct% when the IMD reserve grows by 1/sqrt(1 - pct/100): approximate with a fixed-point sqrt
        uint256 target = (uint256(i) * 1e9) / _sqrt((100 - pct) * 1e16);
        uint256 imdIn = target - i;
        MockToken(IMD).mint(seller, imdIn);
        uint256 wethOut = _out(imdIn, i, w);
        vm.startPrank(seller);
        MockToken(IMD).transfer(PAIR_B, imdIn);
        MockPair(PAIR_B).swap(wethOut, 0, seller, "");
        vm.stopPrank();
    }

    function _sqrt(uint256 y) internal pure returns (uint256 z) {
        if (y > 3) {
            z = y;
            uint256 x = y / 2 + 1;
            while (x < z) { z = x; x = (y / x + x) / 2; }
        } else if (y != 0) z = 1;
    }

    function test_StaleBurnWindowLetsALargePumpThrough() public {
        // 7 hours flat, then IMD falls 50% and stays there for another 7 hours (14 hours of burn window in total)
        vm.warp(block.timestamp + 7 hours);
        _imdFalls(50);
        vm.warp(block.timestamp + 6 hours);
        burner.poke(); // the burn window is stale: a fresh pending window opens at the new, lower price
        vm.warp(block.timestamp + 1 hours);
        MockToken(USDC).mint(address(burner), 1_000e6); // the first payout arrives

        (uint256 spend, uint256 twapOut, uint256 spotHonest, uint256 honestPoolOut, uint32 elapsed) = burner.preview();
        assertEq(elapsed, 14 hours, "preview reports the burn window, not the 1 hour pending one");
        emit log_named_uint("honest spot / burn-window TWAP, bps", (spotHonest * 10_000) / twapOut);

        // Attacker: buy IMD with WETH in pair B so that the route is ~35% dearer than the market
        (uint112 w, uint112 i,) = MockPair(PAIR_B).getReserves();
        uint256 wethIn = (uint256(w) * 162) / 1000; // (1.162)^2 = 1.35
        MockToken(WETH).mint(attacker, wethIn);
        uint256 imdGot = _out(wethIn, w, i);
        vm.startPrank(attacker);
        MockToken(WETH).transfer(PAIR_B, wethIn);
        MockPair(PAIR_B).swap(0, imdGot, attacker, "");
        vm.stopPrank();

        (uint256 spend2, , uint256 spotPumped, , ) = burner.preview();
        assertEq(spend2, spend, "same burn size before and after the pump");
        emit log_named_uint("pumped spot / honest spot, bps", (spotPumped * 10_000) / spotHonest);
        assertLt(spotPumped * 100, spotHonest * 80, "the route is more than 20% dearer than the market");

        // Victim: the burn either reverts (fixed) or must realize close to the market price
        (bool ok, bytes memory ret) = address(burner).call(abi.encodeCall(IMDBuyAndBurn.buyAndBurn, ()));
        if (ok) {
            uint256 burned = abi.decode(ret, (uint256));
            emit log_named_uint("burn loss vs no pump, bps", ((honestPoolOut - burned) * 10_000) / honestPoolOut);
            assertEq(burner.pendTime(), 0, "the burn cleared the fresh pending window it ignored");
            // MAX_DEVIATION_BPS (3%) plus fees and impact (under 1% at this size): anything beyond 5% is the defect
            assertGe(burned * 100, honestPoolOut * 95, "a burn must not run more than ~3% off the market price");
        }
        assertEq(spend, 1_000e6, "the burn is sized by MAX_PER_BURN, not by the pools' depth");
    }
}
```

### 2. Low: 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

`src/IMDBuyAndBurn.sol:46`

```
    uint256 public constant DEPTH_BPS = 25; // one burn spends at most 0.25% of either pair's input-side reserve
```

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.

**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).

### 3. Low: 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

`src/IMDBuyAndBurn.sol:220`

```
            q.twapOut = _mulQ112(_mulQ112(q.usdcIn, avgA), avgB);
```

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.

**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.

### 4. Low: 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

`src/IMDBuyAndBurn.sol:147`

```
        if (pendTime != 0 && pendElapsed <= STALE_AFTER) revert PendingWindow(pendElapsed);
```

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.

**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.

### 5. Info: 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

`src/IMDBuyAndBurn.sol:209`

```
        if (q.usdcIn < balance && balance - q.usdcIn < THRESHOLD && balance > THRESHOLD) q.usdcIn = balance - THRESHOLD;
```

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.

**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.

### 6. Info: 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

`src/IMDBuyAndBurn.sol:203`

```
        uint256 depthA = (rUsdc * DEPTH_BPS) / 10_000;
        uint256 depthB = rWethA == 0 ? 0 : (((rWethB * DEPTH_BPS) / 10_000) * rUsdc) / rWethA;
```

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.

**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.

---

Judge's submission `329d054a15a7f7307beb099f7c9201576d96b74b44932dfe20974fb79a7764c9`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
