# Audit report

> Re-check src/IMDBuyAndBurn.sol at this commit before it is deployed on Base mainnet. Earlier IMD audits: de9e10c6 (4e49d40), dca64686 (2042849) and b2cad286 (e70a156); README.md, Audits, says how each finding was handled. This commit replaces the burn window with no maximum age and the poke()/pending window by one bounded window: buyAndBurn() requires the window to be between MIN_WINDOW (30 min) and MAX_WINDOW (2 h) old, a burn restarts it, and observe() restarts it only when it is older than MAX_WINDOW. The price check stays one-sided by design (a cheaper IMD than the TWAP passes); the protection against sandwiches is economic: DEPTH_BPS = 10, so one burn spends at most 0.1% of either pair's input-side reserve, and moving a pair's price by d costs about 0.3% * d * reserve in round-trip fees (README, Economics). A capped burn now spends a remainder under THRESHOLD instead of stranding it, and a cap under THRESHOLD reverts PoolTooThin. Check that the bounded window removes b2cad286 finding 1 and the pending-window findings, that observe() cannot delay or block a burn, that the economic bound holds (including FP's 4% fees on the WETH/FP leg and flash liquidity), that the dust rule cannot overspend the depth bound by more than THRESHOLD - 1 or produce a zero-size burn, and anything 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 | `218b030bce2706af93100f1f85cf0248168408fb` |
| Job | `564c3cc7-2913-425a-8855-d29a04f00878` |
| Judged | 2026-10-15 17:05 UTC |
| Findings | 2 low · 2 info |

Four agents audited the code as it is at `218b030`, 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: Depth cap reads same-block reserves: flash-minted USDC/WETH liquidity lifts it to min(MAX_PER_BURN, pair B's bound) and voids the 'two thirds of the liquidity' sandwich argument

`src/IMDBuyAndBurn.sol:185`

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

The economic bound that carries the sandwich defence (NatSpec lines 29-32, README 'Economics') is: a burn spends S <= 0.1% of the pair's reserve, a pump by d costs 0.3% * d * reserve in round-trip fees, so a sandwich loses ~3x what the burn loses 'unless the author owns more than two thirds of the pair's liquidity'. Both reserves are read from getReserves() in the burn block, Uniswap v2 mint()/burn() are free, the USDC/WETH pair's tokens carry no transfer fee, and buyAndBurn() is permissionless. So in one bundle an attacker can mint LP into the USDC/WETH pair (own capital or a 0-fee flash loan such as Balancer v2 or Morpho on Base), pump WETH just inside MAX_DEVIATION_BPS, call buyAndBurn(), sell the WETH back and burn the LP. Two things follow: (1) the cap no longer reads 0.1% of the honest USDC reserve but min(MAX_PER_BURN, 0.1% of the WETH/FP pair's WETH valued at the pumped pair-A price); (2) the attacker gets its own share of the pump's fees back, so the net fee cost is 0.3% * d * (honest USDC reserve) however large the share. The sandwich therefore pays whenever honest USDC reserve of pair A < min(MAX_PER_BURN, depthB) / 0.3%, i.e. below about $137k at today's pair B depth (~$411), or below $333k if pair B grows past ~412 WETH. Today (block 52647213: pair A 180.03 WETH / 437,303 USDC, pair B 170.61 WETH / 64,078 FP) the bundle loses about $26 per burn, so it is not exploitable now, but the margin is ~3x on pair A's reserve rather than the documented 3x on cost, and it erodes as the legacy v2 USDC/WETH pool thins or the FP pool deepens. Impact is bounded at d * S <= 3% of one burn (<= $30 per 30-minute window). The WETH/FP pair is not affected the same way: FP's 4% fee on transfers to the pair makes flash liquidity there cost ~2% of the added value; only an FP fee-exempt account (an existing trust assumption) could lift depthB the same way. This merges the audit_permissions, audit_economics and audit_math findings on the same mechanism. Minimal fix that keeps the design: store pair A's USDC reserve and pair B's WETH reserve when the window starts (_observe() and after a burn) and bound the burn by DEPTH_BPS of min(window-start reserve, current reserve), so liquidity must sit in the pool for at least MIN_WINDOW to count. Also replace the 'two thirds' sentence in the NatSpec and README with the real condition (min(MAX_PER_BURN, 0.1% of pair B's WETH in USDC) must stay under 0.3% of pair A's USDC reserve) and list the FP fee-exempt actor.

**Reproduction**

Mock pairs with the real v2 K, fee, cumulative and LP-share rules (test/scratch/FlashLiquiditySandwich.t.sol, no fork). State: pair A = 60,000 USDC / 24.72 WETH ($2,427/ETH); pair B = 169.617 WETH / 64,452.95 FP (Base reserves); burner holds 1,000 USDC; 31 minutes after deployment. Honest preview() cap = 60.000000 USDC. Attacker bundle: mint 9x the pool into pair A (540,000 USDC + 222.5 WETH), buy WETH with USDC sized by bisection so that spot/twap = 9701 bps, call buyAndBurn(), sell the WETH back, burn the LP. Expected (README): the burn is at most 0.1% of the pair's reserve and the sandwich loses. Actual: the burn spends 424.348478 USDC (the cap is now pair B's bound, valued at the pumped price) and the attacker's value at the honest price rises by +8.625614 USDC. Control in the same file without the LP mint: burn 60.92 USDC, attacker -3.611558 USDC. Same bundle with pair A at today's 437,091 USDC: attacker -25.68 USDC (not profitable today). Run: forge test --match-path test/scratch/FlashLiquiditySandwich.t.sol -vv; test_SandwichWithFlashLiquidityDoesNotPay fails with 'a sandwich inside the tolerance must not pay: 8625614 > 0', the other two pass.

### 2. Low: USDC is stranded for good once either pair's input-side reserve is worth under $5,000: PoolTooThin has no exit, and the README says a thin pool 'burns in smaller steps instead of locking'

`src/IMDBuyAndBurn.sol:110`

```
        if (q.cap < THRESHOLD) revert PoolTooThin(q.cap);
```

The size cap is min(MAX_PER_BURN, 0.1% of pair A's USDC reserve, 0.1% of pair B's WETH reserve valued at pair A's price). When that cap is below THRESHOLD ($5) every buyAndBurn() reverts PoolTooThin, for any balance, and nothing else can move the USDC: no owner, no rescue path, pair addresses are constants. The floor is a USDC reserve under 5,000e6 in the USDC/WETH pair, or a WETH reserve in the WETH/FP pair worth under $5,000 (about 2.06 WETH at $2,427; 170.6 WETH today). observe() cannot help because the revert is on size, not on the window. Neither pair's LP is burned (0x...dEaD holds 0 of the WETH/FP pair's 2,776 LP), so the FP team or any LP can withdraw, and the v2 USDC/WETH pair on Base is a legacy venue whose liquidity can migrate. The balance keeps growing with every Lobby payout while every burn reverts. The revert itself is the stated design (a cap under THRESHOLD reverts PoolTooThin), so this is a liveness trust assumption with an undocumented precondition rather than a bypass: README.md lines 10-11 say 'a thin pool burns in smaller steps instead of locking', which only holds above the floor, and the Trust assumptions list the blacklist and USDC levers but not this one. Merges the audit_flow and audit_economics findings. Fix: at minimum document the floor next to the blacklist assumption and correct the README sentence; if liveness is wanted, let a burn whose cap is under THRESHOLD spend min(balance, THRESHOLD) so a thin pool still drains $5 per window (a $5 burn is what the dust rule already allows).

**Reproduction**

Mock pairs with the real v2 K/fee/cumulative rules (test/scratch/JudgeChecks.t.sol, test_ThinPairBStrandsUsdc). Pair A: 180 WETH / 437,000 USDC. Pair B: 2 WETH / 760 FP (WETH side worth about $4,855). Deploy, mint 1,000e6 USDC to the burner, warp 31 minutes. Expected per README: a smaller burn runs. Actual: preview() cap = 4.855555 USDC and buyAndBurn() reverts PoolTooThin(4855555). Warp 2 hours, observe(), warp 31 minutes, buyAndBurn(): PoolTooThin(4855555) again; after three rounds the burner still holds 1,000e6 USDC. Same from the other side (test_ThinPairAStrandsUsdc): pair A 4,900 USDC / 2.017 WETH with pair B as on Base reverts PoolTooThin(4900000).

### 3. Info: Two-hop TWAP is the product of the two pairs' averages: when both legs move the same way inside a window it understates the route average and loosens the 3% tolerance, not only tightens it

`src/IMDBuyAndBurn.sol:203`

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

twapOut multiplies the window average of A = WETH per USDC by the window average of B = FP per WETH. The route's true time-weighted output is the average of A * B, and avg(A) * avg(B) - avg(A * B) = -Cov(A, B). README.md lines 38-39 say the difference 'at most makes a burn wait for a new window'. That holds only when the legs move in opposite directions. When they co-move (ETH cheaper in USDC while FP cheaper in WETH, or both dearer), the covariance is positive, twapOut is below the true route TWAP and the one-sided check spotOut >= 0.97 * twapOut admits a spot that is more than 3% dearer than the route's real average. The effect is second order (product of the two moves) so it is informational: 20% same-direction moves add ~0.8% of leniency, 10% moves ~0.2%. It creates no profitable path on its own because the size bound, not the TWAP check, makes a sandwich unprofitable. Merges the audit_flow and audit_math findings. Fix: correct the README sentence (and NatSpec lines 23-28, which describe the check as a comparison with the route's TWAP), or budget MAX_DEVIATION_BPS for it; accumulating A * B in the contract would change the window model and is not recommended for this commit.

**Reproduction**

Mock pairs (test/scratch/JudgeChecks.t.sol, test_ProductOfAveragesIsLenient): pair A 180 WETH / 437,000 USDC, pair B 169.6 WETH / 64,452 FP, 100e6 USDC in the burner. First 30 minutes of the window at the base price. Then mint 36 WETH into pair A and 12,890.4 FP into pair B and sync() both (each leg gives 20% more output per input, the route 44% more), wait 30 more minutes, call preview(). Measured: twapOut / base output = 12100 bps (1.1 * 1.1), true route average / base = 12199 bps ((1.0 + 1.44) / 2), and the accepted floor 0.97 * twapOut is 9620 bps of the true route average. Expected per README: the floor is never below 9700 bps of the true average. Actual: after same-direction moves a spot 3.8% dearer than the true route average passes _withinTolerance.

### 4. Info: At the thin end the dust rule lets one burn spend up to 2x the depth bound (0.2% of the reserve), so the documented sandwich break-even of two thirds of the liquidity is one third there

`src/IMDBuyAndBurn.sol:192`

```
        q.usdcIn = balance < cap + THRESHOLD ? balance : cap;
```

The dust rule is correctly bounded: usdcIn is never more than cap + THRESHOLD - 1 and never zero, because balance >= THRESHOLD and cap >= THRESHOLD are checked first. But the economic paragraph (NatSpec lines 29-32, README 'Economics') assumes a burn of at most 0.1% of the reserve, while NatSpec line 28 only says the remainder is 'under THRESHOLD'. With cap close to THRESHOLD, S can be 2 * cap - 1, i.e. 0.2% of the reserve: the attacker's gain d * S doubles against the same fee cost (1 - f) * 0.003 * d * R, and the break-even LP share becomes f = 1/3 instead of 2/3. At a 5,000 USDC reserve, cap = $5, S = $9.999999: an LP holding 40% of the pair who pumps WETH 3% pays about $0.45 in fees, gets $0.18 back and captures $0.30 of burn loss, net +$0.03 per burn where the README says only an owner of more than two thirds profits. Absolute amounts are cents and the case only exists for pairs within $5 of the PoolTooThin floor, so this is informational. Fix: state the dust case in the Economics paragraph (bound is 0.1% + $5, break-even share 1/3 at the floor), or apply the dust rule only when cap >= 2 * THRESHOLD.

**Reproduction**

Mock pairs (test/scratch/JudgeChecks.t.sol, test_DustRuleSpendsTwiceTheCapAtTheFloor): pair A 5,000 USDC / 2.0588 WETH (USDC reserve exactly at the floor), pair B 169.6 WETH / 64,452 FP. cap = 5,000e6 * 10 / 10,000 = 5e6 = THRESHOLD, so PoolTooThin does not fire. Balance 9,999,999: it is below cap + THRESHOLD = 10e6, so preview() returns usdcIn = 9,999,999 and buyAndBurn() spends all of it, 0.19999998% of the USDC reserve against the documented 0.1%. With balance 10,000,000 (test_DustRuleOneWeiMoreIsCapped) the burn is capped at exactly 5,000,000 and 5,000,000 stays for the next window.

---

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