# Audit report

> Audit src/IMDBuyAndBurn.sol, a contract for Base mainnet that 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. It has no owner and no withdraw. Focus: (1) can USDC or the bought IMD leave in any way other than a burn; (2) can a caller make a burn execute at a bad price: atomic sandwich, TWAP manipulation across blocks, griefing of the 30-minute window or of poke(); (3) can the contract get stuck so that its USDC can never be burned, including Fren Pet's buy fee, max-transaction and max-wallet limits and blacklist (transfers to 0xdead are exempt from fees and limits), and a pair whose reserves change; (4) correctness of the UQ112x112 TWAP math, wrap-around of cumulative prices and of uint32 timestamps, and the counterfactual cumulative price; (5) the 3% shortfall bound and the $1,000 per-burn cap. The tests in test/ 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 | `4e49d40ded813997af62bc160ed4cc1c4839c17a` |
| Job | `de9e10c6-3380-4fb8-8808-91858e518099` |
| Judged | 2026-10-15 15:41 UTC |
| Findings | 1 medium · 3 low · 2 info |

Four agents audited the code as it is at `4e49d40`, 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: Fixed $1,000 burn size with a 3% bound that must also absorb fees and the trade's own price impact: a flat but thin WETH/IMD pair (< ~18 WETH) makes every burn of a balance >= $1,000 revert forever

`src/IMDBuyAndBurn.sol:84`

```
        uint256 usdcIn = balance > MAX_PER_BURN ? MAX_PER_BURN : balance;
```

buyAndBurn() always spends min(balance, MAX_PER_BURN); no caller can choose a smaller amount. expectedImd (line 95) is the fee-free, impact-free TWAP quote, while imdOut (line 104) carries two 0.3% fees plus the constant-product price impact of the full trade on both pairs. At today's Base depth a $1,000 burn already quotes 98.93% of the TWAP with no price movement (fork: preview() 9893 bps). Price impact grows as the pair's reserves shrink, so when the WETH side of the WETH/IMD pair falls below ~18 WETH (about 1/9 of today's depth, an ordinary event for a memecoin pool) the fee+impact shortfall alone exceeds 300 bps. In a flat market TWAP == spot, so waiting, a new window after the burn or poke() all leave the result unchanged: every buyAndBurn() reverts PriceOffTwap while the balance is >= ~$1,000, and later Lobby payouts pile on top. There is no owner, no withdraw and no amount parameter, so the USDC stays stuck until liquidity providers of a third-party pool return. A $100 balance burns in the same state, so the fixed size is the cause. Merged from the audit_math, audit_flow, audit_economics and audit_permissions reports (four duplicate reports of this issue). Fix that keeps the design: bound usdcIn by pool depth as well (e.g. usdcIn = min(balance, MAX_PER_BURN, reserveUsdc*k, wethReserveOfPairB-in-USDC*k) with k ~ 0.25%), or compute expectedImd with the same getAmountOut formula on TWAP-implied reserves so MAX_SHORTFALL_BPS measures only price deviation.

**Reproduction**

No-fork proof (test/scratch/ThinPoolLock.t.sol): mock pairs at the constant addresses, USDC/WETH 436,000 USDC / 180 WETH, WETH/IMD 16.5 WETH / 6,600 IMD, price unchanged for the whole window. The burner holds 1_000e6 USDC, 31 minutes pass. Expected: the burn executes. Actual: it reverts PriceOffTwap(165137614678899082400, 159808258783426919783), 96.77% of the TWAP. Base fork (block ~52644720): deal both tokens of pair 0xDE66... down to 1/10 at the same ratio (16.56 WETH / 6,600 FP), sync(), fund 1_000e6, warp 31 min: preview gives 9679 bps and buyAndBurn() reverts PriceOffTwap. After warp 6h+1s, poke() and warp 31 min it still reverts. With the balance set to 100e6 the burn succeeds.

**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();
    }
}

/// A WETH/IMD pair holding ~16.5 WETH (about $40k a side, a normal depth for a memecoin pool) at a price that has
/// not moved for the whole window: TWAP == spot. A burn of $1,000 must still be possible; today it never is,
/// because MAX_SHORTFALL_BPS has to absorb fees plus the fixed-size trade's own price impact.
contract ThinPoolLockTest 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();
    }

    function test_FlatPriceBalanceOver1000CanBeBurned() public {
        MockToken(USDC).mint(address(burner), 1_000e6);
        vm.warp(block.timestamp + 31 minutes);
        (, uint256 expected, uint256 spot,) = burner.preview();
        emit log_named_uint("spot/expected bps (no price move)", (spot * 10_000) / expected);

        uint256 deadBefore = MockToken(IMD).balanceOf(DEAD);
        burner.buyAndBurn(); // reverts PriceOffTwap on the current code
        assertGt(MockToken(IMD).balanceOf(DEAD), deadBefore, "IMD burned");
        assertLt(MockToken(USDC).balanceOf(address(burner)), 1_000e6, "USDC spent");
    }
}
```

### 2. Low: Fren Pet owner can permanently freeze the contract's USDC by blacklisting 0xdEaD or the Uniswap WETH/FP pair; the hard-coded route has no fallback

`src/IMDBuyAndBurn.sol:107`

```
        if (wethIsToken0) WETH_IMD.swap(0, imdOut, DEAD, "");
```

Fren Pet's _transfer rejects blacklisted senders and receivers. Its owner still holds blacklist rights (blacklistRenounced() == false on Base). Neither 0xdEaD nor the Uniswap v2 pair 0xDE66... is protected from blacklist()/blacklistLiquidityPool(). After either call, hop 2 reverts in every block, and burnHeld() also reverts if 0xdEaD is blacklisted. The pair, recipient and route are constants and there is no owner or rescue, so the USDC can never leave. This is a third-party trust assumption rather than a code bug. The README mentions the blacklist power but not that it permanently freezes the USDC. Circle blacklisting this contract on USDC has the same effect at line 99. Merged from the audit_math, audit_flow, audit_economics and audit_permissions reports. Mitigation if wanted: a pre-vetted fallback route (for example FP's canonical BaseSwap pair) or a time-locked permissionless escape to a burn-only sink. At minimum, document it.

**Reproduction**

Base fork: burner = new IMDBuyAndBurn(); deal 100e6 USDC; warp 31 min; vm.prank(FP.owner()); FP.blacklist(0x...dEaD) -> buyAndBurn() reverts (verified). Same with vm.prank(FP.owner()); FP.blacklistLiquidityPool(0xDE66c35e01ED8e619Bf092352338ef94f2327337) -> buyAndBurn() reverts 'UniswapV2: TRANSFER_FAILED' (verified). Expected: the USDC is burned. Actual: every call reverts, permanently.

### 3. Low: If the Fren Pet owner removes 0xdEaD's fee exemption, 4% of every burn goes to the Fren Pet contract instead of being burned; the line 109 comment describes a fee that is not charged today

`src/IMDBuyAndBurn.sol:109`

```
        burned = IMD.balanceOf(DEAD) - deadBefore; // FP takes a buy fee, so this can be below imdOut
```

The pair -> 0xdEaD transfer is fee-free only because FP's constructor set excludeFromFees(0xdead, true). The owner can revert that setting. After excludeFromFees(0xdead, false) every burn pays FP's buy fee (4%, owner-settable up to 5%) to the FP contract, so bought IMD leaves other than by burning (brief item 1). The price check at line 105 runs on imdOut before the fee, so it does not see the loss, and the burn succeeds with a smaller burned amount. Today burned == imdOut, so the comment 'FP takes a buy fee' and the README's 4% statement are wrong for the current state. Merged from the audit_math, audit_flow, audit_economics and audit_permissions reports. Fix: document it as a trust assumption, or require IMD.balanceOf(DEAD) - deadBefore == imdOut (burn or revert) and correct the comment.

**Reproduction**

Base fork: deal 1_000e6 USDC to the burner, warp 31 min, read spot from preview(), then vm.prank(FP.owner()); FP.excludeFromFees(0x...dEaD, false); buyAndBurn(). Expected: burned == pool output 162007135410960249015. Actual: burned = 155526849994521839055 (96%), and the difference stays with the FP contract (verified).

### 4. Low: poke() can be front-run once a window is stale, pushing a valid pending burn back by 30 minutes (contradicts 'It cannot be used to hold burns back')

`src/IMDBuyAndBurn.sol:126`

```
        if (elapsed <= STALE_AFTER) revert NotStale(elapsed);
```

When elapsed > STALE_AFTER, both poke() and buyAndBurn() are callable. poke() resets obsTime with no further condition, so a poke() placed before a keeper's burn makes the burn revert WindowTooShort(0) for the next MIN_WINDOW. This is bounded to one 30-minute delay per stale period and causes no loss, but the NatSpec at line 120 claims the opposite. Merged from the audit_math, audit_flow and audit_economics reports. Fix: make poke() revert when the balance is >= THRESHOLD and elapsed >= MIN_WINDOW, or have buyAndBurn() start a fresh window itself when it is stale.

**Reproduction**

Base fork: deal 10e6 USDC, warp 6h+1s (a burn is valid now). Tx 1 (anyone): poke() succeeds. Tx 2, same block: buyAndBurn() reverts WindowTooShort(0) (verified). Expected: the valid burn executes.

### 5. Info: MAX_SHORTFALL_BPS comment understates the budget: fees plus impact use ~1.07% at the $1,000 cap, leaving ~1.9% for honest price drift, so burns pause during ordinary IMD rallies

`src/IMDBuyAndBurn.sol:36`

```
    uint256 public constant MAX_SHORTFALL_BPS = 300; // output may be at most 3% below the TWAP (covers 2 x 0.3% fees)
```

The comment says the 3% covers the 2 x 0.3% fees. The budget also pays the trade's own price impact on both pairs, so at today's depth a $1,000 burn sits at 9893 bps of the TWAP with no price movement. Any rise of IMD (or ETH vs USDC) of more than ~1.9% between the window average and spot blocks burns until the average catches up, or until 6h + poke() + 30 min. This is liveness, not loss: the guard is one-sided and falling prices never block. It is the same root cause as the medium finding at line 84. Merged from the audit_math, audit_flow, audit_economics and audit_permissions reports. The specialists' step-rise timings (for example 130 minutes after a 10% step) follow from this headroom but were not re-measured here.

**Reproduction**

Base fork (block ~52644720): new IMDBuyAndBurn(); deal 1_000e6 USDC; warp 31 min with no trades; preview() gives spot*10000/expected = 9893 (verified). Expected per the comment: ~9940 (fees only).

### 6. Info: A balance just above MAX_PER_BURN leaves a remainder below THRESHOLD that cannot be burned until more USDC arrives

`src/IMDBuyAndBurn.sol:84`

```
        uint256 usdcIn = balance > MAX_PER_BURN ? MAX_PER_BURN : balance;
```

The cap applies to the full balance, so a balance in (1_000e6, 1_005e6) burns exactly 1_000e6 and leaves under $5 behind the BelowThreshold check at line 77. A later payout tops it up, but a final payout strands the dust. Merged from the audit_math report. Fix: if balance - MAX_PER_BURN < THRESHOLD, spend the whole balance.

**Reproduction**

Base fork: deal 1_004e6 USDC, warp 31 min, buyAndBurn() leaves 4e6. Warp 31 min more: buyAndBurn() reverts BelowThreshold(4000000) (verified).

---

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