{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"584aadc2-a902-47dd-b639-e46a8223a277","kind":"audit","nodes":[{"acceptedSubmissionHash":"cebdd7364cc796b9324be07f90a4dc5a6124f70d2cf5bddd6d1bf1a6c3d312a8","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"722ba3d9eb1755ada65d02a4f5865e22f9984ba07997fe4995285592ec644f18","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"a589ff4852dca0711a4e72f89b822f8eee6867275b05269e03449ec4b14e3897","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"b4f7eacd2f66a9344f3c71f819bbcf6b51e7e84a11719cb5391e1df968cc89d8","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"6ef97f92303df329a93d27dc17045ade9be4e589aa85f973cd5747b6ca67ea8e","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"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.\n\nimdUSD 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.\n\nBUILD 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).\n\nThis panel covers two areas whose money flows meet: give both the same depth, and answer every question.\n\nAnswer each numbered question, including the ones where nothing is wrong:\n1. 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().\n2. EIP-3009 AND PERMIT. Replay across chains or contracts, a front-run cancel, a nonce reused after cancel.\n3. 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.\n4. FEE NOTES (open, buy, redeem, capacity, outstanding). Sell notes past the revenue cover, redeem early or twice, or block others' redemption.\n5. SAVINGS VAULT (sync, the stream, same-block deposits, donations). Share-price inflation or a deposit that captures earlier savers' stream.\n6. MIGRATOR (v1 to v2 and back, the migrator line). Mint v2 without burning v1, or the reverse; exceed the line.\n7. 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.\n8. 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.\n9. 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.\n10. 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.\n11. 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.\n12. 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.\n\nNot 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.\n\nFor 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.","parentJobId":null,"planHash":"d6cc002c5b40446b62164cd4dca9df8bd9ea8c542b08da5728ecacb8e965ca82","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"584aadc2-a902-47dd-b639-e46a8223a277","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51332","feedbackHash":"d4873a886c232128ecd669ecd54baeb83fcb31cda89eee4b290d0babd4986f3f","nodeKey":"audit_economics","submissionHash":"cebdd7364cc796b9324be07f90a4dc5a6124f70d2cf5bddd6d1bf1a6c3d312a8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52669","feedbackHash":"301e4053a71a89bec4ca0c54aa7bb141c7857e5725a6b53c992b616a43a9aab1","nodeKey":"audit_flow","submissionHash":"722ba3d9eb1755ada65d02a4f5865e22f9984ba07997fe4995285592ec644f18","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51060","feedbackHash":"8df1f3194d51c7551109f6ab202c715516023d20f0d6e7b37368e668fea227e3","nodeKey":"audit_judge","submissionHash":"a589ff4852dca0711a4e72f89b822f8eee6867275b05269e03449ec4b14e3897","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52160","feedbackHash":"7c0397dcef376b1cbaed7e3983981c165b36ecab62c6699a964959d0a66dcd9a","nodeKey":"audit_math","submissionHash":"b4f7eacd2f66a9344f3c71f819bbcf6b51e7e84a11719cb5391e1df968cc89d8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51884","feedbackHash":"14cf3ad1df4c030647eda13d29c65649aa730a6f04beea9ab67976dbee541220","nodeKey":"audit_permissions","submissionHash":"6ef97f92303df329a93d27dc17045ade9be4e589aa85f973cd5747b6ca67ea8e","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"1814fc05246414e1bb238730d63e08d9c661fe5f1ecbb7522054364e1cf01b97","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4aa8623bfb8d8e5e","findings":[{"citation":"resolved","description":"`circulating()` subtracts the flash loans in flight from `totalSupply()` so a flash mint cannot dilute the Redeemer's fee ramp (Redeemer.sol:146,153) or the savings uptake (LedgerBase.sol:277). But the counter is not reduced when flash-minted tokens are burned by a minter inside the loan. A borrower can flash-mint D (<= flashCap, 100k in BetaConfig), repay its own debt D through `Ledger.adjust` (burns D: totalSupply falls by D, flashOutstanding stays D), then call `Redeemer.redeem` on another position of the type: `supplyBefore = circulating() + redeemed` now reads the real supply minus D, so the base-rate rise `redeemed / supplyBefore / divisor` is multiplied by supply/(supply - D). The borrower then re-borrows D (cost: 7 days of interest on D as the upfront fee, about 86 imdUSD on 90k at 5%) and repays the flash loan. With 90k of a 100k supply the proof shows a 5k redemption lifting the ETH base rate to 3.12% instead of 0.31% (10x, divisor 16 as in BetaConfig); on sIMD (divisor 2) the same trick pins the fee at the 5% cap. The rate decays with a 12h half-life, so redemption arbitrage on the type is priced out for many hours and the attacker's own lowest-rate position is shielded from redemption: the known issue 'An honest large sIMD redemption can hold the fee at its 5% cap for hours' becomes reachable by a small redemption with no capital. The same deflation inflates `uptake = saved / supply` in `_mintInterest` (LedgerBase.sol:278) for an accrual triggered in the loan, moving that accrual's split toward savers up to `savingsShare`. `circulating()` cannot underflow (every burnable amount is part of the pre-loan supply) but it can read 0, at which `_fee` returns FEE_CAP. Reachable with BetaConfig (flashCap 100k, chain cap 300k, ETH divisor 16, sIMD divisor 2). Smallest fix: snapshot the supply at the first flash loan of the transaction in a transient slot and return `max(totalSupply() - flashOutstanding(), snapshot)` from `circulating()` while a loan is in flight (a real mint inside a loan is then not counted, which is the conservative direction), or have minter burns inside a flash loan reduce `flashOutstanding` by `min(amount, flashOutstanding)` (burning first what was flash-minted).","line":144,"path":"src/ImdUSD.sol","reproduction":"Setup (proof): ETH type, redeemable, redeemDivisor 16, flashCap 100k; bob borrows 10k, attacker contract borrows 90k (supply about 100,096 with upfront fees), 10 days after the seal so the launch base rate has decayed. Call: attacker.run(90_000e18, 5_000e18) -> usd.flashLoan(90k) -> in onFlashLoan: ledger.adjust(id, repay-all) [burns 90,086]; redeemer.redeem(ETH, 5_000e18, 0.05e18, 10, 0); ledger.adjust(id, +90k); flash repaid. Expected: Redeemer.decayedBaseRate(ETH) rises by 5,000 / 100,009 / 16 = 0.312%. Actual: circulating() reads 10,009 inside the loan and 5,009 after the burn, supplyBefore = 10,009, base rate 3.122%. Output: `[FAIL: base rate rose far more than the redemption's share of the real supply: 31220110637048240 > 6244012590666484]` with logs `circulating() inside the loan 10009589041095890410959`, `honest rise 0.003122006295333242`, `actual rise 0.031220110637048240`. Run: forge test --match-path test/scratch/ProofCirculating.t.sol -vv (solc 0.8.26 via_ir).","severity":"medium","snippet":"    function circulating() external view returns (uint256) {\n        return totalSupply() - flashOutstanding();\n    }","title":"circulating() is deflated by burning flash-minted imdUSD (repay in a flash loan), letting a borrower spike the redemption base rate and the savings uptake with no capital"},{"citation":"resolved","description":"Treasury.sol:134-137 refuses `retireReserve` while `ledger.totalBadDebt() != 0` because 'the same balance is what covers it', and Treasury.sol:49 states 'bad-debt cover keeps first call on the balance'. The note path has no such gate: `FeeNotes.redeem` -> `Treasury.payNote` moves imdUSD out of the Treasury whenever the series has matured and the balance covers the face, including while `totalBadDebt() != 0`. A matured note holder who sees a liquidation that will book bad debt (or who simply acts after one whose cover was best-effort and failed: README 'Cover gas', 'an empty Treasury') takes the Treasury's imdUSD ahead of the cover, so the unbacked-supply brake stays on for every type until someone else funds the Treasury. Not reachable with BetaConfig as deployed (no fee-note series is open; one opens only through OPEN_NOTE_SERIES), so this is a design inconsistency to fix before the first series. Fix: in `Treasury.payNote` (or `FeeNotes.redeem`) add `if (ledger.totalBadDebt() != 0) revert BadDebtOutstanding();`, mirroring `retireReserve`; or state in the NatSpec that notes rank ahead of cover.","line":148,"path":"src/peg/FeeNotes.sol","reproduction":"State: a series matured with 1,000 face held by H; Treasury holds 1,000 imdUSD; Ledger.totalBadDebt() = 1,000 (a liquidation booked it and the Treasury cover ran out of gas or the sleeve was mid-bid). Call: H -> FeeNotes.redeem(id, 1000e18). Expected (per Treasury.sol:49 and the retireReserve rule): revert, the balance is for the cover. Actual: succeeds, Treasury balance 0, bad debt still 1,000, Treasury.coverBadDebt(ilk) now covers 0 and Ledger._checkBrake keeps refusing new debt.","severity":"low","snippet":"        uint256 bal = token.balanceOf(address(treasury));\n        if (bal < face) revert TreasuryShort(bal);\n        _burn(msg.sender, id, face);\n        outstanding -= face;\n        treasury.payNote(msg.sender, face);","title":"FeeNotes.redeem pays note holders from the Treasury while bad debt is booked, unlike retireReserve"},{"citation":"resolved","description":"`deposit(amount, to)` credits the pending slots of any `to`. A griefer who deposits 1 wei for a victim on two different days occupies both of the victim's `_pend` slots; the victim's own `deposit` then reverts `TooManyPending` until one of those buckets converts and `_realize` frees a slot (two days at best; indefinitely while `_lossOverRoom()` or an unpriced holding holds every bucket, since `_convert` returns early). Repeating 1 wei per day keeps one slot occupied for ever. The victim can recover by calling `cancelDeposit(2)` (it drains the griefer's wei from the victim's slots) in the same transaction as the deposit, but a griefer who watches the mempool re-fills a slot ahead of it. Cost to the griefer: gas plus 1 wei per day. Reachable with BetaConfig (deposits are permissionless). Fix: only allow `to == msg.sender` to create a new slot, or let a deposit for `to` merge into an existing slot only; or size `_pend` as a mapping keyed by day so slots are never scarce.","line":414,"path":"src/backstop/Backstop.sol","reproduction":"Day D: griefer calls deposit(1, victim); day D+1: deposit(1, victim). Day D+1, victim calls deposit(10_000e18, victim). Expected: the deposit lands in the day D+1 bucket. Actual: revert TooManyPending (slot 0 holds (D, 1), slot 1 holds (D+1, 1)). If the edge sleeve has booked bad debt above its room (`_lossOverRoom()`), the D bucket never converts and the victim stays blocked until they cancel the griefer's 2 wei themselves.","severity":"low","snippet":"        Pend[2] storage p = _pend[to];\n        if (p[0].day == day && p[0].amount != 0) p[0].amount += a128;\n        else if (p[1].day == day && p[1].amount != 0) p[1].amount += a128;\n        else if (p[0].amount == 0) p[0] = Pend(uint64(day), a128);\n        else if (p[1].amount == 0) p[1] = Pend(uint64(day), a128);\n        else revert TooManyPending();","title":"Backstop.deposit to an arbitrary `to` lets anyone fill a user's two pending slots and block their deposits"},{"citation":"resolved","description":"Treasury.sol:136-137 reads `if (ledger.totalBadDebt() != 0) revert BadDebtOutstanding();`, so the accepted risk as written no longer describes the code. The remaining accepted part (permissionless: anyone can burn the whole Treasury imdUSD balance against `reserveBacked` at any moment with no bad debt booked, which empties the balance that FeeNotes.redeem and the next liquidation's cover draw on) is still true and is a trust assumption worth keeping in the list; the 'does not check outstanding bad debt' clause should be dropped. Not a code defect.","line":188,"path":"README.md","reproduction":"State: Ledger.totalBadDebt() = 1 wei, Treasury holds 1,000 imdUSD, reserveBacked = 1,000. Call: anyone -> Treasury.retireReserve(1000e18). README says it succeeds; actual: revert BadDebtOutstanding().","severity":"info","snippet":"- `Treasury.retireReserve` is permissionless and does not check outstanding bad debt.","title":"README known-issue entry for Treasury.retireReserve is stale: the code does refuse while bad debt is booked"},{"citation":"resolved","description":"`setKeeper` only checks `msg.sender == bridgeSetter` (Parameters). Parameters reaches it from the 48h SET_KEEPER proposal (ParametersRules.sol:267-269) and also from `Parameters.setGenesisKeeper` (Parameters.sol:63), the deployer's pre-seal call that README invariant 8 describes ('set by the deployer before the seal'). The token's comment names only the timelocked path. Since the slot is set once and the beta deploy uses the genesis path, a reader of the token alone would expect the keeper to have been public for 48h, which it was not. Documentation only; the README's 'Minters set once' invariant holds in code (`AlreadySet` on every slot).","line":18,"path":"src/ImdUSD.sol","reproduction":"Deployment with BetaConfig: usd.keeper() is the PegHook right after Parameters.seal(), with no SET_KEEPER proposal ever having been queued (no Proposed event for kind SET_KEEPER).","severity":"info","snippet":"///  - `keeper` is set once by the same timelocked setter: the v4 peg hook (src/peg/PegHook.sol), capped by\n///    `Parameters.keeperCap`.","title":"ImdUSD NatSpec says the keeper minter is set 'by the same timelocked setter'; Parameters also has an untimelocked genesis path (setGenesisKeeper) before the seal"}],"hash":"6ef97f92303df329a93d27dc17045ade9be4e589aa85f973cd5747b6ca67ea8e","nodeId":"4936fd0d-169e-4916-ba28-156b9cb74165","outcome":"completed","summary":"The findings file holds 5 entries (1 medium with a proof that fails on this commit, 2 low, 2 info), the scratch test is deleted and the tree is otherwise unchanged. Here is the review.\n\n## Summary\n\nThe permissions panel (Access Control, Trust Gap, Asymmetry) over `ImdUSD`, `SavingsVault`, `Treasury`, `FeeNotes`, `Migrator`, `Backstop`, `PricerOracle` and `Allocator` found one substantiated medium, two lows and two documentation mismatches. No critical or high. The proof was run with solc 0.8.26 via_ir as the build instructions require (`forge test --match-path test/scratch/ProofCirculating.t.sol -vv`), failed for the stated reason, and its full source is embedded in `.imd-findings.json`. I did not run the six-suite panel glob: every build on this machine took 70 to 360 seconds, so I spent the budget on the proof and on reading.\n\n## Findings\n\n1. **Medium. `circulating()` can be deflated inside a flash loan** (`src/ImdUSD.sol:144`). A minter burn of flash-minted tokens lowers `totalSupply` but not `flashOutstanding`. A borrower flash-mints D, repays its own debt D, redeems a small amount while `circulating()` reads supply minus D, reborrows D and repays the loan. The Redeemer's base-rate rise is multiplied by supply/(supply minus D). Proof output: a 5k redemption lifted the ETH base rate to 3.12% instead of 0.31%. On sIMD (divisor 2) it pins the 5% cap. The same read inflates the savings uptake in `_mintInterest`. Reachable with BetaConfig (flash cap 100k). Fix: snapshot the supply at the first flash loan and return the max of the two while a loan is in flight.\n2. **Low. `FeeNotes.redeem` pays notes while bad debt is booked** (`src/peg/FeeNotes.sol:148`). `retireReserve` refuses in that state and Treasury.sol:49 claims cover \"keeps first call\", but `payNote` has no gate. Dormant in the beta (no series open).\n3. **Low. `Backstop.deposit(amount, to)` fills a victim's two pend slots** (`src/backstop/Backstop.sol:414`). Two 1-wei deposits on different days make the victim's own deposits revert `TooManyPending`; indefinitely while buckets wait. Victim can self-heal with `cancelDeposit(2)` but can be front-run.\n4. **Info.** README line 188 says `retireReserve` does not check bad debt; the code does.\n5. **Info.** ImdUSD.sol:18 says the keeper is set by the timelocked setter only; `Parameters.setGenesisKeeper` is an untimelocked pre-seal path the beta uses.\n\n## Answers to the twelve questions\n\n1. **Minters and supply.** Holds. Every `burn(from)` call site burns `msg.sender`'s own balance on an action it initiated: `Ledger.cover`, `retire`, `adjust` (actor), `burnFrom` (Redeemer's caller), `Migrator.toV1`, PegHook (itself). `circulating()` cannot underflow because every burnable amount is part of the pre-loan supply, but it can be driven toward zero (finding 1).\n2. **EIP-3009 and permit.** Holds. Domain separator includes chainId and address; nonces are per authorizer, set true on use or cancel, so no reuse after cancel. A cancel can be front-run by the transfer, which is inherent to EIP-3009; `receiveWithAuthorization` binds the submitter to the payee.\n3. **Treasury.** Ordering holds: `coverBadDebt` calls the edge sleeve first and only refusals (UnknownIlk, zero) fall through. Only `payNote` and the timelocked `_apply` move funds out; `payNote` is gated to the notes contract. Finding 2 is the one way the balance leaves ahead of a booked cover. The revenue meter rolls correctly, including the 90-day wrap. `surplusOf` is informational only.\n4. **Fee notes.** Capacity is checked at every buy against 2x trailing revenue plus the per-series cap; redemption needs maturity and burns the holder's balance, so no double redeem. Blocking others' redemption needs the Treasury balance to fall: `retireReserve` (permissionless, known) and `coverBadDebt` can do that by design.\n5. **Savings vault.** Holds. Donations stream, the deposit/withdraw netting of `depositedAt` cannot raise `settledAssets` above the pre-block figure in any ordering I could const","treeHash":null,"usage":{"cachedInputTokens":2473921,"cost":{"source":"runtime","usdMicros":7351701},"inputTokens":514,"model":"claude-fable-5-1","outputTokens":59055,"runtime":"claude","turns":47,"wallClockMs":1872535}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7cb215928f3c9473","findings":[{"citation":"resolved","description":"FeeNotes.redeem checks only that the Treasury holds `face` (FeeNotes.sol:148-152), and Treasury.payNote (Treasury.sol:184) pays any amount the notes contract asks for. Neither looks at Ledger.totalBadDebt. Treasury.sol:46-49 says 'nothing here is earmarked for notes, so bad-debt cover keeps first call on the balance', and retireReserve (Treasury.sol:136-137) refuses to spend this same balance while bad debt is booked ('the same balance is what covers it'). The note path has no such guard. The race happens whenever a hole stays booked: a liquidation's in-transaction cover found the Treasury short or failed (EdgeCoverFailed, a sleeve mid-bid, under-gassed best effort), and interest then arrives in the Treasury before anyone calls the permissionless coverBadDebt. A matured note holder takes that imdUSD first, and the hole stays in `unbacked()`, which drives the global brake. Call sequence: Parameters OPEN_NOTE_SERIES (timelocked) -> buyer FeeNotes.buy -> a liquidation books bad debt it cannot cover -> Ledger._mintInterest mints revenue to the Treasury -> holder FeeNotes.redeem(id, face) succeeds -> a later Treasury.coverBadDebt finds less. Reachability in BetaConfig: FeeNotes is deployed dormant, so this needs a governed series to be opened first. Smallest fix: in FeeNotes.redeem (or Treasury.payNote), revert while ICoverable(ledger).totalBadDebt() != 0, as retireReserve does. Alternatively let payNote spend only the balance above totalBadDebt.","line":148,"path":"src/peg/FeeNotes.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Treasury, ICoverable} from \"src/Treasury.sol\";\nimport {FeeNotes, ITreasuryNotes} from \"src/peg/FeeNotes.sol\";\n\ncontract PToken is ERC20 {\n    uint8 private immutable dec;\n    constructor(string memory n, uint8 d) ERC20(n, n) { dec = d; }\n    function decimals() public view override returns (uint8) { return dec; }\n    function mint(address to, uint256 a) external { _mint(to, a); }\n    function burn(address from, uint256 a) external { _burn(from, a); }\n}\n\ncontract PParams {\n    function edgeIlk(bytes32) external pure returns (bool) { return false; }\n    function edgeBackstop() external pure returns (address) { return address(0); }\n}\n\n/// The Ledger's surface the Treasury and a sleeve use: bad debt per type, cover (burns the caller's imdUSD), revenue.\ncontract PLedger {\n    PToken public usd;\n    PParams public p = new PParams();\n    mapping(bytes32 => uint128) public bad;\n    uint256 public totalBadDebt;\n    constructor(PToken u) { usd = u; }\n    function params() external view returns (address) { return address(p); }\n    function setBad(bytes32 ilk, uint128 b) external { totalBadDebt = totalBadDebt + b - bad[ilk]; bad[ilk] = b; }\n    function ilkState(bytes32 ilk) external view returns (uint128, uint128, uint256, uint64, uint128) {\n        return (0, 0, 0, 0, bad[ilk]);\n    }\n    function cover(bytes32 ilk, uint256 amount) external {\n        if (amount > bad[ilk]) amount = bad[ilk];\n        bad[ilk] -= uint128(amount);\n        totalBadDebt -= amount;\n        usd.burn(msg.sender, amount);\n    }\n    function retire(uint256) external {}\n    function payInterest(Treasury t, bytes32 ilk, uint256 amount) external {\n        usd.mint(address(t), amount);\n        t.noteRevenue(ilk, amount);\n    }\n}\n\ncontract ProofNotesBeforeBadDebt is Test {\n    bytes32 constant ILK = \"EDGE-A\";\n\n    /// Q3/Q4: a matured note is paid out of the Treasury's imdUSD while bad debt is booked and uncovered, so the\n    /// balance that should have covered the hole leaves first (retireReserve refuses exactly this case).\n    function test_noteRedeemedAheadOfBookedBadDebt() public {\n        PToken usd = new PToken(\"imdUSD\", 18);\n        PToken usdc = new PToken(\"USDC\", 6);\n        PLedger ledger = new PLedger(usd);\n        Treasury t = new Treasury(address(0xBEEF), IERC20(address(usd)), address(this));\n        t.wire(ICoverable(address(ledger)));\n        FeeNotes notes = new FeeNotes(IERC20(address(usd)), IERC20(address(usdc)), ITreasuryNotes(address(t)), address(this));\n        t.wireNotes(address(notes));\n\n        ledger.payInterest(t, ILK, 600e18); // revenue 600 -> note capacity 1,200\n        uint256 id = notes.open(30 days, 500, 1_000e18);\n        address buyer = address(0xB0B);\n        usdc.mint(buyer, 1_000e6);\n        vm.startPrank(buyer);\n        usdc.approve(address(notes), type(uint256).max);\n        notes.buy(id, 500e18);\n        vm.stopPrank();\n\n        // A liquidation books 1,000 of bad debt; its in-transaction cover found the Treasury empty of imdUSD beyond the\n        // 600 (here: nobody has called coverBadDebt yet). The note matures.\n        ledger.setBad(ILK, 1_000e18);\n        vm.warp(1 + 37 days + 1);\n\n        // Expected: the 600 imdUSD the Treasury holds goes to the booked hole first (as retireReserve's guard and the\n        // NatSpec \"bad-debt cover keeps first call on the balance\" say). Actual: the note holder takes 500 of it.\n        vm.prank(buyer);\n        (bool ok,) = address(notes).call(abi.encodeCall(FeeNotes.redeem, (id, 500e18)));\n        if (ok) {\n            t.coverBadDebt(ILK);\n            emit log_named_uint(\"bad debt left after cover (expected 400e18)\", ledger.totalBadDebt());\n        }\n        assertFalse(ok, \"note paid while bad debt is booked and uncovered\");\n    }\n}","reproduction":"Run test/scratch/ProofNotesBeforeBadDebt.t.sol with `forge test --match-path test/scratch/ProofNotesBeforeBadDebt.t.sol -vv`. State: 600 imdUSD of revenue noted to the Treasury (note capacity 1,200). Series opened at 30 days / 5% discount, 500 face bought. 1,000 of bad debt booked on the ilk. Time warped past maturity. Expected: redeem(id, 500e18) reverts until the hole is covered, so coverBadDebt burns all 600 and leaves 400 of bad debt. Actual: redeem pays 500, the cover burns 100, and 900e18 of bad debt is left. Output on this commit: `[FAIL: note paid while bad debt is booked and uncovered] test_noteRedeemedAheadOfBookedBadDebt()`, log `bad debt left after cover (expected 400e18): 900000000000000000000`. The test passes once redeem refuses while totalBadDebt != 0.","severity":"low","snippet":"        uint256 bal = token.balanceOf(address(treasury));","title":"FeeNotes.redeem / Treasury.payNote pay a matured note out of the Treasury's imdUSD while bad debt is booked and uncovered"},{"citation":"resolved","description":"The NatSpec says 'NO JIT' and that entries are priced so that 'a newcomer can only overpay' (Backstop.sol:26-35). A bucket becomes convertible from day D+2, but it converts only at the first _convert call after that (deposit, cancelDeposit, realize, requestWithdraw, openBid). Until then, cancelDeposit (line 424, `if (b.converted) continue;` at 435) refunds it in full. When the sleeve is quiet, the owner of a matured bucket holds an option on the held collateral's next price. Before a known rise (a pending Chainlink round, or a signed swarm attestation; relaying is permissionless and the cap allows 20% per epoch for sIMD) they call realize(self) at the old HIGH price, then the update lands. Before a known fall they call cancelDeposit. Either way the existing holders pay. The README's known issue covers this free option only for the booked-loss case ('A deposit that waits past maturity holds a free option'). It applies to every price move of held collateral, so it is broader than written. Impact is bounded by one oracle step times the share of the pool held in collateral, and by the collateral being sold off. Reachable with BetaConfig on both sleeves whenever they hold bid collateral. Possible fix: refuse cancelDeposit for buckets with day + 2 <= today, so a matured bucket is converted at whatever moment anyone first calls in rather than being freely withdrawable. Converting matured buckets at the first price read after maturity, rather than at an owner-chosen call, removes the timing too.","line":448,"path":"src/backstop/Backstop.sol","reproduction":"Mock oracle (low = high), blue-type sleeve. Day 10: Alice deposits 1,000. Day 12: realize(Alice). A bid then spends 500 imdUSD for 500 X at $1. Bob deposits 1,000 on day 12. Day 14 (matured, no other calls): Bob calls realize(bob) at X = $1, then X updates to $2. nav = 2,500; Alice's shares are worth 1,249.99 and Bob's 1,249.99. If Bob had entered at the post-update price (or Alice had been alone through the move), Alice would hold 1,500, so 250 moved from Alice to Bob. Had X been about to fall instead, Bob would have called cancelDeposit(1,000e18) and got all of it back. Forge output from a scratch test of exactly this sequence (deleted afterwards): `[FAIL: existing holder lost the gain to the timed conversion: 1249999999999999999999 < 1499000000000000000000]`, logs `nav after update: 2500000000000000000000`, `alice value: 1249999999999999999999`, `bob value (deposited 1,000): 1249999999999999999999`.","severity":"low","snippet":"    function realize(address user) external nonReentrant notInBid {","title":"A matured Backstop bucket converts at a moment its owner picks, and can be cancelled in full until then: a timed entry around price updates"},{"citation":"resolved","description":"ImdUSD.setKeeper is reachable only from bridgeSetter (Parameters), but Parameters reaches it only through setGenesisKeeper (Parameters.sol:63-68): the wirer calls it before the seal, with no 48h timelock. The README (invariant 8) describes this correctly ('set by the deployer before the seal'). The token's NatSpec claims a timelock the code does not apply. Fix the comment.","line":18,"path":"src/ImdUSD.sol","reproduction":"Parameters.setGenesisKeeper(keeper) called by `wirer` with sealed_ == false calls ImdUSD.setKeeper(keeper) immediately, with no proposal or ETA. Expected per the NatSpec: a 48h timelocked proposal. Actual: instant.","severity":"info","snippet":"///  - `keeper` is set once by the same timelocked setter: the v4 peg hook (src/peg/PegHook.sol), capped by","title":"ImdUSD NatSpec: the keeper is said to be set by the timelocked setter, but it is set at genesis by the wirer, with no timelock"},{"citation":"resolved","description":"The interface declares only `imdUSD()` and `sell(...)`. The actual flow is Backstop.sell -> transfer -> IBackstopSeller.onBackstopSell callback -> balance-difference settlement. The comment describes a two-call API that is not there. Also related: when the route pulls less than `amount`, the unsold collateral stays in the Allocator (Backstop has already debited coll[token]). The sleeve is protected only by the 98%-of-low floor on the whole amount.","line":46,"path":"src/peg/Allocator.sol","reproduction":"grep for releaseForSale/settleSale in src/: they appear only in this comment. Expected: the comment describes sell + onBackstopSell. Actual: it names functions that do not exist.","severity":"info","snippet":"/// Allocator may call either. `releaseForSale` sends `amount` of `collateral` to the Allocator; `settleSale` is called","title":"Allocator IBackstopSale NatSpec names functions that do not exist (releaseForSale / settleSale)"},{"citation":"resolved","description":"Liquidator.bite returns at `if (c.auction)` before the standing-bid branch, and the README says 'Seats are an auction type: no sleeve bids on them'. The contract header says the edge sleeve covers seats.","line":16,"path":"src/backstop/Backstop.sol","reproduction":"Liquidator.bite for a seat position: c.auction is true, so IAuctionJoin.open runs and openBid is never called. Expected per the NatSpec: the edge sleeve bids. Actual: Dutch auction only.","severity":"info","snippet":"/// exist per chain: `blue` (ETH, BTC, staked stables) and `edge` (sIMD, seats, stocks). The edge sleeve also burns its","title":"Backstop NatSpec lists seats as an edge-sleeve bid type, but seats are auction types that no sleeve bids on"}],"hash":"722ba3d9eb1755ada65d02a4f5865e22f9984ba07997fe4995285592ec644f18","nodeId":"59b49125-0188-46e7-9f37-941ff0d16660","outcome":"completed","summary":"I found no critical, high or medium issues in this scope. There are two low findings and three wrong comments. All five are in `.imd-findings.json`; only the first finding carries a proof.\n\n**Checks run**\n- **Panel suites:** the required six-suite glob ran and all 142 tests passed.\n- **Low 1 proof:** fails on this commit, as it should.\n- **Low 2 test:** also fails on this commit. It is attached as numbers only, because no small fix would make it pass.\n- **Scratch tests:** deleted; `git status` shows the tree unchanged.\n- **Not run:** the full project and `--use solar` (this machine has Foundry 1.8.3).\n- **Passes made:** the control-flow review (Execution Trace, Periphery, First Principles) was done by me alone, without helpers.\n\n## Findings\n\n**Low 1: a matured fee note is paid while bad debt is booked** (`src/peg/FeeNotes.sol:148`, `Treasury.payNote` at `src/Treasury.sol:184`)\n- `redeem` only checks that the Treasury holds the face amount. It never checks `totalBadDebt`, though `retireReserve` refuses that same case.\n- `Treasury.sol:49` says bad-debt cover \"keeps first call on the balance\"; the code does not enforce it.\n- **Reproduction:** 600 imdUSD revenue, 500 face of notes sold, 1,000 bad debt booked. Expected: redeem waits and the cover leaves 400 uncovered. Actual: the note is paid and 900 stays uncovered.\n- **Fix:** revert in `redeem` (or `payNote`) while `totalBadDebt != 0`.\n- **Beta reachability:** FeeNotes is deployed dormant, so this needs a series opened through the 48h timelock first.\n\n**Low 2: a matured Backstop deposit converts when its owner chooses, and can be cancelled in full until then** (`src/backstop/Backstop.sol:448`, `cancelDeposit` at 424/435)\n- In a quiet sleeve, the owner can convert just before a known price rise or cancel before a fall.\n- **Measured:** the test shows 250 of 1,500 moving from the existing holder to the newcomer on a +100% move of collateral bought at $1.\n- **Known issue:** the README lists this free option only for the booked-loss case, so it is broader than written.\n- **Fix:** refuse `cancelDeposit` once a bucket has matured.\n\n## The 12 questions\n1. **Minters and supply: holds.**\n   - Every mint and burn has a matching liability: Ledger debt, interest, bad-debt cover and reserve retirement; Migrator `outstanding`; the keeper's own buy and sell.\n   - The burn-from-anyone power is used only on the caller's own funds: the repayer, the liquidation payer, the Redeemer's `msg.sender`, the Migrator's `msg.sender`.\n   - `circulating()` cannot underflow. Every burn inside a flash loan cancels a liability that is already part of the non-flash supply, so `totalSupply` stays at or above `flashOutstanding`.\n   - The flash cap applies per transaction, and nested loans restore the counter correctly.\n2. **EIP-3009 and permit: holds.**\n   - The OpenZeppelin EIP-712 domain includes chain id and contract address, so signatures cannot be replayed across chains or contracts.\n   - Transfer, receive and cancel share one nonce space per authoriser. A cancelled nonce stays set forever, so it cannot be reused.\n   - Only the payee can submit a receive authorisation. A front-run cancel only cancels what the authoriser signed.\n3. **Treasury: one finding (Low 1).**\n   - Edge bad debt is covered sleeve first, then Treasury. Any sleeve revert other than `UnknownIlk` makes the Treasury's cover revert (fail closed).\n   - The only payouts are the 48h timelocked `_apply` and `payNote`, which only the notes contract can call.\n   - The revenue meter rolls its 90-day window correctly.\n   - `surplusOf` is bookkeeping only.\n   - The README is out of date: `retireReserve` now does check outstanding bad debt.\n4. **Fee notes: holds, apart from Low 1.**\n   - Sales are capped at 2× the 90-day revenue.\n   - A note cannot be redeemed early (maturity check) or twice (it is burned first), and notes cannot be transferred.\n   - Redemption can only be blocked by an empty Treasury, which fails closed.\n5. **Savings vault: holds.**\n   -","treeHash":null,"usage":{"cachedInputTokens":3073913,"cost":{"source":"runtime","usdMicros":2810755},"inputTokens":57,"model":"claude-opus-5-5","outputTokens":52481,"runtime":"claude","turns":32,"wallClockMs":1138918}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"312a95392d3e8a75","findings":[{"citation":"resolved","description":"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.","line":145,"path":"src/ImdUSD.sol","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).","severity":"medium","snippet":"        return totalSupply() - flashOutstanding();","title":"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"},{"citation":"resolved","description":"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().","line":148,"path":"src/peg/FeeNotes.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Treasury, ICoverable} from \"src/Treasury.sol\";\nimport {FeeNotes, ITreasuryNotes} from \"src/peg/FeeNotes.sol\";\n\ncontract NUSD is ERC20 {\n    constructor() ERC20(\"imdUSD\", \"imdUSD\") {}\n    function mint(address to, uint256 a) external { _mint(to, a); }\n    function burn(address from, uint256 a) external { _burn(from, a); }\n}\n\ncontract NUSDC is ERC20 {\n    constructor() ERC20(\"USDC\", \"USDC\") {}\n    function decimals() public pure override returns (uint8) { return 6; }\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\n/// @dev The Ledger as the Treasury sees it: books bad debt, burns the coverer's imdUSD, reports interest.\ncontract NLedger is ICoverable {\n    NUSD public usd;\n    Treasury public treasury;\n    uint128 public bad;\n    constructor(NUSD u) { usd = u; }\n    function setTreasury(Treasury t) external { treasury = t; }\n    function setBad(uint128 b) external { bad = b; }\n    function totalBadDebt() external view returns (uint256) { return bad; }\n    function cover(bytes32, uint256 amount) external {\n        if (amount > bad) amount = bad;\n        bad -= uint128(amount);\n        usd.burn(msg.sender, amount);\n    }\n    function retire(uint256) external {}\n    function ilkState(bytes32) external view returns (uint128, uint128, uint256, uint64, uint128) { return (0, 0, 0, 0, bad); }\n    // Treasury.coverBadDebt reads ledger.params().edgeIlk(ilk): this contract answers both, with no edge types.\n    function params() external view returns (address) { return address(this); }\n    function edgeIlk(bytes32) external pure returns (bool) { return false; }\n    function edgeBackstop() external pure returns (address) { return address(0); }\n    /// @dev Interest minted to the Treasury and reported to its meter, as LedgerBase._mintInterest does.\n    function pay(bytes32 ilk, uint256 amount) external {\n        usd.mint(address(treasury), amount);\n        treasury.noteRevenue(ilk, amount);\n    }\n}\n\n/// Treasury.sol says: \"nothing here is earmarked for notes, so bad-debt cover keeps first call on the balance\", and\n/// `retireReserve` refuses while `totalBadDebt != 0` for that reason. A matured note redeems the same balance with no\n/// such check: the note holder is paid first and the booked bad debt stays uncovered.\ncontract ProofNoteRedeemAheadOfBadDebt is Test {\n    NUSD usd;\n    NUSDC usdc;\n    NLedger ledger;\n    Treasury treasury;\n    FeeNotes notes;\n    address governor = makeAddr(\"governor\");\n    address buyer = makeAddr(\"buyer\");\n    bytes32 constant ILK = \"ETH\";\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        usd = new NUSD();\n        usdc = new NUSDC();\n        ledger = new NLedger(usd);\n        treasury = new Treasury(governor, IERC20(address(usd)), address(this));\n        ledger.setTreasury(treasury);\n        treasury.wire(ICoverable(address(ledger)));\n        notes = new FeeNotes(IERC20(address(usd)), IERC20(address(usdc)), ITreasuryNotes(address(treasury)), address(this));\n        treasury.wireNotes(address(notes));\n        // 1,000 imdUSD of interest in the meter: capacity 2,000 of face.\n        ledger.pay(ILK, 1_000e18);\n        uint256 id = notes.open(30 days, 500, 1_000e18);\n        usdc.mint(buyer, 1_000e6);\n        vm.startPrank(buyer);\n        usdc.approve(address(notes), type(uint256).max);\n        notes.buy(id, 1_000e18);\n        vm.stopPrank();\n        skip(7 days + 30 days); // matured\n    }\n\n    function test_matureNoteCannotTakeTheBalanceThatCoversBookedBadDebt() public {\n        // A liquidation booked 1,000 of bad debt; the cover in its own transaction failed (e.g. EdgeBidOpen at the\n        // time) and the Treasury's balance now equals the hole.\n        ledger.setBad(1_000e18);\n        assertEq(usd.balanceOf(address(treasury)), 1_000e18);\n        assertEq(ledger.totalBadDebt(), 1_000e18);\n        // retireReserve refuses while bad debt is booked...\n        vm.expectRevert(Treasury.BadDebtOutstanding.selector);\n        treasury.retireReserve(1);\n        // ...a note redemption should hold for the same reason, or at least leave what the bad debt needs.\n        vm.prank(buyer);\n        try notes.redeem(1, 1_000e18) {} catch {}\n        treasury.coverBadDebt(ILK);\n        assertEq(ledger.totalBadDebt(), 0, \"bad-debt cover had first call on the Treasury balance\");\n    }\n}","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.","severity":"low","snippet":"        uint256 bal = token.balanceOf(address(treasury));\n        if (bal < face) revert TreasuryShort(bal);","title":"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"},{"citation":"resolved","description":"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).","line":137,"path":"src/Treasury.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Treasury, ICoverable} from \"src/Treasury.sol\";\nimport {FeeNotes, ITreasuryNotes} from \"src/peg/FeeNotes.sol\";\n\ncontract Tok is ERC20 {\n    uint8 private immutable d;\n    constructor(string memory n, uint8 d_) ERC20(n, n) { d = d_; }\n    function decimals() public view override returns (uint8) { return d; }\n    function mint(address to, uint256 a) external { _mint(to, a); }\n    function burnFrom_(address from, uint256 a) external { _burn(from, a); }\n}\n\n/// Minimal Ledger: the parts Treasury calls (retire burns the caller's imdUSD against reserveBacked, like Ledger.retire).\ncontract MiniLedger {\n    Tok public token;\n    Treasury public treasury;\n    uint256 public reserveBacked;\n    uint256 public totalBadDebt;\n    constructor(Tok t) { token = t; }\n    function setTreasury(Treasury t) external { treasury = t; }\n    function setReserveBacked(uint256 r) external { reserveBacked = r; }\n    function retire(uint256 amount) external {\n        if (amount > reserveBacked) amount = reserveBacked;\n        reserveBacked -= amount;\n        token.burnFrom_(msg.sender, amount);\n    }\n    function cover(bytes32, uint256) external {}\n    function ilkState(bytes32) external pure returns (uint128, uint128, uint256, uint64, uint128) {}\n    function mintInterest(bytes32 ilk, uint256 amount) external {\n        token.mint(address(treasury), amount);\n        treasury.noteRevenue(ilk, amount);\n    }\n}\n\ncontract ProofRetireBlocksNotes is Test {\n    Tok imd;\n    Tok usdc;\n    MiniLedger ledger;\n    Treasury treasury;\n    FeeNotes notes;\n    address buyer = address(0xB0B);\n    address attacker = address(0xBAD);\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        imd = new Tok(\"imdUSD\", 18);\n        usdc = new Tok(\"USDC\", 6);\n        ledger = new MiniLedger(imd);\n        treasury = new Treasury(address(0x5AFE), IERC20(address(imd)), address(this));\n        ledger.setTreasury(treasury);\n        treasury.wire(ICoverable(address(ledger)));\n        notes = new FeeNotes(IERC20(address(imd)), IERC20(address(usdc)), ITreasuryNotes(address(treasury)), address(this));\n        treasury.wireNotes(address(notes));\n    }\n\n    function test_retireReserveBurnsNoteBacking() public {\n        // Yield harvests booked 1,000 of reserve-backed supply; 1,000 of interest reached the Treasury.\n        ledger.setReserveBacked(1_000e18);\n        ledger.mintInterest(\"ETH\", 1_000e18);\n        assertEq(notes.capacity(), 2_000e18);\n\n        // A series opens (Parameters) and a buyer pays USDC for 900 face.\n        uint256 id = notes.open(30 days, 500, 1_000e18);\n        usdc.mint(buyer, 1_000e6);\n        vm.startPrank(buyer);\n        usdc.approve(address(notes), type(uint256).max);\n        notes.buy(id, 900e18);\n        vm.stopPrank();\n\n        vm.warp(block.timestamp + 37 days + 1);\n        assertEq(imd.balanceOf(address(treasury)), 1_000e18, \"Treasury holds the face\");\n\n        // No bad debt is booked, so anyone may burn the Treasury's whole balance against reserveBacked.\n        vm.prank(attacker);\n        treasury.retireReserve(type(uint256).max);\n\n        // Expected: the matured note is still payable from the interest that backed it.\n        // Actual: the Treasury balance is 0 and redeem reverts TreasuryShort(0).\n        vm.prank(buyer);\n        notes.redeem(id, 900e18);\n        assertEq(imd.balanceOf(buyer), 900e18);\n    }\n}","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).","severity":"low","snippet":"        if (ledger.totalBadDebt() != 0) revert BadDebtOutstanding();\n        uint256 bal = token.balanceOf(address(this));\n        ledger.retire(amount < bal ? amount : bal);","title":"Anyone can burn the Treasury imdUSD that backs matured fee notes through retireReserve, so every note redemption reverts TreasuryShort until the Safe refills"},{"citation":"resolved","description":"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).","line":419,"path":"src/backstop/Backstop.sol","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).","severity":"low","snippet":"        else if (p[0].amount == 0) p[0] = Pend(uint64(day), a128);\n        else if (p[1].amount == 0) p[1] = Pend(uint64(day), a128);\n        else revert TooManyPending();","title":"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"},{"citation":"resolved","description":"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.","line":708,"path":"src/backstop/Backstop.sol","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.","severity":"low","snippet":"        if (h == n || openDays[h] + 2 > today) return;","title":"A matured Backstop bucket converts at the first call after maturity, so an idle sleeve's depositor picks the price its deposit converts at"},{"citation":"resolved","description":"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.","line":293,"path":"src/LedgerBase.sol","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.","severity":"info","snippet":"                IEdgeSleeve(edge).sync(); // streams over 7 days; a no-op inside an open bid (closeBid counts it then)","title":"LedgerBase comment says closeBid counts an edge premium that arrives inside a bid; closeBid never counts it, the next sync does"},{"citation":"resolved","description":"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'.","line":18,"path":"src/ImdUSD.sol","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.","severity":"info","snippet":"///  - `keeper` is set once by the same timelocked setter: the v4 peg hook (src/peg/PegHook.sol), capped by","title":"ImdUSD NatSpec says the keeper minter is set by the timelocked setter; at genesis Parameters.setGenesisKeeper sets it with no timelock"},{"citation":"resolved","description":"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.","line":46,"path":"src/peg/Allocator.sol","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.","severity":"info","snippet":"/// Allocator may call either. `releaseForSale` sends `amount` of `collateral` to the Allocator; `settleSale` is called\n/// right after with the proceeds already transferred to the Backstop, so it can book them against that collateral.","title":"Allocator's IBackstopSale NatSpec describes releaseForSale/settleSale, which do not exist; the real flow is sell + onBackstopSell"},{"citation":"resolved","description":"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)'.","line":16,"path":"src/backstop/Backstop.sol","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.","severity":"info","snippet":"/// exist per chain: `blue` (ETH, BTC, staked stables) and `edge` (sIMD, seats, stocks). The edge sleeve also burns its","title":"Backstop header lists seats under the edge sleeve's standing bid; seats are an auction type no sleeve bids on"},{"citation":"resolved","description":"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'.","line":13,"path":"src/Migrator.sol","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.","severity":"info","snippet":"/// v1's own vault's risk carried into v2; `toV1` is never blocked while it holds v1. The count is kept","title":"Migrator NatSpec: 'toV1 is never blocked while it holds v1' is not true; toV1 is bounded by `outstanding`, not by the v1 held"},{"citation":"resolved","description":"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'.","line":188,"path":"README.md","reproduction":"With Ledger.totalBadDebt() == 1 wei, call Treasury.retireReserve(1). README says it burns. Actual: revert BadDebtOutstanding().","severity":"info","snippet":"- `Treasury.retireReserve` is permissionless and does not check outstanding bad debt.","title":"README known issue on Treasury.retireReserve is stale: the code does refuse while bad debt is booked"}],"hash":"a589ff4852dca0711a4e72f89b822f8eee6867275b05269e03449ec4b14e3897","nodeId":"ff5da907-2fcd-4027-aea2-42b1e5bf4cc6","outcome":"completed","summary":"The review is complete. The findings file holds 11 merged findings (1 medium, 4 low, 6 info), three with proofs; the tree is otherwise unchanged (scratch tests deleted, nothing in src/, test/, script/ or config touched).\n\n## Outcome\n\n**Kept, reproduced, merged**\n\n- **Medium, src/ImdUSD.sol:145.** A minter burn of flash-minted imdUSD inside the loan lowers totalSupply but not flashOutstanding, so circulating() under-reads the real supply. A borrower repays its own debt inside a flash mint, redeems against another position, and re-borrows. The Redeemer's base-rate rise is multiplied by supply/(supply − burned). My own proof fails on this commit: honest rise 0.312%, actual 3.118% (ETH, divisor 16, 100k supply, 90k own debt). Reachable in BetaConfig; cost is the 7-day upfront fee plus the attacker's own redemption fee.\n- **Low, src/peg/FeeNotes.sol:148.** Matured notes are paid while bad debt is booked, against Treasury.sol:49 and the retireReserve rule. Three specialists reported this; merged into one, with the proof that fails as stated.\n- **Low, src/Treasury.sol:137.** Anyone can burn the Treasury balance that backs matured notes via retireReserve (reserveBacked grows at LedgerBase:380 and LedgerSettlement:184). Proof fails as stated; the specialist's line 205 citation was wrong and is corrected.\n- **Low, src/backstop/Backstop.sol:419.** deposit(amount, to) slot griefing. The specialist's reproduction does not hold: a same-day deposit merges, and from day D+2 the griefer's bucket converts and frees the slot. I verified the block exists only while every bucket waits (dead feed, loss over room, MAX_RATE stop), and recorded it with that precondition.\n- **Low, src/backstop/Backstop.sol:708.** A matured bucket converts at the first call, so an idle sleeve's depositor times the conversion around price updates. The specialist's \"cancel in full after maturity\" leg is wrong: cancelDeposit converts first and reverts NotReady. Kept the narrower, verified version.\n- **Info.** Six documentation claims the code does not have: LedgerBase:293 (closeBid does not count the mid-bid premium, verified with the specialist's test), ImdUSD:18 (keeper set at genesis without timelock), Allocator:46 (releaseForSale/settleSale do not exist), Backstop:16 (seats are auction types), Migrator:13 (toV1 is bounded by outstanding, not v1 held), README:188 (retireReserve does check bad debt).\n\n**Dropped or downgraded**: nothing dropped outright; two Backstop lows were rewritten because their stated reproductions failed.\n\n## Answers to the 12 questions\n\n1. **Minters and supply**: finding (medium) on circulating() deflation. Mint/burn are matched (Ledger debt, Migrator outstanding, keeper and bridge terms); burn-from-anyone is only reachable through each minter's own action; circulating() cannot underflow.\n2. **EIP-3009 and permit**: holds. Domain is ERC20Permit's EIP-712 (name, version, chainId, contract); cancel needs the authorizer's signature; a cancelled nonce reads AuthorizationAlreadyUsed forever; receiveWithAuthorization binds to the payee.\n3. **Treasury**: two lows (note payment while bad debt booked; permissionless retireReserve empties note backing). Payouts only through the 48h _apply; meter roll and trailingRevenue clamp correctly at 90 days.\n4. **Fee notes**: capacity checked at buy against the meter; redeem burns before paying, so no double redeem; early redeem blocked by maturity. Blocking others' redemption is possible via retireReserve (low).\n5. **Savings vault**: holds. Donations and interest stream over 7 days; settledAssets excludes this block's deposits; tracked never falls below unvested; 1e6 offset shares.\n6. **Migrator**: holds. toV2 bounded by migratorLine, toV1 by outstanding; NatSpec sentence wrong (info).\n7. **Shares**: two lows (slot griefing, conversion timing). First-depositor, donation and full-exit cases are either safe or already listed as known issues (DEAD offset, retire residual, MAX_RATE).\n8. **Standing bid**: holds. closeBid ","treeHash":null,"usage":{"cachedInputTokens":2130723,"cost":{"source":"runtime","usdMicros":6798573},"inputTokens":418,"model":"claude-fable-5-1","outputTokens":48624,"runtime":"claude","turns":43,"wallClockMs":975708}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"cd8076510aad8b8e","findings":[{"citation":"resolved","description":"Treasury.sol line 49 states: \"nothing here is earmarked for notes, so bad-debt cover keeps first call on the balance\", and Treasury.retireReserve enforces that rule for the other permissionless burn path (reverts BadDebtOutstanding while ledger.totalBadDebt() != 0). The note path has no such rule: FeeNotes.redeem checks only token.balanceOf(treasury) >= face, and Treasury.payNote (line 184) transfers unconditionally to the notes contract's caller. Any holder of a matured note can therefore pull imdUSD out of the Treasury while bad debt is booked, before anyone calls Treasury.coverBadDebt. The Liquidator covers in the liquidation's own transaction only on a best-effort basis (its try/catch swallows EdgeBidOpen, EdgeCoverFailed, an empty Treasury), so bad debt routinely outlives its liquidation whenever the Treasury was short; interest that then arrives (noteRevenue) is claimable by note holders first, and the uncovered bad debt keeps the global unbacked-supply brake on for every type. Impact: the ordering the Treasury documents (cover before notes) does not hold, and a note holder, not governance, decides whether Treasury imdUSD covers a hole or repays a note. Reachable with BetaConfig: FeeNotes is deployed dormant and becomes live once OPEN_NOTE_SERIES is applied (48h); no other precondition. Smallest fix: in Treasury.payNote (or FeeNotes.redeem) refuse while ledger.totalBadDebt() != 0, mirroring retireReserve, or pay at most balance - totalBadDebt. If paying notes ahead of bad debt is the intended policy, change the Treasury NatSpec at line 49 instead.","line":148,"path":"src/peg/FeeNotes.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Treasury, ICoverable} from \"src/Treasury.sol\";\nimport {FeeNotes, ITreasuryNotes} from \"src/peg/FeeNotes.sol\";\n\ncontract NUSD is ERC20 {\n    constructor() ERC20(\"imdUSD\", \"imdUSD\") {}\n    function mint(address to, uint256 a) external { _mint(to, a); }\n    function burn(address from, uint256 a) external { _burn(from, a); }\n}\n\ncontract NUSDC is ERC20 {\n    constructor() ERC20(\"USDC\", \"USDC\") {}\n    function decimals() public pure override returns (uint8) { return 6; }\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\n/// @dev The Ledger as the Treasury sees it: books bad debt, burns the coverer's imdUSD, reports interest.\ncontract NLedger is ICoverable {\n    NUSD public usd;\n    Treasury public treasury;\n    uint128 public bad;\n    constructor(NUSD u) { usd = u; }\n    function setTreasury(Treasury t) external { treasury = t; }\n    function setBad(uint128 b) external { bad = b; }\n    function totalBadDebt() external view returns (uint256) { return bad; }\n    function cover(bytes32, uint256 amount) external {\n        if (amount > bad) amount = bad;\n        bad -= uint128(amount);\n        usd.burn(msg.sender, amount);\n    }\n    function retire(uint256) external {}\n    function ilkState(bytes32) external view returns (uint128, uint128, uint256, uint64, uint128) { return (0, 0, 0, 0, bad); }\n    // Treasury.coverBadDebt reads ledger.params().edgeIlk(ilk): this contract answers both, with no edge types.\n    function params() external view returns (address) { return address(this); }\n    function edgeIlk(bytes32) external pure returns (bool) { return false; }\n    function edgeBackstop() external pure returns (address) { return address(0); }\n    /// @dev Interest minted to the Treasury and reported to its meter, as LedgerBase._mintInterest does.\n    function pay(bytes32 ilk, uint256 amount) external {\n        usd.mint(address(treasury), amount);\n        treasury.noteRevenue(ilk, amount);\n    }\n}\n\n/// Treasury.sol says: \"nothing here is earmarked for notes, so bad-debt cover keeps first call on the balance\", and\n/// `retireReserve` refuses while `totalBadDebt != 0` for that reason. A matured note redeems the same balance with no\n/// such check: the note holder is paid first and the booked bad debt stays uncovered.\ncontract ProofNoteRedeemAheadOfBadDebt is Test {\n    NUSD usd;\n    NUSDC usdc;\n    NLedger ledger;\n    Treasury treasury;\n    FeeNotes notes;\n    address governor = makeAddr(\"governor\");\n    address buyer = makeAddr(\"buyer\");\n    bytes32 constant ILK = \"ETH\";\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        usd = new NUSD();\n        usdc = new NUSDC();\n        ledger = new NLedger(usd);\n        treasury = new Treasury(governor, IERC20(address(usd)), address(this));\n        ledger.setTreasury(treasury);\n        treasury.wire(ICoverable(address(ledger)));\n        notes = new FeeNotes(IERC20(address(usd)), IERC20(address(usdc)), ITreasuryNotes(address(treasury)), address(this));\n        treasury.wireNotes(address(notes));\n        // 1,000 imdUSD of interest in the meter: capacity 2,000 of face.\n        ledger.pay(ILK, 1_000e18);\n        uint256 id = notes.open(30 days, 500, 1_000e18);\n        usdc.mint(buyer, 1_000e6);\n        vm.startPrank(buyer);\n        usdc.approve(address(notes), type(uint256).max);\n        notes.buy(id, 1_000e18);\n        vm.stopPrank();\n        skip(7 days + 30 days); // matured\n    }\n\n    function test_matureNoteCannotTakeTheBalanceThatCoversBookedBadDebt() public {\n        // A liquidation booked 1,000 of bad debt; the cover in its own transaction failed (e.g. EdgeBidOpen at the\n        // time) and the Treasury's balance now equals the hole.\n        ledger.setBad(1_000e18);\n        assertEq(usd.balanceOf(address(treasury)), 1_000e18);\n        assertEq(ledger.totalBadDebt(), 1_000e18);\n        // retireReserve refuses while bad debt is booked...\n        vm.expectRevert(Treasury.BadDebtOutstanding.selector);\n        treasury.retireReserve(1);\n        // ...a note redemption should hold for the same reason, or at least leave what the bad debt needs.\n        vm.prank(buyer);\n        try notes.redeem(1, 1_000e18) {} catch {}\n        treasury.coverBadDebt(ILK);\n        assertEq(ledger.totalBadDebt(), 0, \"bad-debt cover had first call on the Treasury balance\");\n    }\n}","reproduction":"State: Treasury holds 1,000 imdUSD of interest (meter 1,000, capacity 2,000); one series opened (tenor 30d, 5%, cap 1,000); buyer bought 1,000 face for 950 USDC; 37 days later the note is matured; the Ledger books 1,000 of bad debt (totalBadDebt = 1,000) whose in-transaction cover failed. Call sequence from an external caller: buyer -> FeeNotes.redeem(1, 1_000e18) -> Treasury.payNote(buyer, 1_000e18) succeeds; then anyone -> Treasury.coverBadDebt(ilk) covers 0 because the balance is 0. Expected (per Treasury.sol:49 and the retireReserve rule): redeem holds while bad debt is booked, so coverBadDebt burns 1,000 and totalBadDebt becomes 0. Actual: redeem succeeds, Treasury balance 0, totalBadDebt stays 1,000. The attached test asserts totalBadDebt == 0 after redeem+cover and fails with 1000000000000000000000 != 0 on this commit. Run: forge test --match-path test/scratch/ProofNoteRedeemAheadOfBadDebt.t.sol -vv (ran with solc 0.8.26 via_ir: [FAIL: bad-debt cover had first call on the Treasury balance: 1000000000000000000000 != 0]).","severity":"low","snippet":"        uint256 bal = token.balanceOf(address(treasury));\n        if (bal < face) revert TreasuryShort(bal);","title":"FeeNotes.redeem pays a matured note from the Treasury balance while bad debt is booked, against the Treasury's stated first call for bad-debt cover"},{"citation":"resolved","description":"Backstop.sync() returns early while a bid is open (Backstop.sol:595), and Backstop.closeBid (Backstop.sol:532-563) never 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 therefore sits outside `tracked + staged` from closeBid until the next sync() (the next deposit, realize, requestWithdraw, withdraw, coverBadDebt, sell or an explicit sync). No funds are at risk: the next sync streams it over 7 days exactly as the sleeve's own header says, and every exit calls sync first. Only the LedgerBase comment is wrong about where the counting happens. Fix: reword the comment to \"a no-op inside an open bid; the next sync streams it\", or have closeBid call sync() after clearing the transient bid slots.","line":293,"path":"src/LedgerBase.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Backstop} from \"src/backstop/Backstop.sol\";\nimport {IBackstopOracle, ILedgerCover} from \"src/backstop/IBackstop.sol\";\n\ncontract PUSD is ERC20 {\n    constructor() ERC20(\"imdUSD\", \"imdUSD\") {}\n    function mint(address to, uint256 a) external { _mint(to, a); }\n    function burn(address from, uint256 a) external { _burn(from, a); }\n}\n\ncontract PToken is ERC20 {\n    constructor() ERC20(\"WETH\", \"WETH\") {}\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\ncontract POracle is IBackstopOracle {\n    function priceOf(address) external pure returns (uint256, uint256, bool) { return (2000e18, 2010e18, true); }\n    function pricedAt(address) external pure returns (uint256) { return 0; }\n}\n\n/// @dev The Ledger's role in a standing bid: burn the repaid debt from the payer, pay the collateral, and (as the\n/// Ledger's accrual does on an edge type) mint the premium to the sleeve mid-liquidation.\ncontract PLedger is ILedgerCover {\n    PUSD public usd;\n    constructor(PUSD u) { usd = u; }\n    function cover(bytes32, uint256) external {}\n    function ilkState(bytes32) external pure returns (uint128, uint128, uint256, uint64, uint128) { return (0, 0, 0, 0, 0); }\n    function liquidatePosition(address payer, PToken coll, uint256 repaid, uint256 seized, uint256 premium) external returns (uint256) {\n        if (premium != 0) usd.mint(payer, premium); // the edge premium arriving inside the bid\n        usd.burn(payer, repaid);\n        coll.mint(payer, seized);\n        return repaid;\n    }\n}\n\ncontract PLiquidator {\n    Backstop public bs;\n    PLedger public ledger;\n    constructor(PLedger l) { ledger = l; }\n    function setBackstop(Backstop b) external { bs = b; }\n    function liquidate(bytes32 ilk, PToken coll, uint256 repaid, uint256 seized, uint256 premium) external {\n        bs.openBid(ilk);\n        uint256 r = ledger.liquidatePosition(address(bs), coll, repaid, seized, premium);\n        bs.closeBid(ilk, r);\n    }\n}\n\n/// LedgerBase._mintInterest says of the edge premium: \"streams over 7 days; a no-op inside an open bid (closeBid counts\n/// it then)\". closeBid does not count it: the arrival sits outside `tracked + staged` until the next `sync()`.\ncontract ProofBidArrivalNotCountedAtClose is Test {\n    PUSD usd;\n    PToken weth;\n    PLedger ledger;\n    PLiquidator liq;\n    Backstop edge;\n    bytes32 constant ILK = \"SIMD\";\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        usd = new PUSD();\n        weth = new PToken();\n        ledger = new PLedger(usd);\n        liq = new PLiquidator(ledger);\n        edge = new Backstop(IERC20(address(usd)), ledger, new POracle(), true, \"edge\", \"sE\");\n        edge.wire(address(liq), address(this), address(0));\n        edge.addIlk(ILK, address(weth), 0, 100e18, 1e18);\n        edge.seal();\n        liq.setBackstop(edge);\n        usd.mint(alice, 10_000e18);\n        vm.startPrank(alice);\n        usd.approve(address(edge), type(uint256).max);\n        edge.deposit(10_000e18, alice);\n        vm.stopPrank();\n        skip(2 days);\n        edge.realize(alice);\n    }\n\n    function test_premiumArrivingInsideTheBidIsCountedOnlyAtTheNextSync() public {\n        uint256 premium = 50e18;\n        liq.liquidate(ILK, weth, 5_000e18, 2.75e18, premium);\n        uint256 counted = edge.tracked() + edge.staged();\n        uint256 bal = usd.balanceOf(address(edge));\n        // The claim: closeBid counts the arrival. If it did, the balance would be fully accounted for at close.\n        assertEq(bal, counted, \"closeBid did not fold the mid-bid arrival into the stream\");\n    }\n\n    function test_nextSyncDoesCountIt() public {\n        liq.liquidate(ILK, weth, 5_000e18, 2.75e18, 50e18);\n        edge.sync();\n        assertEq(usd.balanceOf(address(edge)), edge.tracked() + edge.staged());\n        assertEq(edge.unvested(), 50e18, \"the arrival streams from the sync, not from the close\");\n    }\n}","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 an explicit sync(), tracked + staged = 5,050 and unvested() = 50. Test ran with solc 0.8.26: [FAIL: closeBid did not fold the mid-bid arrival into the stream: 5050000000000000000000 != 5000000000000000000000] test_premiumArrivingInsideTheBidIsCountedOnlyAtTheNextSync; [PASS] test_nextSyncDoesCountIt.","severity":"info","snippet":"                IEdgeSleeve(edge).sync(); // streams over 7 days; a no-op inside an open bid (closeBid counts it then)","title":"NatSpec claims closeBid counts an edge premium that arrives inside a bid; it is counted only at the next sync"},{"citation":"resolved","description":"ImdUSD.setKeeper is callable by `bridgeSetter` (Parameters), and Parameters exposes setGenesisKeeper (Parameters.sol:63-68), callable by the wirer before seal with no timelock, which calls token.setKeeper. The README's invariant 8 describes this correctly (\"keeper (the peg hook, set by the deployer before the seal)\"); the token's own NatSpec claims a 48h-public step that does not exist for the genesis path. The code is as the README describes; only the token comment overstates the guard. Fix: reword line 18 to say the keeper is set once by Parameters, at genesis by the deployer before the seal, otherwise through the timelock.","line":18,"path":"src/ImdUSD.sol","reproduction":"Deployer calls Parameters.setGenesisKeeper(hook) before Parameters.seal(): ImdUSD.keeper becomes `hook` in the same transaction, with no proposal, ETA or 48h wait. Expected per the ImdUSD comment: a timelocked setter. Actual: immediate. (Doc-only; no funds affected: keeperCap is 0 at genesis.)","severity":"info","snippet":"///  - `keeper` is set once by the same timelocked setter: the v4 peg hook (src/peg/PegHook.sol), capped by","title":"ImdUSD NatSpec says the keeper minter is set through the timelocked setter; at genesis it is set by the deployer without a timelock"},{"citation":"resolved","description":"Treasury.retireReserve (Treasury.sol:136-140) reverts BadDebtOutstanding while ledger.totalBadDebt() != 0. The README entry is stale: the code is stricter than documented. The permissionless part still holds: anyone may burn Treasury imdUSD against reserveBacked up to the balance when no bad debt is booked, which also lowers what FeeNotes.redeem can draw (TreasuryShort until the Safe funds), as the fail-closed design accepts. Fix: update the README line to say retireReserve refuses while bad debt is booked.","line":188,"path":"README.md","reproduction":"With ledger.totalBadDebt() == 1 wei, Treasury.retireReserve(1) reverts BadDebtOutstanding (Treasury.sol:137). Expected per the README: the call proceeds and burns. Actual: revert. Doc-only.","severity":"info","snippet":"- `Treasury.retireReserve` is permissionless and does not check outstanding bad debt.","title":"README known issue says Treasury.retireReserve does not check outstanding bad debt; the code does"}],"hash":"b4f7eacd2f66a9344f3c71f819bbcf6b51e7e84a11719cb5391e1df968cc89d8","nodeId":"4694ac67-a52c-4d9c-8a03-185409ebf7ef","outcome":"completed","summary":"Findings file written with four entries (one low, three info), both proofs ran under solc and failed on this commit, scratch tests removed, and the tree is unchanged. Full report follows.\n\n## Result\n\nThe in-scope math holds. I found no critical, high or medium defect in `ImdUSD`, `SavingsVault`, `Treasury`, `FeeNotes`, `Migrator`, `Backstop`, `PricerOracle` or `Allocator` at this commit. Four findings are in `.imd-findings.json`: one low (fee-note redemption runs ahead of booked bad debt, against the Treasury's stated ordering) and three documentation mismatches. Two carry Foundry proofs that fail on this code.\n\nBuild and test, as required:\n\n| Run | Result |\n|---|---|\n| Panel glob (Backstop, BackstopPanel, Allocator, PanelAllocator, Savings, FeeNotes), solc 0.8.26 via_ir | 142 passed, 0 failed, 7m42s |\n| `test/scratch/ProofNoteRedeemAheadOfBadDebt.t.sol` | 1 failed as intended (`1000e18 != 0`) |\n| `test/scratch/ProofBidArrivalNotCountedAtClose.t.sol` | 1 failed as intended (`5050e18 != 5000e18`), 1 passed |\n\nBoth proof sources, with their output, are in the findings file. `test/scratch/` is deleted and `git status` shows no tracked change.\n\n## Answers to the twelve questions\n\n1. **Minters and supply.** Holds. Every `mint` is matched by a liability (debt via the Ledger, v1 held by the Migrator, keeper outstanding, bridge empty in the beta), and every `burn` path burns from the actor who initiated it: `Ledger.cover` and `retire` burn `msg.sender`, `liquidatePosition` burns the payer (the biter, or a sleeve that consented in `openBid`), `Migrator.toV1` burns `msg.sender`. No minter exposes a burn from an arbitrary address. `circulating()` cannot underflow: a flash-minted amount can only be burned through paths that require a liability of equal size, and liabilities never exceed supply. The flash cap is per transaction via the transient counter and `maxFlashLoan` nets loans in flight. One doc mismatch (info): the keeper is set at genesis by the deployer through `Parameters.setGenesisKeeper` without a timelock, while the token's NatSpec says \"timelocked setter\".\n2. **EIP-3009 and permit.** Holds. The EIP-712 domain includes chain id and contract address, so no cross-chain or cross-contract replay. Nonces are per authorizer and single-use for transfer, receive and cancel alike. A cancel needs the authorizer's own signature, so a third party cannot front-run a cancel. A cancelled or used nonce is never reusable. `receiveWithAuthorization` enforces `msg.sender == to`. Both the packed and `bytes` overloads go through `SignatureChecker`, so both accept ERC-1271 (the NatSpec says only the `bytes` ones do; harmless overstatement).\n3. **Treasury.** Mostly holds. `coverBadDebt` always calls the edge sleeve first and only falls through on `UnknownIlk` or a zero cover. `retireReserve` refuses while any bad debt is booked. Timelocked payouts are the governor's only arbitrary transfer. The meter's ring buffer rolls correctly, including gaps longer than 90 days. **Low finding:** `payNote` has no bad-debt check, so a matured note takes imdUSD ahead of booked bad debt, contradicting the Treasury's own \"first call\" comment (proof attached). The README's known-issue line saying `retireReserve` does not check bad debt is stale: the code does (info).\n4. **Fee notes.** Holds. Capacity is `2 x trailingRevenue - outstanding`, checked at every buy; a series cannot exceed its own cap or be bought after `saleEnd`; `maturity = saleEnd + tenor` so no buyer shortens the wait; `redeem` burns before paying, so no double redemption; `priceOf` rounds up to the Treasury's favour. Nobody can block another holder's redemption except by legitimately draining the Treasury (bad-debt cover, note redemption, or the permissionless `retireReserve` when no bad debt is booked), which is the documented fail-closed behaviour.\n5. **Savings vault.** Holds. Shares are priced on `tracked - unvested()`; an unsynced arrival is treated as fully unvested so `totalAssets` is identical b","treeHash":null,"usage":{"cachedInputTokens":1605217,"cost":{"source":"runtime","usdMicros":7835888},"inputTokens":322,"model":"claude-fable-5-1","outputTokens":67469,"runtime":"claude","turns":30,"wallClockMs":1519839}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b12a68c6f8a8ce99","findings":[{"citation":"unresolved","description":"Treasury.retireReserve is permissionless and burns min(amount, balance, Ledger.reserveBacked) of the Treasury's imdUSD. It only checks for booked bad debt. FeeNotes pays matured notes from that same balance (Treasury.payNote), and FeeNotes.redeem reverts TreasuryShort while the balance is below face. reserveBacked grows on every yield harvest (LedgerBase harvest: `reserveBacked += credit`) of the beta's ERC-4626 types S-USDS and S-USDC-STEAKHOUSE, and with wstETH. So with no bad debt booked, any account can burn the interest the notes were sold against. Every holder's redemption then reverts until the Safe refills the Treasury through a 48h payout. No funds are stolen: the supply equation still holds, because reserveBacked is backed by the harvested shares the Treasury holds. But note redemption, which the FeeNotes NatSpec says waits only on a short Treasury, can be denied at no cost (gas only) whenever reserveBacked > 0. The same call also spends the imdUSD that would cover future edge bad debt behind the sleeve (Q3). README 'Known issues' says retireReserve 'does not check outstanding bad debt'. The code does check it now, so that entry is out of date. Its effect on fee notes is not listed. Call sequence: harvest makes reserveBacked R > 0 -> notes sold, matured -> attacker calls Treasury.retireReserve(type(uint256).max) -> FeeNotes.redeem reverts TreasuryShort(0). Reachable in BetaConfig: yes, as soon as yield types have harvested and a note series has been opened through OPEN_NOTE_SERIES. Smallest fix: make retireReserve governor-only (or Safe-timelocked), or burn only the balance above the notes' outstanding face: `uint256 free = bal > INotes(notes).outstanding() ? bal - INotes(notes).outstanding() : 0;`.","line":205,"path":"src/Treasury.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Treasury, ICoverable} from \"src/Treasury.sol\";\nimport {FeeNotes, ITreasuryNotes} from \"src/peg/FeeNotes.sol\";\n\ncontract Tok is ERC20 {\n    uint8 private immutable d;\n    constructor(string memory n, uint8 d_) ERC20(n, n) { d = d_; }\n    function decimals() public view override returns (uint8) { return d; }\n    function mint(address to, uint256 a) external { _mint(to, a); }\n    function burnFrom_(address from, uint256 a) external { _burn(from, a); }\n}\n\n/// Minimal Ledger: the parts Treasury calls (retire burns the caller's imdUSD against reserveBacked, like Ledger.retire).\ncontract MiniLedger {\n    Tok public token;\n    Treasury public treasury;\n    uint256 public reserveBacked;\n    uint256 public totalBadDebt;\n    constructor(Tok t) { token = t; }\n    function setTreasury(Treasury t) external { treasury = t; }\n    function setReserveBacked(uint256 r) external { reserveBacked = r; }\n    function retire(uint256 amount) external {\n        if (amount > reserveBacked) amount = reserveBacked;\n        reserveBacked -= amount;\n        token.burnFrom_(msg.sender, amount);\n    }\n    function cover(bytes32, uint256) external {}\n    function ilkState(bytes32) external pure returns (uint128, uint128, uint256, uint64, uint128) {}\n    function mintInterest(bytes32 ilk, uint256 amount) external {\n        token.mint(address(treasury), amount);\n        treasury.noteRevenue(ilk, amount);\n    }\n}\n\ncontract ProofRetireBlocksNotes is Test {\n    Tok imd;\n    Tok usdc;\n    MiniLedger ledger;\n    Treasury treasury;\n    FeeNotes notes;\n    address buyer = address(0xB0B);\n    address attacker = address(0xBAD);\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        imd = new Tok(\"imdUSD\", 18);\n        usdc = new Tok(\"USDC\", 6);\n        ledger = new MiniLedger(imd);\n        treasury = new Treasury(address(0x5AFE), IERC20(address(imd)), address(this));\n        ledger.setTreasury(treasury);\n        treasury.wire(ICoverable(address(ledger)));\n        notes = new FeeNotes(IERC20(address(imd)), IERC20(address(usdc)), ITreasuryNotes(address(treasury)), address(this));\n        treasury.wireNotes(address(notes));\n    }\n\n    function test_retireReserveBurnsNoteBacking() public {\n        // Yield harvests booked 1,000 of reserve-backed supply; 1,000 of interest reached the Treasury.\n        ledger.setReserveBacked(1_000e18);\n        ledger.mintInterest(\"ETH\", 1_000e18);\n        assertEq(notes.capacity(), 2_000e18);\n\n        // A series opens (Parameters) and a buyer pays USDC for 900 face.\n        uint256 id = notes.open(30 days, 500, 1_000e18);\n        usdc.mint(buyer, 1_000e6);\n        vm.startPrank(buyer);\n        usdc.approve(address(notes), type(uint256).max);\n        notes.buy(id, 900e18);\n        vm.stopPrank();\n\n        vm.warp(block.timestamp + 37 days + 1);\n        assertEq(imd.balanceOf(address(treasury)), 1_000e18, \"Treasury holds the face\");\n\n        // No bad debt is booked, so anyone may burn the Treasury's whole balance against reserveBacked.\n        vm.prank(attacker);\n        treasury.retireReserve(type(uint256).max);\n\n        // Expected: the matured note is still payable from the interest that backed it.\n        // Actual: the Treasury balance is 0 and redeem reverts TreasuryShort(0).\n        vm.prank(buyer);\n        notes.redeem(id, 900e18);\n        assertEq(imd.balanceOf(buyer), 900e18);\n    }\n}","reproduction":"State: reserveBacked = 1,000e18, Treasury balance = 1,000e18 of noted interest, totalBadDebt = 0. A series opens (30-day tenor, 5% discount) and a buyer buys 900e18 face for 855 USDC. Warp past maturity. The attacker calls retireReserve(type(uint256).max). Expected: notes.redeem(1, 900e18) pays 900e18. Actual: the Treasury balance is 0 and redeem reverts TreasuryShort(0). `forge test --match-path test/scratch/ProofRetireBlocksNotes.t.sol` output: [FAIL: TreasuryShort(0)] test_retireReserveBurnsNoteBacking() (gas: 676029)","severity":"low","snippet":"    function retireReserve(uint256 amount) external {\n        if (ledger.totalBadDebt() != 0) revert BadDebtOutstanding();\n        uint256 bal = token.balanceOf(address(this));\n        ledger.retire(amount < bal ? amount : bal);\n    }","title":"Anyone can burn the Treasury imdUSD that backs matured fee notes via retireReserve, blocking FeeNotes.redeem"},{"citation":"resolved","description":"The interface comment says the Backstop exposes `releaseForSale` and `settleSale`, with proceeds 'already transferred' before `settleSale`. Neither function exists. The real flow is Backstop.sell -> transfer of the collateral to the Allocator -> IBackstopSeller.onBackstopSell callback -> the route pays imdUSD to the sleeve -> the sleeve checks the balance difference. Anyone integrating or reviewing from this comment gets the order and the callback wrong.","line":46,"path":"src/peg/Allocator.sol","reproduction":"Read IBackstopSale at src/peg/Allocator.sol:48-51: its only functions are imdUSD() and sell(token, amount, minOut, data). grep for releaseForSale or settleSale in src/ finds only this comment.","severity":"info","snippet":"/// Allocator may call either. `releaseForSale` sends `amount` of `collateral` to the Allocator; `settleSale` is called\n/// right after with the proceeds already transferred to the Backstop, so it can book them against that collateral.","title":"NatSpec of IBackstopSale describes releaseForSale/settleSale, which do not exist"},{"citation":"resolved","description":"toV1 runs `outstanding -= amount` first. It 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). It is limited by `outstanding`, not by the v1 it holds.","line":13,"path":"src/Migrator.sol","reproduction":"State: outstanding = 0, and someone has transferred 5e18 v1 to the Migrator. Holder of v2 (borrowed from the Ledger) calls toV1(1e18). Expected per NatSpec: succeeds (it holds v1). Actual: arithmetic underflow panic at Migrator.sol:45.","severity":"info","snippet":"/// v1's own vault's risk carried into v2; `toV1` is never blocked while it holds v1. The count is kept","title":"Migrator NatSpec: 'toV1 is never blocked while it holds v1' is not true"},{"citation":"resolved","description":"Treasury.retireReserve (src/Treasury.sol:206) reverts BadDebtOutstanding while ledger.totalBadDebt() != 0, so the accepted risk as written no longer exists. What remains is the fee-note angle in the low finding above, which the README does not list.","line":188,"path":"README.md","reproduction":"With Ledger.totalBadDebt() == 1, call Treasury.retireReserve(1): the README says it burns. Actual: it reverts BadDebtOutstanding().","severity":"info","snippet":"- `Treasury.retireReserve` is permissionless and does not check outstanding bad debt.","title":"README known issue on Treasury.retireReserve is out of date (the code does check bad debt)"},{"citation":"resolved","description":"The header says the two sleeves bid in liquidations and lists the edge sleeve as '(sIMD, seats, stocks)'. Liquidator.bite sends auction types (c.auction) to IAuctionJoin.open before the standing-bid branch (src/Liquidator.sol:154-158), and BetaConfig.backstopSpec lists only SIMD on the edge sleeve. Seats are never bid on by a sleeve. README lines 29-30 say so correctly.","line":16,"path":"src/backstop/Backstop.sol","reproduction":"Mark and bite an unhealthy SEAT position: Liquidator returns (0,0,0) after IAuctionJoin.open, and Backstop.openBid is never called.","severity":"info","snippet":"/// exist per chain: `blue` (ETH, BTC, staked stables) and `edge` (sIMD, seats, stocks). The edge sleeve also burns its","title":"Backstop header lists seats under the edge sleeve's standing bid; seats are auction types no sleeve bids on"}],"hash":"cebdd7364cc796b9324be07f90a4dc5a6124f70d2cf5bddd6d1bf1a6c3d312a8","nodeId":"abd2f914-a436-419c-9650-e673d1e6ede8","outcome":"completed","summary":"I found one low-severity defect, with a Foundry proof that fails on this commit, and four comments or docs that claim something the code doesn't do. Nothing critical, high or medium turned up across the 12 questions. All five are in `.imd-findings.json`. The panel suites pass (142 of 142), the proof file has been deleted, and no tracked file was changed.\n\n## Findings\n\n**Low: anyone can burn the Treasury imdUSD that pays matured fee notes (`src/Treasury.sol:205`, `retireReserve`).**\n- `retireReserve` only checks for booked bad debt, then burns as much of the Treasury's imdUSD as `reserveBacked` allows. `reserveBacked` grows with every yield harvest of the beta's S-USDS, S-USDC-STEAKHOUSE and wstETH types.\n- **Failing state:** `reserveBacked` = 1,000e18, Treasury balance = 1,000e18, a 900e18 note matured. Someone calls `retireReserve(max)`. `FeeNotes.redeem(1, 900e18)` should pay 900e18; instead it reverts `TreasuryShort(0)`.\n- **Output:** `[FAIL: TreasuryShort(0)] test_retireReserveBurnsNoteBacking() (gas: 676029)`.\n- **Impact:** every note redemption is blocked until the Safe refills the Treasury through a 48h payout. No funds are stolen and solvency is unchanged.\n- **Fix:** make `retireReserve` governor-only, or burn only the balance above the notes' outstanding face.\n\n**Info: comments and docs that are wrong:**\n- `Allocator.sol:46` describes `releaseForSale` and `settleSale`. Neither exists; the real flow is `sell` with an `onBackstopSell` callback.\n- `Migrator.sol:13` says \"`toV1` is never blocked while it holds v1\". It is limited by `outstanding`: with `outstanding` = 0 and donated v1 in the contract, `toV1(1e18)` reverts with an underflow.\n- `README.md:188`: the known issue says `retireReserve` doesn't check bad debt. The code does check it (`Treasury.sol:206`), so the entry is out of date.\n- `Backstop.sol:16` lists seats under the edge sleeve's bid. Seats are auction types and no sleeve ever bids on them (`Liquidator.sol:154`).\n\n## Answers to the 12 questions\n1. **Minters and supply: holds.**\n   - Every mint has a matching liability: borrows, interest, migrator v1 deposits, and the keeper (bounded by its cap, a known issue).\n   - Every burn-from-anyone call burns only from the caller itself (`msg.sender`, `actor`) or the payer of its own action (the sleeve inside its open bid, the join).\n   - The flash mint cap is shared across nested loans in one transaction.\n   - `circulating()` can't underflow: burns inside a loan are bounded by the supply that existed before it.\n2. **EIP-3009 and permit: holds.**\n   - The EIP-712 domain binds chain id and contract address, so signatures don't replay elsewhere.\n   - A used or cancelled nonce can't be used again, and a cancel of a used nonce reverts.\n   - `receiveWithAuthorization` checks the caller is the payee.\n   - A cancel can be front-run by the transfer it was cancelling; that is standard EIP-3009 behaviour.\n3. **Treasury: holds for ordering, with one defect.**\n   - An edge sleeve's cover is tried first. Only an `UnknownIlk` refusal or a zero cover falls through to the Treasury.\n   - Payouts go only through the 48h timelock.\n   - The revenue meter can only be written by the Ledger.\n   - The defect is the low finding: anyone can spend Treasury funds through `retireReserve`.\n4. **Fee notes:** capacity (2 × trailing revenue minus outstanding) is checked at every purchase. Maturity is enforced, notes are burned on redeem, and they can't be transferred. Others' redemptions can be blocked, through the low finding.\n5. **Savings vault: holds.** `sync` doesn't change `totalAssets`, donations and interest stream over 7 days, and the virtual-share offset is 6. Same-block deposits are excluded from the uptake figure.\n6. **Migrator: holds.** Minting happens only after v1 is pulled, burning happens before v1 is sent, and the line is checked against `outstanding`. The comment is wrong (info finding).\n7. **Shares: holds.**\n   - Buckets convert at a value that excludes waiting deposits and not-ye","treeHash":null,"usage":{"cachedInputTokens":2441522,"cost":{"source":"runtime","usdMicros":2504616},"inputTokens":47,"model":"claude-opus-5-5","outputTokens":39442,"runtime":"claude","turns":26,"wallClockMs":1201216}}],"verification":[]}