# Audit report

> Audit src/ImdUSD.sol, src/SavingsVault.sol, src/Treasury.sol, src/peg/FeeNotes.sol, src/Migrator.sol and src/backstop/Backstop.sol, src/backstop/PricerOracle.sol, src/peg/Allocator.sol, at the pinned commit, for a capped mainnet beta. Read whatever else in src/ these depend on, but report on this scope.
>
> imdUSD v2 is a multi-collateral CDP stablecoin; this is its capped mainnet beta on Ethereum. Positions are NFT ids; borrowers set their own interest rate and redemptions take from the lowest rates first; liquidation is mark then bite, with a standing bid from Backstop sleeves; the peg pool is a Uniswap v4 pool with our own hook; prices come from Chainlink and from swarm-attested feeds (EIP-712 attestations bound to pinned questions). README.md gives the scope, the key invariants, the trust model and the known issues: read it first.
>
> BUILD AND TEST (required; Foundry, solc 0.8.26 via_ir per foundry.toml). Never build or test the whole project: with every suite it needs about 49 GB and the compiler will be killed. Run only this panel's tests: `forge test --match-path 'test/unit/{Backstop,BackstopPanel,Allocator,PanelAllocator,Savings,FeeNotes}.t.sol'` (measured: about 4.3 GB peak, 4 minutes). One build at a time on your machine. Write each proof as a new file test/unit/Proof<Name>.t.sol, add it to the glob and run it; paste it with its output into the finding, then delete it: your submission must leave the tree unchanged. With Foundry 1.9 or later, `forge test --use solar --no-match-path 'test/fork/*'` builds everything and runs all 1,179 tests in seconds under 1 GB (Solar accepts a little syntax solc refuses: confirm a proof with the solc command).
>
> This panel covers two areas whose money flows meet: give both the same depth, and answer every question.
>
> Answer each numbered question, including the ones where nothing is wrong:
> 1. MINTERS AND SUPPLY (ImdUSD mint, burn, isMinter, setBridge, setKeeper, the flash mint and its cap, circulating()). Any mint or burn not matched by a liability on the other side, any way to use a minter's burn-from-anyone power outside its own action, any underflow of circulating().
> 2. EIP-3009 AND PERMIT. Replay across chains or contracts, a front-run cancel, a nonce reused after cancel.
> 3. TREASURY (coverBadDebt, retireReserve, surplusOf, timelocked payouts, the revenue meter). Make the Treasury spend out of order, spend what covers bad debt, or pay anyone outside the timelock.
> 4. FEE NOTES (open, buy, redeem, capacity, outstanding). Sell notes past the revenue cover, redeem early or twice, or block others' redemption.
> 5. SAVINGS VAULT (sync, the stream, same-block deposits, donations). Share-price inflation or a deposit that captures earlier savers' stream.
> 6. MIGRATOR (v1 to v2 and back, the migrator line). Mint v2 without burning v1, or the reverse; exceed the line.
> 7. SHARES (deposit buckets, _convert, the DEAD offset shares, MAX_RATE, requestWithdraw and the claim window, realize, cancelDeposit). Any sequence that gives a depositor more than their pro-rata share, including a first depositor, donations, a full exit, and conversions while a token is retired.
> 8. STANDING BID (openBid, closeBid, bidRoom, bidRoomNow, floor, MIN_RESIDUE, the price check at the low leg). Make the sleeve pay more than the collateral is worth, or bid with money deposited in the same transaction.
> 9. FIRST LOSS (coverBadDebt, owedBadDebt, _lossOverRoom, the edge premium, edgeCapacity, the 60-day bootstrap). Escape a booked loss by queueing out, or borrow past the edge capacity.
> 10. RETIRED TOKENS (retire, retiredAt, PricerOracle.pricedAt, _clearRetired, the dust rule in nav). Convert a bucket at a token value of 0 while the token is priced.
> 11. SALES (Backstop.sell, saleLimit, Allocator.sellForBackstop and onBackstopSell, routes, minOut, feed checks). Sell below fair value, hijack a route, or re-enter through a route.
> 12. ALLOCATOR GOVERNANCE (keepers, freezer, freeze and unfreeze, limit buckets, targets, returnToTreasury, fundBuffer, per-kind timelock). A keeper moving funds outside its limits, or anyone moving them while frozen.
>
> Not findings: anything listed under README.md 'Known issues and accepted risks' (say so if you find it worse than written); src/bridge, src/dex, src/earn/LoopVault.sol and src/stocks (after the beta) unless a question names them; test/ and its mocks; gas optimisations unless they change behaviour.
>
> For every finding: severity by impact; file, line and the lines copied; the call sequence from an external caller; a concrete failing input or state with expected against actual; whether it is reachable with the values in script/DeployConfig.sol (BetaConfig); the smallest fix; and a Foundry test that fails on this commit, RUN as BUILD says and pasted in full in the finding with its output (the judge reruns it). The test/ tree has harnesses to reuse (test/utils/System.sol, the unit suites). Answer every numbered question, including those where nothing is wrong. Give a coverage row (holds, finding or unreached, with a reason) for every external function of the contracts in scope. Also report every comment or NatSpec that claims a property the code does not have.

| | |
|---|---|
| Repository | https://github.com/fa11up/imdusd-v2-audit.git |
| Commit | `2875ffb22a726f041bd6891f32f35ddc7cdb983d` |
| Job | `584aadc2-a902-47dd-b639-e46a8223a277` |
| Judged | 2026-11-10 21:09 UTC |
| Findings | 1 medium · 4 low · 6 info |

Four agents audited the code as it is at `2875ffb`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: circulating() is deflated when a minter burns flash-minted imdUSD inside the loan: a borrower multiplies the redemption base-rate rise (and the savings uptake) at near-zero cost

`src/ImdUSD.sol:145`

```
        return totalSupply() - flashOutstanding();
```

Q1 (supply) and Q5 (uptake). `circulating()` = totalSupply() - flashOutstanding() is what Redeemer._fee / Redeemer.redeem (src/Redeemer.sol:146-147,153) and LedgerBase._mintInterest (src/LedgerBase.sol:277-278) divide by so that a flash mint cannot dilute them. The counter in the transient slot is only ever moved by flashLoan itself (lines 159,167). A minter burn of the flash-minted tokens inside the loan (Ledger.adjust repay: `dDebt < 0` burns from the caller) lowers totalSupply() by the amount burned while flashOutstanding() keeps the full loan, so circulating() under-reads the real supply by the burned amount. Call sequence (external caller = a borrower with debt D on a redeemable type): usd.flashLoan(self, usd, D, data) -> onFlashLoan: ledger.adjust(id, repay D) [burns D of the flash-minted tokens]; redeemer.redeem(ilk, X, maxFee, n, 0) on another position of the type: supplyBefore = circulating() + X = (S - D) and next base rate = decayed + X / (S - D) / divisor instead of X / S / divisor; ledger.adjust(id, borrow D) [cost: the 7-day upfront fee on D]; approve and return. The base-rate rise is multiplied by S / (S - D), bounded by flashCap (100k in BetaConfig) against a 300k chain cap: with the beta's lines a borrower holding a third of the supply triples every rise, and on a small book (the proof: 100k supply, 90k own debt) a 5k redemption lifts the ETH base rate (divisor 16) from 0.31% to 3.12%; on sIMD (divisor 2) the same lifts it to the 5% cap, which decays with a 12h half-life. Effect: redemption arbitrage on the type is priced out for hours and the attacker's own lowest-rate positions are shielded from redemption; the README's 'An honest large sIMD redemption can hold the fee at its 5% cap for hours' becomes reachable by a small redemption. The same deflation inflates uptake = saved / circulating() in _mintInterest for an accrual triggered inside the loan, pushing the split toward savers up to `savingsShare`. No underflow: every burnable amount is part of the pre-loan supply, so circulating() >= 0 (it can read 0, at which _fee returns FEE_CAP). Reachable with BetaConfig (flashCap 100k, chainLine 300k, ETH divisor 16, sIMD divisor 2): yes, with no precondition beyond holding a debt position. Smallest fix: make minter burns inside a flash loan reduce the in-flight counter first, e.g. in `burn` and `_burn`-reaching paths: `uint256 f = flashOutstanding(); if (f != 0) _setFlashOutstanding(f - Math.min(f, amount));` (burning first what was flash-minted), or snapshot totalSupply() at the outermost flashLoan entry in a transient slot and have circulating() return min(totalSupply() - flashOutstanding(), snapshot) while a loan is in flight. Proof run (solc 0.8.26 via_ir): forge test --match-path test/scratch/ProofCirculating.t.sol -vv -> [FAIL: base rate rose far more than the redemption's share of the real supply: 31177354248881897 > 6244012590666484] with logs: real supply before the tx 100095890410958904109590; circulating() inside the loan, before the burn 100095890410958904109590; circulating() inside the loan, after repaying 90k 10023300806905610808784; honest rise 0.003122006295333242; actual rise 0.031177354248881897. The test passes once circulating() stops under-reading on a minter burn inside a loan.

**Reproduction**

State: ETH type redeemable, redeemDivisor 16, flashCap 100k; bob 10k debt at 5%, attacker contract 90k debt at 6%; 10 days after the seal (launch base rate decayed). Input: attacker.run(90_000e18, 5_000e18). Expected: Redeemer.decayedBaseRate(ETH) rises by 5,000 / 100,096 / 16 = 0.312%. Actual: circulating() reads 10,023 inside the loan after the repayment, supplyBefore = 15,023 - 5,000 + 5,000, and the base rate rises 3.118% (10x).

### 2. Low: FeeNotes.redeem pays a matured note from the Treasury's imdUSD while bad debt is booked and uncovered, against the Treasury's stated first call for bad-debt cover

`src/peg/FeeNotes.sol:148`

```
        uint256 bal = token.balanceOf(address(treasury));
        if (bal < face) revert TreasuryShort(bal);
```

Q3 and Q4 (merged from three specialists). Treasury.sol:49 states 'nothing here is earmarked for notes, so bad-debt cover keeps first call on the balance', and Treasury.retireReserve (src/Treasury.sol:137) enforces that order for the other permissionless burn path by reverting BadDebtOutstanding while ledger.totalBadDebt() != 0. The note path has no such rule: FeeNotes.redeem checks only token.balanceOf(treasury) >= face and Treasury.payNote (src/Treasury.sol:184-188) transfers unconditionally to the notes contract's caller. Bad debt routinely outlives its liquidation whenever the in-transaction cover was best effort and failed (README 'Cover gas': a sleeve mid-bid, EdgeCoverFailed, an empty Treasury), and interest minted to the Treasury afterwards (noteRevenue) is then claimable by a matured note holder before anyone calls the permissionless Treasury.coverBadDebt; the hole stays in unbacked() and keeps the global brake on for every type. Call sequence: Parameters OPEN_NOTE_SERIES (48h) -> buyer FeeNotes.buy -> a liquidation books bad debt whose cover fails -> Ledger._mintInterest mints revenue to the Treasury -> holder FeeNotes.redeem(id, face) succeeds -> Treasury.coverBadDebt(ilk) covers less. Reachable with BetaConfig: FeeNotes is deployed dormant, so a governed series must be opened first; no other precondition. Smallest fix: in Treasury.payNote (or FeeNotes.redeem) `if (ledger.totalBadDebt() != 0) revert BadDebtOutstanding();`, mirroring retireReserve, or pay at most balance - totalBadDebt. If notes are meant to rank ahead of cover, change the Treasury NatSpec at line 49 instead. Proof run (solc 0.8.26 via_ir): forge test --match-path test/scratch/ProofNoteRedeemAheadOfBadDebt.t.sol -> [FAIL: bad-debt cover had first call on the Treasury balance: 1000000000000000000000 != 0] test_matureNoteCannotTakeTheBalanceThatCoversBookedBadDebt().

**Reproduction**

State: Treasury holds 1,000 imdUSD of interest (meter 1,000, capacity 2,000); series opened (30 days, 5%, cap 1,000); buyer bought 1,000 face for 950 USDC; 37 days later the note has matured; the Ledger books 1,000 of bad debt (totalBadDebt = 1,000). Calls: buyer -> FeeNotes.redeem(1, 1_000e18); anyone -> Treasury.coverBadDebt(ilk). Expected (Treasury.sol:49, retireReserve's rule): redeem holds while bad debt is booked, coverBadDebt burns 1,000, totalBadDebt = 0. Actual: redeem succeeds, Treasury balance 0, totalBadDebt stays 1,000.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Treasury, ICoverable} from "src/Treasury.sol";
import {FeeNotes, ITreasuryNotes} from "src/peg/FeeNotes.sol";

contract NUSD is ERC20 {
    constructor() ERC20("imdUSD", "imdUSD") {}
    function mint(address to, uint256 a) external { _mint(to, a); }
    function burn(address from, uint256 a) external { _burn(from, a); }
}

contract NUSDC is ERC20 {
    constructor() ERC20("USDC", "USDC") {}
    function decimals() public pure override returns (uint8) { return 6; }
    function mint(address to, uint256 a) external { _mint(to, a); }
}

/// @dev The Ledger as the Treasury sees it: books bad debt, burns the coverer's imdUSD, reports interest.
contract NLedger is ICoverable {
    NUSD public usd;
    Treasury public treasury;
    uint128 public bad;
    constructor(NUSD u) { usd = u; }
    function setTreasury(Treasury t) external { treasury = t; }
    function setBad(uint128 b) external { bad = b; }
    function totalBadDebt() external view returns (uint256) { return bad; }
    function cover(bytes32, uint256 amount) external {
        if (amount > bad) amount = bad;
        bad -= uint128(amount);
        usd.burn(msg.sender, amount);
    }
    function retire(uint256) external {}
    function ilkState(bytes32) external view returns (uint128, uint128, uint256, uint64, uint128) { return (0, 0, 0, 0, bad); }
    // Treasury.coverBadDebt reads ledger.params().edgeIlk(ilk): this contract answers both, with no edge types.
    function params() external view returns (address) { return address(this); }
    function edgeIlk(bytes32) external pure returns (bool) { return false; }
    function edgeBackstop() external pure returns (address) { return address(0); }
    /// @dev Interest minted to the Treasury and reported to its meter, as LedgerBase._mintInterest does.
    function pay(bytes32 ilk, uint256 amount) external {
        usd.mint(address(treasury), amount);
        treasury.noteRevenue(ilk, amount);
    }
}

/// Treasury.sol says: "nothing here is earmarked for notes, so bad-debt cover keeps first call on the balance", and
/// `retireReserve` refuses while `totalBadDebt != 0` for that reason. A matured note redeems the same balance with no
/// such check: the note holder is paid first and the booked bad debt stays uncovered.
contract ProofNoteRedeemAheadOfBadDebt is Test {
    NUSD usd;
    NUSDC usdc;
    NLedger ledger;
    Treasury treasury;
    FeeNotes notes;
    address governor = makeAddr("governor");
    address buyer = makeAddr("buyer");
    bytes32 constant ILK = "ETH";

    function setUp() public {
        vm.warp(1_800_000_000);
        usd = new NUSD();
        usdc = new NUSDC();
        ledger = new NLedger(usd);
        treasury = new Treasury(governor, IERC20(address(usd)), address(this));
        ledger.setTreasury(treasury);
        treasury.wire(ICoverable(address(ledger)));
        notes = new FeeNotes(IERC20(address(usd)), IERC20(address(usdc)), ITreasuryNotes(address(treasury)), address(this));
        treasury.wireNotes(address(notes));
        // 1,000 imdUSD of interest in the meter: capacity 2,000 of face.
        ledger.pay(ILK, 1_000e18);
        uint256 id = notes.open(30 days, 500, 1_000e18);
        usdc.mint(buyer, 1_000e6);
        vm.startPrank(buyer);
        usdc.approve(address(notes), type(uint256).max);
        notes.buy(id, 1_000e18);
        vm.stopPrank();
        skip(7 days + 30 days); // matured
    }

    function test_matureNoteCannotTakeTheBalanceThatCoversBookedBadDebt() public {
        // A liquidation booked 1,000 of bad debt; the cover in its own transaction failed (e.g. EdgeBidOpen at the
        // time) and the Treasury's balance now equals the hole.
        ledger.setBad(1_000e18);
        assertEq(usd.balanceOf(address(treasury)), 1_000e18);
        assertEq(ledger.totalBadDebt(), 1_000e18);
        // retireReserve refuses while bad debt is booked...
        vm.expectRevert(Treasury.BadDebtOutstanding.selector);
        treasury.retireReserve(1);
        // ...a note redemption should hold for the same reason, or at least leave what the bad debt needs.
        vm.prank(buyer);
        try notes.redeem(1, 1_000e18) {} catch {}
        treasury.coverBadDebt(ILK);
        assertEq(ledger.totalBadDebt(), 0, "bad-debt cover had first call on the Treasury balance");
    }
}
```

### 3. Low: Anyone can burn the Treasury imdUSD that backs matured fee notes through retireReserve, so every note redemption reverts TreasuryShort until the Safe refills

`src/Treasury.sol:137`

```
        if (ledger.totalBadDebt() != 0) revert BadDebtOutstanding();
        uint256 bal = token.balanceOf(address(this));
        ledger.retire(amount < bal ? amount : bal);
```

Q3 and Q4. Treasury.retireReserve is permissionless and burns min(amount, balance, Ledger.reserveBacked) of the Treasury's imdUSD (Ledger.retire, src/Ledger.sol:386-391); it refuses only while bad debt is booked. FeeNotes pays matured notes from the same balance (Treasury.payNote) and FeeNotes.redeem reverts TreasuryShort while the balance is below face. reserveBacked grows with every ERC-4626 yield harvest (src/LedgerBase.sol:380 `reserveBacked += credit`; S-USDS, S-USDC-STEAKHOUSE and wstETH in the beta) and with reserve liquidations (src/LedgerSettlement.sol:184). So with no bad debt booked, any account burns, at gas cost only, the interest the notes were sold against, and every holder's redemption reverts until the Safe funds the Treasury through a 48h payout. No funds are stolen (the burn is against reserve-backed supply), but redemption of notes that the FeeNotes NatSpec says 'waits' only on a short Treasury can be denied at will whenever reserveBacked > 0, and the same call spends the imdUSD that would cover future edge bad debt. The README known issue on retireReserve (line 188) is stale (the code does check bad debt) and does not list this effect. Call sequence: harvests make reserveBacked R > 0 -> a series is sold and matures -> attacker Treasury.retireReserve(type(uint256).max) -> holder FeeNotes.redeem reverts TreasuryShort(0). Reachable with BetaConfig once a note series has been opened (OPEN_NOTE_SERIES) and a yield type has harvested. Smallest fix: burn only the balance above the notes' outstanding face: `uint256 o = INotes(notes).outstanding(); uint256 free = bal > o ? bal - o : 0; ledger.retire(amount < free ? amount : free);`, or make retireReserve governor-only. Proof run (solc 0.8.26 via_ir): forge test --match-path test/scratch/ProofRetireBlocksNotes.t.sol -> [FAIL: TreasuryShort(0)] test_retireReserveBurnsNoteBacking() (gas: 676029).

**Reproduction**

State: reserveBacked = 1,000e18; Treasury balance 1,000e18 of noted interest; totalBadDebt = 0; a series (30 days, 5%) sold 900e18 face for 855 USDC and has matured. Call: attacker -> Treasury.retireReserve(type(uint256).max). Expected: FeeNotes.redeem(1, 900e18) then pays 900e18. Actual: the Treasury balance is 0 and redeem reverts TreasuryShort(0).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Treasury, ICoverable} from "src/Treasury.sol";
import {FeeNotes, ITreasuryNotes} from "src/peg/FeeNotes.sol";

contract Tok is ERC20 {
    uint8 private immutable d;
    constructor(string memory n, uint8 d_) ERC20(n, n) { d = d_; }
    function decimals() public view override returns (uint8) { return d; }
    function mint(address to, uint256 a) external { _mint(to, a); }
    function burnFrom_(address from, uint256 a) external { _burn(from, a); }
}

/// Minimal Ledger: the parts Treasury calls (retire burns the caller's imdUSD against reserveBacked, like Ledger.retire).
contract MiniLedger {
    Tok public token;
    Treasury public treasury;
    uint256 public reserveBacked;
    uint256 public totalBadDebt;
    constructor(Tok t) { token = t; }
    function setTreasury(Treasury t) external { treasury = t; }
    function setReserveBacked(uint256 r) external { reserveBacked = r; }
    function retire(uint256 amount) external {
        if (amount > reserveBacked) amount = reserveBacked;
        reserveBacked -= amount;
        token.burnFrom_(msg.sender, amount);
    }
    function cover(bytes32, uint256) external {}
    function ilkState(bytes32) external pure returns (uint128, uint128, uint256, uint64, uint128) {}
    function mintInterest(bytes32 ilk, uint256 amount) external {
        token.mint(address(treasury), amount);
        treasury.noteRevenue(ilk, amount);
    }
}

contract ProofRetireBlocksNotes is Test {
    Tok imd;
    Tok usdc;
    MiniLedger ledger;
    Treasury treasury;
    FeeNotes notes;
    address buyer = address(0xB0B);
    address attacker = address(0xBAD);

    function setUp() public {
        vm.warp(1_800_000_000);
        imd = new Tok("imdUSD", 18);
        usdc = new Tok("USDC", 6);
        ledger = new MiniLedger(imd);
        treasury = new Treasury(address(0x5AFE), IERC20(address(imd)), address(this));
        ledger.setTreasury(treasury);
        treasury.wire(ICoverable(address(ledger)));
        notes = new FeeNotes(IERC20(address(imd)), IERC20(address(usdc)), ITreasuryNotes(address(treasury)), address(this));
        treasury.wireNotes(address(notes));
    }

    function test_retireReserveBurnsNoteBacking() public {
        // Yield harvests booked 1,000 of reserve-backed supply; 1,000 of interest reached the Treasury.
        ledger.setReserveBacked(1_000e18);
        ledger.mintInterest("ETH", 1_000e18);
        assertEq(notes.capacity(), 2_000e18);

        // A series opens (Parameters) and a buyer pays USDC for 900 face.
        uint256 id = notes.open(30 days, 500, 1_000e18);
        usdc.mint(buyer, 1_000e6);
        vm.startPrank(buyer);
        usdc.approve(address(notes), type(uint256).max);
        notes.buy(id, 900e18);
        vm.stopPrank();

        vm.warp(block.timestamp + 37 days + 1);
        assertEq(imd.balanceOf(address(treasury)), 1_000e18, "Treasury holds the face");

        // No bad debt is booked, so anyone may burn the Treasury's whole balance against reserveBacked.
        vm.prank(attacker);
        treasury.retireReserve(type(uint256).max);

        // Expected: the matured note is still payable from the interest that backed it.
        // Actual: the Treasury balance is 0 and redeem reverts TreasuryShort(0).
        vm.prank(buyer);
        notes.redeem(id, 900e18);
        assertEq(imd.balanceOf(buyer), 900e18);
    }
}
```

### 4. Low: Backstop.deposit(amount, to) lets anyone occupy both of another user's pending slots; while buckets wait, two wei block the victim's own deposits

`src/backstop/Backstop.sol:419`

```
        else if (p[0].amount == 0) p[0] = Pend(uint64(day), a128);
        else if (p[1].amount == 0) p[1] = Pend(uint64(day), a128);
        else revert TooManyPending();
```

Q7. `deposit(amount, to)` credits the two `_pend[to]` slots of any `to`. A third day-bucket for the same user reverts TooManyPending. Under normal conditions this does not block anyone (verified): a same-day deposit merges into the griefer's slot (lines 415-416), and from day D+2 the griefer's bucket converts in `_convert()` and `_realize(to)` frees the slot before the slot logic runs. The specialist's reproduction as written (griefer on D and D+1, victim on D+1) therefore does not reproduce. The block is real only while every bucket waits (`_convert` returns early: a held token unpriced and above DUST_VALUE, `_lossOverRoom()` on the edge sleeve, or the MAX_RATE stop): then a griefer who deposits 1 wei for the victim on two different days keeps the victim's `deposit` reverting TooManyPending until the victim calls `cancelDeposit(2)` themselves, and a griefer watching the mempool can re-fill a freed slot ahead of the victim's next deposit for 1 wei plus gas per attempt. Reachable with BetaConfig (deposits are permissionless) only in those waiting states. Smallest fix: let a deposit for `to != msg.sender` only merge into an existing slot of `to` (or refuse it), or key pending deposits by day so slots are never scarce. Verified with a scratch test (solc 0.8.26, deleted): test_depositForOthers_whileBucketsWait_blocks passes (expectRevert TooManyPending on the victim's deposit after two 1-wei deposits on consecutive days with the sleeve's held token unpriced), and test_depositForOthers_normalConditions_noBlock passes (no revert without the waiting state).

**Reproduction**

State: blue sleeve holding 500 X (worth > DUST_VALUE at its last high) whose feed has gone unpriced, so `_convert` returns early. Day D: griefer deposit(1, victim); day D+1: griefer deposit(1, victim); day D+2: victim deposit(10_000e18, victim). Expected: the victim's deposit lands in day D+2's bucket. Actual: revert TooManyPending; it succeeds only after the victim calls cancelDeposit(2).

### 5. Low: A matured Backstop bucket converts at the first call after maturity, so an idle sleeve's depositor picks the price its deposit converts at

`src/backstop/Backstop.sol:708`

```
        if (h == n || openDays[h] + 2 > today) return;
```

Q7 and Q10. A bucket of day D becomes convertible from the start of day D+2 but converts only when the next deposit, cancelDeposit, realize, requestWithdraw or openBid runs `_convert()`, at the nav then. While nobody calls in, the bucket's owner holds a one-sided option on the held collateral's next price: before a known rise (a pending Chainlink round, a signed swarm attestation anyone may relay) they call realize(self) at the old HIGH price and the update lands on their shares; through a fall they simply do not call and convert afterwards at the lower nav. Existing holders pay the difference, bounded by one oracle step times the collateral share of the pool times the bucket's share. The specialist's claim that the matured bucket can also be cancelled in full does not hold: cancelDeposit runs `_convert()` first and then skips converted buckets, so cancelling a matured bucket reverts NotReady (verified). The README's known issue covers this option only for the booked-loss case. Reachable with BetaConfig on both sleeves whenever they hold bid collateral and no other call touches the sleeve between maturity and the price move. Smallest fix: have the operator's keeper (or the Liquidator's mark) call `realize` at the start of every day so no matured bucket waits; or record the nav at the first `sync`/price read after maturity and convert at that figure. Verified with a scratch test (solc 0.8.26, deleted): cancelDeposit(1_000e18) on a bucket two days old reverts NotReady; the bucket converts in whichever call comes first.

**Reproduction**

Blue sleeve with 500 imdUSD and 500 X at $1 (one holder, alice, 1,000 shares). Day 12: bob deposits 1,000. Day 14, no call since: a +100% X update is pending. Bob calls realize(bob) first: shares = 1,000 x supply / 1,000 nav, then X = $2: nav 2,500, alice 1,250, bob 1,250. Expected (bob converted at the maturity price or after the update): alice 1,500. Actual: 250 moved from alice to bob. Had the update been a fall, bob leaves the bucket unconverted until after it.

### 6. Info: LedgerBase comment says closeBid counts an edge premium that arrives inside a bid; closeBid never counts it, the next sync does

`src/LedgerBase.sol:293`

```
                IEdgeSleeve(edge).sync(); // streams over 7 days; a no-op inside an open bid (closeBid counts it then)
```

Q9. Backstop.sync() returns early while a bid is open (src/backstop/Backstop.sol:595) and Backstop.closeBid (532-563) neither calls sync nor adds the arrival to `tracked`; it only tolerates it in the `usdAfter + repaid < usdBefore` check. The premium the Ledger mints to the edge sleeve during liquidatePosition sits outside `tracked + staged` from closeBid until the next sync() (any deposit, realize, requestWithdraw, withdraw, coverBadDebt, sell, or an explicit sync). No funds are at risk and every exit syncs first; only the comment is wrong. Fix: reword to 'a no-op inside an open bid; the next sync streams it', or call sync() at the end of closeBid after clearing the transient slots. Verified with the specialist's test (solc 0.8.26): [FAIL: closeBid did not fold the mid-bid arrival into the stream: 5050000000000000000000 != 5000000000000000000000] and [PASS] test_nextSyncDoesCountIt.

**Reproduction**

Edge sleeve with 10,000 vested imdUSD; a standing bid repays 5,000 for 2.75 WETH while the Ledger mints a 50 imdUSD premium to the sleeve mid-liquidation. After closeBid: balance 5,050, tracked + staged 5,000. Expected per the comment: 5,050 counted at close. Actual: 5,000; after sync(), tracked + staged = 5,050 and unvested() = 50.

### 7. Info: ImdUSD NatSpec says the keeper minter is set by the timelocked setter; at genesis Parameters.setGenesisKeeper sets it with no timelock

`src/ImdUSD.sol:18`

```
///  - `keeper` is set once by the same timelocked setter: the v4 peg hook (src/peg/PegHook.sol), capped by
```

Q1 (merged from three specialists). ImdUSD.setKeeper only checks msg.sender == bridgeSetter (Parameters). Parameters reaches it from the 48h SET_KEEPER proposal and from setGenesisKeeper (src/Parameters.sol:63-68): the wirer's pre-seal call with no proposal or ETA, which is the path the beta deploy uses. README invariant 8 describes this correctly; the token's own comment claims a 48h-public step the genesis path does not have. keeperCap is 0 at genesis, so no funds are affected. Fix: reword line 18 to 'set once by Parameters: at genesis by the deployer before the seal, otherwise through the timelock'.

**Reproduction**

Deployer calls Parameters.setGenesisKeeper(hook) before Parameters.seal(): ImdUSD.keeper() becomes hook in the same transaction, no Proposed event for SET_KEEPER. Expected per the comment: a timelocked setter. Actual: instant.

### 8. Info: Allocator's IBackstopSale NatSpec describes releaseForSale/settleSale, which do not exist; the real flow is sell + onBackstopSell

`src/peg/Allocator.sol:46`

```
/// Allocator may call either. `releaseForSale` sends `amount` of `collateral` to the Allocator; `settleSale` is called
/// right after with the proceeds already transferred to the Backstop, so it can book them against that collateral.
```

Q11 (merged from two specialists). The interface (lines 48-51) declares only imdUSD() and sell(token, amount, minOut, data). The actual flow is Backstop.sell -> transfer of the collateral to the Allocator -> IBackstopSeller.onBackstopSell callback -> the route pays imdUSD to the sleeve -> the sleeve checks its balance difference against minOut and 98% of the low price. An integrator reading the comment gets the order and the callback wrong. Related behaviour worth stating in the comment: when the route pulls less than `amount`, the unsold collateral stays in the Allocator (Backstop has already debited coll[token]) and only the whole-amount floor protects the sleeve. Fix: rewrite the comment to describe sell and onBackstopSell.

**Reproduction**

grep -rn 'releaseForSale\|settleSale' src/ finds only this comment. Expected: the comment names the interface's functions. Actual: it names two that do not exist.

### 9. Info: Backstop header lists seats under the edge sleeve's standing bid; seats are an auction type no sleeve bids on

`src/backstop/Backstop.sol:16`

```
/// exist per chain: `blue` (ETH, BTC, staked stables) and `edge` (sIMD, seats, stocks). The edge sleeve also burns its
```

Q8 (merged from two specialists). Liquidator.bite returns at `if (c.auction)` (src/Liquidator.sol:154-158) and opens IAuctionJoin before the standing-bid branch, and BetaConfig.backstopSpec lists only SIMD on the edge sleeve. README lines 29-30 say so correctly. Fix: change '(sIMD, seats, stocks)' to '(sIMD, later edge types)'.

**Reproduction**

Mark and bite an unhealthy SEAT position: the Liquidator returns (0,0,0) after IAuctionJoin.open and Backstop.openBid is never called. Expected per the header: the edge sleeve bids. Actual: Dutch auction only.

### 10. Info: Migrator NatSpec: 'toV1 is never blocked while it holds v1' is not true; toV1 is bounded by `outstanding`, not by the v1 held

`src/Migrator.sol:13`

```
/// v1's own vault's risk carried into v2; `toV1` is never blocked while it holds v1. The count is kept
```

Q6. toV1 runs `outstanding -= amount` first (line 45) and reverts with a 0x11 panic whenever amount > outstanding, even while the Migrator holds more v1 than that (v1 donated to it, which the same comment says is deliberately not counted). The behaviour is right (a donation must not create burn or mint room); the sentence is wrong. Fix: 'toV1 is bounded by `outstanding` (the v1 taken in through toV2), never by donated v1'.

**Reproduction**

State: outstanding = 0 and 5e18 v1 transferred to the Migrator. A v2 holder calls toV1(1e18). Expected per the comment: succeeds (it holds v1). Actual: arithmetic underflow at Migrator.sol:45.

### 11. Info: README known issue on Treasury.retireReserve is stale: the code does refuse while bad debt is booked

`README.md:188`

```
- `Treasury.retireReserve` is permissionless and does not check outstanding bad debt.
```

Q3 (merged from three specialists). Treasury.retireReserve (src/Treasury.sol:137) reverts BadDebtOutstanding while ledger.totalBadDebt() != 0, so the accepted risk as written no longer describes the code. The permissionless part still holds and has the fee-note effect reported above (anyone may burn the balance that backs matured notes). Fix: update the entry to 'retireReserve is permissionless (refuses while bad debt is booked); it can burn the balance matured fee notes draw on'.

**Reproduction**

With Ledger.totalBadDebt() == 1 wei, call Treasury.retireReserve(1). README says it burns. Actual: revert BadDebtOutstanding().

---

Judge's submission `a589ff4852dca0711a4e72f89b822f8eee6867275b05269e03449ec4b14e3897`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
