# Audit report

> Final re-check of src/IMDBuyAndBurn.sol at the commit now deployed on Base mainnet at 0xcad3cecbb402b9a7f5c08e3dbee57a34d2f949fa (to be verified on Sourcify). Earlier IMD audits: de9e10c6, dca64686, b2cad286 and 564c3cc7; README.md, Audits, says how each finding was handled. Changes since 564c3cc7 (commit 218b030): (1) the size bound is DEPTH_BPS of the smaller of the reserves now and the reserves stored at the window's start (obsUsdc, obsWethA, obsWethB, written by _observe() at deployment, observe() and after each burn), so liquidity added inside the burn's transaction cannot raise it; (2) PoolTooThin is gone: a burn is never smaller than THRESHOLD (size = max(cap, THRESHOLD)) and a remainder under THRESHOLD is spent with the last capped burn (usdcIn = balance when balance < size + THRESHOLD). Check that both changes are correct, that the stored reserves cannot be set by an attacker to a value that blocks or inflates burns (for example observe() right after a manipulation, or a burn that stores pumped post-swap reserves), and anything else new. 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 | `440edec3d64447552f198588d5ae267109fb1ce0` |
| Job | `c00ee601-a70b-43c9-8bb7-774e16c1c15a` |
| Judged | 2026-10-15 17:35 UTC |
| Findings | 1 low · 2 info |

Four agents audited the code as it is at `440edec`, 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: Window-start reserves are stored inside the caller's own transaction, so flash liquidity at the previous burn (or at observe()) and again at the next burn lifts the size bound to MAX_PER_BURN

`src/IMDBuyAndBurn.sol:219`

```
        obsUsdc = uint112(rUsdc);
```

Change (1) bounds a burn by DEPTH_BPS of min(reserves now, reserves stored at the window's start). Both terms are plain getReserves() snapshots, and the stored one is written by _observe() at the end of buyAndBurn() (line 133) and in observe() (line 147), inside a transaction anyone can send. Uniswap v2 mint()/burn() of LP shares pays no fee, so a caller can wrap their own buyAndBurn() (or observe() on a window older than MAX_WINDOW) in add-liquidity / remove-liquidity: that burn is still held to the old bound, but the snapshot it leaves is the inflated reserves (obsUsdc = 4,373,694 USDC while the pair holds 437,328). MIN_WINDOW later the same caller flash-adds again around the next burn and both terms of the min are inflated, so the cap is MAX_PER_BURN ($1,000) instead of 0.1% of the real reserves (about $414 at Base's reserves). The guarantee in the contract comment (lines 28-29, 33-35) and README lines 11 and 24 ('liquidity added inside the burn's transaction cannot raise it', 'unless the author has owned more than two thirds of the pair's liquidity since the window began') therefore holds only within one transaction: two instants of flash liquidity 30 minutes apart suffice, and nobody needs to hold anything across the window. A balanced two-leg price manipulation does the same without LP (buy WETH with about 1x pair A's USDC and sell about 1x pair B's FP: spotOut stays equal to twapOut, the burn passes the check and stores obsUsdc = 876,382 USDC, 2x real), at round-trip fee cost. Impact: with the cap at $1,000 the sandwich economics in the comment no longer hold once the pumped pair's real USDC reserve is under MAX_PER_BURN / 0.3% = about $333k (today about $439k; the economics specialist measured the attacker's net at -$1.47 at $438k and +$14.73 at $250k with mock pairs, i.e. a within-tolerance sandwich pays below roughly $420k once the attacker's LP share of the burn's own fees is counted). Loss per paying window is bounded by MAX_DEVIATION_BPS x MAX_PER_BURN = $30 instead of $12, repeatable every MIN_WINDOW while the balance is >= $1,005. Mitigating facts verified on Base today: the WETH/FP pair is the shallower leg ($414 vs $439), so lifting the cap above $439 needs WETH and FP added to pair B at both instants; Fren Pet (limitsInEffect = true, sellTotalFees = buyTotalFees = 4, pair 0xDE66 is an automatedMarketMakerPair) taxes each FP transfer to or from the pair at 4%, and FP has no flash source, so in practice an attacker who only flashes pair A (USDC and WETH are flash-loanable on Base) lifts the cap from $414 to the deeper leg's real depth ($439), and reaching $1,000 costs tens of thousands in FP tax for a $30 gain. No profitable path exists at today's liquidity and fee settings, hence low; but the bound no longer provides what the code and README claim, the margin now depends on pool size (which DEPTH_BPS was introduced to remove) and on Fren Pet's owner keeping the tax. Fix options that preserve the design: (b) in the burn's _observe() never raise the stored depth (store the new reserves only when _depth(new) <= _depth(stored)) and let only observe() on a stale window take a fresh snapshot; a patched copy with that change makes the attached proof pass (cap stays 414.489760 with flash liquidity). observe() on a stale window stays wrappable, so also (a) choose MAX_PER_BURN <= 0.1% of the smallest reserve you want the 3x margin to hold for (for example $300 for a $300k USDC reserve), or (c) document that the 3x margin holds only while the pumped pair's USDC reserve exceeds MAX_PER_BURN / 0.003 = $333k. Reported by all four specialists (flow, permissions, math as low; economics as medium); merged here, kept at low because the economics specialist's profitability figure ignores the 4% FP tax on the pair B leg.

**Reproduction**

Mock pairs with Uniswap v2's K, fee and cumulative-price rules at Base's reserves (pair A 180 WETH / 437,328 USDC, pair B 170.6 WETH / 64,077 FP), burner holding 5,000 USDC, 31 minutes after deployment. preview().usdcIn = 414.489760 (honest cap, 0.1% of pair B's WETH valued in USDC). Transaction 1: mint 9x each pair's reserves into both pairs and sync(), buyAndBurn() (spends 414.49, bounded by the reserves stored at deployment), remove the 9x and sync(). State: obsUsdc = 4,373,694.489760 USDC, obsWethB = 1,706.17 WETH, while the pairs hold 437k USDC / 170.6 WETH. Warp 31 minutes with no extra liquidity held: preview() cap = 415.688964 (now = real). Transaction 2: mint 9x again and sync(), preview().cap = 1000.000000 = MAX_PER_BURN. Expected: at most 414.49 USDC (0.1% of liquidity really present during the window). Actual: 1,000 USDC. Same result when the storing transaction is observe() on a window older than 2 hours. Run: forge test --match-path test/scratch/FlashLiquidityAcrossBurns.t.sol -vv, fails with 'liquidity never held across the window must not raise the bound: 1000000000 > 414489760'. With a patched copy that never raises the stored depth at the end of a burn the same test passes (cap 414.489760).

**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 reserves stored at the window's start (obsUsdc, obsWethA, obsWethB) are written by _observe() at the
// end of buyAndBurn(), inside a transaction anyone can send. Liquidity added before that burn and removed right after
// it, in the same transaction, becomes the "window start" reserves, so the next burn's bound is 0.1% of liquidity
// nobody held for a single second. The bound meant to resist flash liquidity (min of now and then) is then
// min(flash, flash). Mock pairs with the real Uniswap v2 K and fee rules, no fork.

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, plus sync() and an LP
/// burn modelled without LP tokens
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 sync() external { _update(); }
    function removeLiquidity(address to, uint256 a0, uint256 a1) external {
        MockToken(token0).transfer(to, a0);
        MockToken(token1).transfer(to, a1);
        _update();
    }
    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 FlashLiquidityAcrossBurnsTest is Test {
    address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
    address constant WETH = 0x4200000000000000000000000000000000000006;
    address constant IMD = 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105;
    address constant PAIR_A = 0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C; // WETH/USDC, WETH is token0 as on Base
    address constant PAIR_B = 0xDE66c35e01ED8e619Bf092352338ef94f2327337; // WETH/IMD, WETH is token0 as on Base

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

    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);
        // Base reserves at block 52647419
        MockToken(WETH).mint(PAIR_A, 180.0e18);
        MockToken(USDC).mint(PAIR_A, 437_328e6);
        MockPair(PAIR_A).init(WETH, USDC);
        MockToken(WETH).mint(PAIR_B, 170.6e18);
        MockToken(IMD).mint(PAIR_B, 64_077e18);
        MockPair(PAIR_B).init(WETH, IMD);
        burner = new IMDBuyAndBurn();
    }

    /// Adds (mult - 1) times the current reserves at the current ratio, as an LP mint would
    function _addLiquidity(address pair, uint256 mult) internal returns (uint256 a0, uint256 a1) {
        (uint112 x0, uint112 x1,) = MockPair(pair).getReserves();
        a0 = uint256(x0) * (mult - 1);
        a1 = uint256(x1) * (mult - 1);
        MockToken(MockPair(pair).token0()).mint(pair, a0);
        MockToken(MockPair(pair).token1()).mint(pair, a1);
        MockPair(pair).sync();
    }

    function test_FlashLiquidityAroundPreviousBurnRaisesTheCap() public {
        MockToken(USDC).mint(address(burner), 5_000e6);
        vm.warp(block.timestamp + 31 minutes);
        (uint256 honestCap,,,,) = burner.preview();
        emit log_named_decimal_uint("honest cap (0.1% of the real reserves)", honestCap, 6);

        // Burn N, wrapped in one transaction: add 10x liquidity to both pairs, burn, remove it again
        (uint256 a0, uint256 a1) = _addLiquidity(PAIR_A, 10);
        (uint256 b0, uint256 b1) = _addLiquidity(PAIR_B, 10);
        burner.buyAndBurn(); // still bounded by the real reserves stored at deployment
        MockPair(PAIR_A).removeLiquidity(attacker, a0, a1);
        MockPair(PAIR_B).removeLiquidity(attacker, b0, b1);
        emit log_named_decimal_uint("obsUsdc stored by burn N", burner.obsUsdc(), 6);
        emit log_named_decimal_uint("obsWethB stored by burn N", burner.obsWethB(), 18);

        // Thirty-one minutes later: nobody held any extra liquidity in between
        vm.warp(block.timestamp + 31 minutes);
        (uint256 capNoFlash,,,,) = burner.preview();
        emit log_named_decimal_uint("cap at burn N+1 before adding liquidity", capNoFlash, 6);

        // Burn N+1, wrapped again: add 10x liquidity, then the bound the burn uses
        _addLiquidity(PAIR_A, 10);
        _addLiquidity(PAIR_B, 10);
        (uint256 capFlash,,,,) = burner.preview();
        emit log_named_decimal_uint("cap at burn N+1 with flash liquidity", capFlash, 6);
        assertLe(capFlash, honestCap, "liquidity never held across the window must not raise the bound");
    }
}
```

### 2. Info: Stored window-start reserves can be lowered in the storing transaction (a WETH dump before observe() or a burn, or a pair A LP removing its share), shrinking the next burn towards the $5 floor for one

`src/IMDBuyAndBurn.sol:147`

```
        _observe();
```

The other direction of the brief's question. observe() has no price or depth check (none is possible, the window it starts has no TWAP yet) and _observe() stores whatever the pairs hold in that transaction. Selling WETH into the USDC/WETH pair lowers rUsdc and raises rWethA, which lowers both depthA and depthB in _depth() (lines 227-228) and makes IMD cheaper along the route, a direction _withinTolerance() never rejects (line 176). So an attacker who dumps WETH, calls observe() (window older than MAX_WINDOW) or buyAndBurn() (window 30 min to 2 h old), and buys the WETH back in the same transaction leaves obsUsdc and obsWethA depressed; the next burn is bounded by depthThen (line 192) for the whole following window. The stored cumulative prices are unaffected (the pair accrued the honest price up to this block; the manipulated price counts for zero seconds when unwound in the same transaction). A liquidity provider of the USDC/WETH pair gets the same effect for free (no swap fee, no Fren Pet tax on pair A) by removing its share before the storing transaction and re-adding it after; it only binds when it brings depthA(then) under depthB, i.e. a share above about 5% today. Change (2) guarantees this cannot block: size = max(cap, THRESHOLD), so the worst case is a $5 burn in one window, and that burn's own _observe() stores honest reserves again. It is griefing at a loss (about $2,000 of WETH in fees and impact to defer about $388 of burning by 30 minutes), with nothing to gain, so no code change is needed; the README's 'observe() ... can never delay a burn' (line 20) and contract line 38 should say that it cannot delay a burn but can shrink the next one for a window, and the trust assumptions could note that an LP of the USDC/WETH pair can throttle one window for free. If a bound is wanted, a floor such as depthThen >= depth / 4 would cap the throttle at 4x while keeping the same-transaction flash-liquidity bound within that factor. Reported by all four specialists (permissions as low, the others as info); the swap and LP variants are merged here.

**Reproduction**

Mock pairs at Base's reserves (pair A 180 WETH / 437,328 USDC, pair B 170.6 WETH / 64,077 FP), burner holding 1,000 USDC, window idle for 2 hours and 1 second. Attacker in one transaction: sell 540 WETH into pair A (USDC reserve falls to 109,578, WETH to 720), call observe() (succeeds, stores obsUsdc = 109,578.551742 and obsWethA = 720), buy the WETH back: net cost 0.812435 WETH. Warp 31 minutes: preview().usdcIn = 25.964029 USDC (expected about 414 at the real reserves); buyAndBurn() spends 25.96. Warp 31 minutes: preview().usdcIn = 412.702164, the effect lasted one window. Same trade in front of a burn (window 31 min old, 2,000 USDC): the burn passes the price check (IMD cheaper), stores obsUsdc = 109,604.515771, costs the attacker 0.971853 WETH, and the next burn is 26.002208 USDC. LP variant: remove 90% of pair A (162 WETH, 393,595 USDC) before observe(), re-add after and sync(): next preview().usdcIn = 43.732800 USDC. Run: forge test --match-path test/scratch/JudgeRepro.t.sol --match-test 'Deflates|Throttles' -vv (the tests assert the shrunk sizes and pass).

### 3. Info: The THRESHOLD-floor sentence understates the floor's share of a thin reserve: $5 to $9.99 is 0.2% only at the $5,000 boundary and grows without bound below it, inverting the sandwich margin under abou

`src/IMDBuyAndBurn.sol:36`

```
/// THRESHOLD floor (pools worth under about $10,000) a burn can reach 0.2% of a reserve, which halves that margin.
```

With size = max(cap, THRESHOLD) and the remainder rule (balance < size + THRESHOLD spends the whole balance, line 197), a burn in a pool whose 0.1% depth is under $5 spends between $5 and $9.99 whatever the reserve. The share of the reserve is therefore up to $9.99 / reserve: 0.2% at a $5,000 reserve (the 'pools worth about $10,000' boundary in the comment is right, two sides of $5,000), but 1% at a $1,000 reserve and 10% at $100. The margin does not merely halve: a sandwich's gain d x S versus the pump's fee 0.3% x d x R inverts once R < S / 0.003, i.e. a USDC reserve under $3,330 for S = $9.99 ($1,667 for S = $5), for a gain of at most 3% x $9.99 = $0.30 per window. This is the intended drain-rather-than-lock tradeoff from the 564c3cc7 round and the amounts are cents, so it is a documentation precision note (contract line 36 and README line 25-26), not a code change: say that below about $10,000 of pool value the burn's share grows as the pool shrinks (1% at a $1,000 reserve) and that under about $6,600 of pool value a within-tolerance sandwich pays, bounded by cents per window. Reported by the flow and economics specialists; merged. The flow specialist's claim that the boundary is $5,000 rather than $10,000 does not hold: the comment speaks of pool value, which is twice the reserve.

**Reproduction**

Mock pairs: pair A 0.412 WETH / 1,000 USDC, pair B 170.6 WETH / 64,077 FP; burner holding 9.99 USDC; window 31 minutes old. _quote(): depthA = 1,000e6 x 10 / 10_000 = 1 USDC, cap = 1 USDC, size = THRESHOLD = 5 USDC, balance 9.99 < 5 + 5 so usdcIn = 9.99 USDC = 0.999% of the USDC reserve; buyAndBurn() spends all 9.99 USDC. Expected per the comment: at most 0.2% of a reserve. Run: forge test --match-path test/scratch/JudgeRepro.t.sol --match-test FloorShare -vv (asserts usdcIn = 9.99e6 and share >= 0.99%, passes). test/ThinPoolLock.t.sol's VeryThinPoolTest shows the floor binding at a $4,855 reserve, consistent with the $10,000 pool-value boundary.

---

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