{"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":"414e25cc-f7f9-47d1-bc5e-c0dfb8705119","kind":"audit","nodes":[{"acceptedSubmissionHash":"5ce98d39676af46e30c4bf73f2e32ba2960e27fa97cb81e5b6a763967ed3d8a5","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"4c3b66af11a67164bd08a4a42e515a47351d0b07e489182bf41d82b51cbd8489","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"e510d7fe4a938d1c181aa46064f779a53ceec197bffc5555a3d055adb9c16979","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"22d0b67a6ba06b7cfe937295ad77e3522116d5dc753539eaab7cff1e9050a867","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"aeca1a4d139d88110f92bc5dcdcf3c7deb70904e21268f1a298af5c7dba300f6","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the vault: src/CDPVault.sol, src/ParameterizedVault.sol and src/ImdUSD.sol, at the pinned commit, for a mainnet launch. Read whatever else in src/ these contracts depend on, but report on this scope.\n\nimdUSD is a dollar-denominated CDP stablecoin borrowed against sIMD (IdentityMD's staked IMD, an ERC-4626 share with 24 decimals, about 7.95 IMD each). Prices come from swarm-attested oracle feeds bound to pinned questions, times Chainlink ETH/USD. Everything about the deployment is in src/DeploymentConfig.sol and docs/MAINNET-RUNBOOK.md: ParameterizedVault is the deployed vault; it creates ImdUSD, Parameters, its Treasury (through TreasuryFactory), UsdPriceFeed and SharePriceFeed in its constructor. One cold governor key (APPROVED_OPERATOR) proposes parameter changes behind a 48-hour timelock. Collateral pricing is per 1e18 raw units throughout. IMD's only market is a full-range Uniswap v4 pool, about $2.3M a side with a 1% fee; docs/PARAMETERS-2026-10-05.md has the numbers every economic parameter was chosen from.\n\nAnswer each numbered question, including the ones where nothing is wrong:\n1. Positions: can lock, lockIMD, free, draw or wipe leave a position below mat, double-count collateral, or move funds for someone other than the caller? lockIMD credits shares by balance delta: can a share vault that misreports, or reentrancy through the staking vault, inflate the credit?\n2. Liquidation (bark, barkFor, bite, heel, tail, lull): with CHOP_PERCENT 20 and the chip/cut split, can a liquidator, marker or borrower extract more than the formula, can the dust sweep be gamed, and can a position be frozen unliquidatable?\n3. Redemption (cash): the fee base (redemptionDivisor, governed 1-8 through Parameters), the fresh-debt record, candidate eligibility (mat + gap) and the backingPerUnit cap. Can a redeemer pay less than the fee for its size, worsen a candidate's ratio, or drain the reserve below what backs remaining supply?\n4. Bad debt: totalBadDebt, badDebtOf, and cover(owner, amount), which anyone may call to burn Treasury imdUSD against a drained position through the repayment path. Can coverage exceed what is owed, apply to a position that still holds collateral, be spent twice, or desynchronise totalBadDebt from the per-position record?\n5. Stability fee (duty 444 bps, chi, drip, checkpoints): can a governed rate change reprice elapsed time, or can any sequence make chiOf exceed chi?\n6. Price gating: is every value-moving action refused on stale or divergent feeds (_pricingStale, skew), and are the exceptions (lock, wipe, debt-free free) safe?\n7. Work issuance (earn, earnLine, earnMat, backedDebt and its transient slot): can same-transaction debt or a reserve listing authorise unbacked minting?\n8. Arithmetic: overflow at extreme collateral or price, rounding direction in every division that pays someone, and units wherever a price, a 24-decimal amount and basis points meet.\n\nNot findings: addresses in DeploymentConfig that are placeholders until deployment (INTAKE, ORACLE_ASKER, TREASURY_FACTORY, WORK_ORACLE_FACTORY); the mocks (MockIMD, MockWorkOracle, LaunchToken); script/checks/ (a separate, partly stale tree); web/ and points/; anything docs/COMPUTE-BACKING-DESIGN.md describes as future work; and findings of the earlier audits in docs/AUDIT-*.md and docs/INTERNAL-AUDIT-2026-10-04.md, unless the fix regressed. A constant set to a deliberate economic value is not a finding; an arithmetic or ordering error in how it is used is.\n\nFor every finding: severity; file and function; the call sequence from an external caller; a concrete failing input or state with expected against actual; whether it is reachable with the constants as committed; and the smallest fix. Also report every place a comment or NatSpec claims a property the code does not have, and say which contracts you read in full and which you could not reach.","parentJobId":null,"planHash":"1bfd2a0c931c6c3ea8dbbd8ea6efd82317331896a01a7c933933fe91869099dd","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"414e25cc-f7f9-47d1-bc5e-c0dfb8705119","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":"51538","feedbackHash":"e03d55f1c8d15ad845c6bebb3e919becac4250f1e181709a0493add0b64f9c6b","nodeKey":"audit_economics","submissionHash":"5ce98d39676af46e30c4bf73f2e32ba2960e27fa97cb81e5b6a763967ed3d8a5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51143","feedbackHash":"a38a820d4bc52ca350715aa33172f610325ca156bd37a645cb35c79e8cafc975","nodeKey":"audit_flow","submissionHash":"4c3b66af11a67164bd08a4a42e515a47351d0b07e489182bf41d82b51cbd8489","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51507","feedbackHash":"522b1ed8fbfe445c074d41747bb6ddaf895a112f6a9183813709ddc9d94f92c4","nodeKey":"audit_judge","submissionHash":"e510d7fe4a938d1c181aa46064f779a53ceec197bffc5555a3d055adb9c16979","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52124","feedbackHash":"f05d2c757e74eb569164346a5f9654833ed231ec52817b4e3b564fadab7063ad","nodeKey":"audit_math","submissionHash":"22d0b67a6ba06b7cfe937295ad77e3522116d5dc753539eaab7cff1e9050a867","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51004","feedbackHash":"77d07e617e9bbc33635e5ca80627fd829e1653e1c0bf00673d69df8762086520","nodeKey":"audit_permissions","submissionHash":"aeca1a4d139d88110f92bc5dcdcf3c7deb70904e21268f1a298af5c7dba300f6","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"e6b9b55be41b76c0cfa8d9386ee9b26af7e62b52d0d0ebed91ebf9c514ac3e16","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"03845cacb54c3a73","findings":[{"citation":"resolved","description":"cash() (CDPVault.sol:547-548) pays amount * backingPerUnit * (1 - fee) / price and the comment at 540-546 argues this is 'exactly neutral on backing by construction' and 'strictly IMPROVES backing, in every state'. That argument assumes the backing figure is a pool of assets removed pro rata. It is not: _backingPerUnit (583-590) adds _securedCollateralValue (606-615), which is min(securedCollateral * price, (totalDebt - minted - totalBadDebt) * mat / 100). When the second term binds, every imdUSD of principal a redemption cancels removes mat/100 (1.70-2.00) imdUSD of counted backing while supply falls by only 1, so the ratio falls, and keeps falling with every position-funded burn. The same mechanism makes the claim at 652 ('Its discount remains in this position as backing') false once the candidate's principal reaches zero: _secured (701-707) returns 0 for principal == 0, so the retained fee is ordinary debt-free, freely withdrawable collateral and backs nothing. Consequences: (1) redeemers in a run are paid in order, the first at 1 - fee and later ones at a lower, falling multiple, which is the opposite of the pro-rata neutrality the code documents; (2) the public backingPerUnit view can cross below par purely because a redemption happened, with no price move; (3) after the last eligible candidate's debt is redeemed away, work-minted imdUSD that remains has zero counted backing, cash reverts ZeroAmount, and the fees that were supposed to back it sit in former candidates' positions. Reachable with the shipped constants (mat 170, earnMat 2500): it needs the cap to bind, i.e. secured collateral above 1.7x prior principal and supply above 1.7x prior principal, which any repayment after work minting produces because totalEarned never falls. Smallest fix: decide which claim is the design. If pro-rata neutrality is wanted, the ratio term of backing has to shrink by at most the principal cancelled (for example cap it at prior principal x 100/100 for redemption purposes, or value only the reserve plus collateral actually surrendered); otherwise delete the neutrality/improvement claim at 540-546 and the 'remains as backing' claim at 652 and document that payout declines through a run.","line":542,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault, MockIMD collateral at $1, NHI 0.85 (mat 170, gap 50), empty Treasury. Tx1: A locks 684, draws 400 (ratio 171). Tx2: B locks 1800, draws 600. Tx3: worker W earns 250 (= earnLine 0 + 1000*2500/10000). Supply 1250. Tx4: B wipes 600. Now totalDebt 400, supply 650, securedCollateral*price = 684, cap = 400*170/100 = 680, backing 680/650 -> backingPerUnit() = 1e18 (capped). Tx5: R calls cash(100e18, 0, A). Expected per the comment: backingPerUnit after >= 1e18. Actual: R receives 95e18 gem (fee saturates at 500 bps because 100/650/2 > 4.5%), A's debt is 300, backing = min(589, 510) = 510, supply 550, backingPerUnit() = 927272727272727272. Tx6: a second cash(100e18, 0, A) pays only 88.09e18 and leaves backingPerUnit() = 755555555555555555 (340/450). Scratch test test/scratch/BackingFalls.t.sol reproduces these exact figures (assertGe(after, before) fails with 927272727272727272 < 1000000000000000000).","severity":"low","snippet":"        // against. With the fee applied on top it strictly IMPROVES backing, in every state.","title":"backingPerUnit falls across a position-funded redemption whenever the aggregate mat cap binds; cash's 'strictly improves backing in every state' and _redeemPosition's 'discount remains as backing' are"},{"citation":"resolved","description":"This is the open requester decision recorded as advisory 08f0352c in docs/INTERNAL-AUDIT-2026-10-04.md and docs/REDEMPTION-CHECKS.md; it is restated here only because question 3 asks directly whether a redeemer can pay less than the fee for its size, and because the earlier record describes it as a same-block effect while it holds across any number of blocks within the 12-hour window. The fee a chunk pays is floor + decayed base + its own increase (line 519-521), but the stored base after a burn that cancelled fresh principal is _redemptionRate(amount - freshCancelled) (line 563), i.e. the decayed base alone when the whole burn was fresh. So a redeemer who splits a burn into n chunks against a candidate whose principal is younger than FRESH_DEBT_WINDOW pays floor + increase(chunk) on each chunk and the base never accumulates; with n large the total converges to the 50 bps floor. The candidate (who retains the fee as collateral) receives up to 4.5 points less than the escalating fee was meant to leave with them, and the next redeemer against any candidate starts from a zero base. The comment at 562 ('The fresh part of the burn is charged in full') is only true of a single-shot burn. Smallest fix that keeps the b952037a mitigation: accumulate fresh increases into a per-candidate base (mapping candidate => rate, decayed like redemptionBaseRate) and charge floor + global base + candidateBase + increase, so a self-redeemer pumps only their own candidate's rate while a chunker faces the escalation.","line":563,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {CDPVault} from \"src/CDPVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract ScratchFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        setValue(v);\n    }\n\n    function setValue(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract ScratchAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @notice Redeeming FRESH principal in chunks pays only the floor fee: the fresh-debt exemption keeps\n/// the base rate from accumulating between chunks, so the same burn that costs 2.5% in one shot costs\n/// about 0.55% in forty. Aged principal, redeemed the same way, pays the escalating fee.\ncontract FreshChunkingTest is Test {\n    address private constant OTHER = address(0x0AAA);\n    address private constant CANDIDATE = address(0x0CAD);\n    address private constant REDEEMER = address(0x0DEE);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    ScratchFeed private primary;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new ScratchAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        // 1 IMD = 1/2000 ETH and 1 ETH = $2000, so 1 IMD = $1.\n        primary = new ScratchFeed(5e14);\n        ScratchFeed health = new ScratchFeed(0.85 ether); // mat 170, gap 50, ceiling 220\n        ScratchFeed spot = new ScratchFeed(5e14);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        stable = vault.stablecoin();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(OTHER, 2_000_000 ether);\n        imd.mint(CANDIDATE, 171_000 ether);\n        vm.stopPrank();\n        // Supply: 900k from OTHER (ratio 222, outside the band) + 100k from CANDIDATE (ratio 171).\n        _open(OTHER, 2_000_000 ether, 900_000 ether);\n        vm.prank(OTHER);\n        stable.transfer(REDEEMER, 40_000 ether);\n    }\n\n    function _open(address who, uint256 collateral, uint256 debt) private {\n        vm.startPrank(who);\n        imd.approve(address(vault), collateral);\n        vault.lock(collateral);\n        vault.draw(debt);\n        vm.stopPrank();\n    }\n\n    function _redeemInChunks(uint256 chunks, uint256 each) private returns (uint256 paid) {\n        vm.startPrank(REDEEMER);\n        for (uint256 i; i < chunks; ++i) {\n            paid += vault.cash(each, 0, CANDIDATE);\n        }\n        vm.stopPrank();\n    }\n\n    function test_freshPrincipalIsNotCheaperToRedeemThanAgedPrincipal() public {\n        _open(CANDIDATE, 171_000 ether, 100_000 ether);\n        uint256 quoteWholeBps = vault.redemptionFeeBps(40_000 ether); // one shot: 50 + 200 = 250 bps\n        assertEq(quoteWholeBps, 250, \"one-shot fee for 4% of supply\");\n\n        uint256 snapshot = vm.snapshotState();\n\n        // A: the candidate's principal is minutes old. Forty 1,000 imdUSD burns.\n        uint256 paidFresh = _redeemInChunks(40, 1_000 ether);\n        uint256 feeFreshBps = (40_000 ether - paidFresh) * 10_000 / 40_000 ether;\n        emit log_named_uint(\"fee paid, fresh principal, chunked (bps)\", feeFreshBps);\n        emit log_named_uint(\"base rate left for everyone else (1e18)\", vault.redemptionBaseRate());\n\n        vm.revertToState(snapshot);\n\n        // B: the same candidate, same chunks, after the 12-hour window has closed.\n        vm.warp(block.timestamp + 13 hours);\n        primary.setValue(5e14);\n        uint256 paidAged = _redeemInChunks(40, 1_000 ether);\n        uint256 feeAgedBps = (40_000 ether - paidAged) * 10_000 / 40_000 ether;\n        emit log_named_uint(\"fee paid, aged principal, chunked (bps)\", feeAgedBps);\n\n        // Chunking must not let fresh principal be redeemed for less than aged principal: the fee\n        // for a given size should not depend on whether the candidate borrowed this morning.\n        assertLe(paidFresh, paidAged, \"fresh principal was redeemed more cheaply than aged principal\");\n    }\n}","reproduction":"ParameterizedVault, $1 MockIMD, NHI 0.85. OTHER locks 2,000,000 and draws 900,000 (ratio 222, outside the band); CANDIDATE locks 171,000 and draws 100,000 (ratio 171). Supply 1,000,000; redemptionFeeBps(40,000e18) quotes 250 bps one-shot. Fresh: REDEEMER calls cash(1,000e18, 0, CANDIDATE) forty times in the next block(s): total gem received 39,776.1e18, i.e. 55 bps paid in total, redemptionBaseRate() == 0 afterwards. Aged: identical forty calls 13 hours after the draw: 39,382.2e18 received, 154 bps paid, base left at ~2%. Expected: fresh principal is not cheaper to redeem than aged principal and a 4%-of-supply burn pays at least the escalation its size implies. Scratch test test/scratch/FreshChunking.t.sol fails with 39776100000000000000000 > 39382200000000000000000.","severity":"low","snippet":"        redemptionBaseRate = freshCancelled == 0 ? base : _redemptionRate(amount - freshCancelled);","title":"Known advisory 08f0352c, quantified: chunked burns against fresh principal pay only the floor fee and leave the base rate at zero, across blocks as well as within one"},{"citation":"resolved","description":"barkFor records msg.sender's chosen beneficiary as the marker and bite (826-831) pays that address chip() of the bonus. Nothing excludes owner == beneficiary, and the owner is the party best placed to know the moment their position goes under (they can bundle the feed update and the mark through SwarmRelay). docs/PARAMETERS-2026-10-05.md states the borrower's cost of liquidation is 20% of the debt repaid; with a self-mark it is 18%, and if the borrower also supplies the imdUSD and bites after grace (or in the same transaction when NHI <= 0.60 and lull() == 0) the only cost is the protocol's cut, 2%. This is the normal behaviour of an open liquidation market and the second-key variant cannot be prevented on chain; it is recorded so the parameter record's 20% is read as the keeper-captured maximum, not the borrower's floor.","line":752,"path":"src/CDPVault.sol","reproduction":"NHI 0.60 (mat 200, lull 0). Borrower locks 200 IMD at $1 and draws 100. Price falls to $0.90: ratio 180 < 200. Borrower calls barkFor(self, self) then bite(self, 100e18) in the same block: collateralSeized = 100*1.2/0.9 = 133.33 IMD, bonus = 22.22, protocolCut = 2.22 to the Treasury, marker == msg.sender so the borrower receives 131.11 IMD and keeps the 66.67 left in the position. Expected penalty per the parameter record: 20 IMD-equivalent (20% of debt). Actual: 2.22 IMD, the protocol cut only.","severity":"info","snippet":"    function barkFor(address owner, address beneficiary) public nonReentrant {","title":"A borrower can mark their own position and so recover the marker's 10% of the liquidation bonus, cutting the effective penalty from 20% to 18% (to 2% if they also bite)"},{"citation":"resolved","description":"bite computes collateralSeized = mulDiv(debtToRepay, (100 + CHOP_PERCENT) * 1e16, price) with CHOP_PERCENT = 20, i.e. 1.2e18 and 120%. The same stale figure appears at CDPVault.sol:177 ('splits the existing 10% bonus'), CDPVault.sol:882 ('including the 10% payout' on badDebtOf, whose code uses 100 + CHOP_PERCENT) and ParameterizedVault.sol:218 ('seizable at the usual 10% bonus'). Fix: replace 1.1e18/110%/10% with 1.2e18/120%/20% or reference CHOP_PERCENT.","line":779,"path":"src/CDPVault.sol","reproduction":"Read bite at CDPVault.sol:795: price 1e18, debtToRepay 100e18 -> collateralSeized = 120e18, not the 110e18 the NatSpec at line 779 states.","severity":"info","snippet":"    /// @dev Payout is floor(debtToRepay * 1.1e18 / price) IMD, i.e. collateral worth 110% of the imdUSD burned","title":"NatSpec still describes a 10% bonus and a 1.1e18 payout after CHOP_PERCENT was raised to 20"},{"citation":"resolved","description":"docs/MAINNET-RUNBOOK.md deploys ParameterizedVault with StakedIMD (24 decimals) as gem and prices it through SharePriceFeed per 1e18 raw share units (about 8.7e13 for the figures in the brief). The arithmetic is decimal-agnostic and correct, but the contract header (line 28) and the constructor @param at line 279 ('Deployed, nonrebasing, fee-free MockIMD collateral (18 decimals)') describe a different deployment; in the base vault the price is also wei of ETH per IMD, not USD. Fix: state that collateral is any token priced per 1e18 raw units by _priceOrZero(), USD-denominated in ParameterizedVault.","line":28,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault constructed with a 24-decimal ERC-4626 share (test/ShareCollateral.t.sol) accepts it and prices it at rate * usd / 1e18 per 1e18 raw units; the NatSpec at line 28 and 279 says 18 decimals / MockIMD.","severity":"info","snippet":"/// @dev Both tokens use 18 decimals; price is USD per IMD scaled by 1e18.","title":"Contract NatSpec says both tokens use 18 decimals and the collateral is MockIMD; the deployed collateral is 24-decimal sIMD priced per 1e18 raw units"},{"citation":"resolved","description":"CDPVault.cover (486-500) calls stablecoin.burn(payer, amount) where payer is the Treasury returned by _surplus(), on behalf of any caller. The token-side documentation of what the registered vault may do with holders' balances is therefore incomplete: the Treasury's imdUSD can be burned by anyone's call. Fix: add cover to the list ('or the Treasury's, when anyone covers realized bad debt').","line":61,"path":"src/ImdUSD.sol","reproduction":"After any liquidation that drains a position, a stranger calls vault.cover(owner, 1e18): ImdUSD.burn(treasury, 1e18) executes with msg.sender == vault although the Treasury made no call (test/Cover.t.sol test_coverRetiresRealizedBadDebtWithTheTreasurysImdUSD).","severity":"info","snippet":"    /// @dev CDPVault only burns the caller's tokens during repayment, liquidation or redemption.","title":"ImdUSD.burn NatSpec says the vault only burns the caller's tokens; cover() burns the Treasury's"},{"citation":"resolved","description":"_mat (CDPVault.sol:1081-1085) floors at 170 since the 2026-10-05 parameter change, so the cliff 'mat - 1' from docs/COMPUTE-BACKING-DESIGN.md section 3 is 70 points = 7000 bps, and 2500 is about a third of it, giving roughly 128% worst-case backing with an empty reserve, not 120%. The same stale derivation is in DeploymentConfig.sol:146 ('which is 5000 at the loosest NHI'). The constant itself is a deliberate value and not a finding; the derivation written next to it is wrong.","line":72,"path":"src/Parameters.sol","reproduction":"mat() with NHI >= 0.85e18 returns 170 (CDPVault.sol:1082). The comment at Parameters.sol:72 says 150.","severity":"info","snippet":"    /// touches one at the loosest NHI (mat 150); this is half of it, 120% with an empty reserve.","title":"MAX_EARN_MAT_BPS rationale cites 'mat 150' and a 5000-bps cliff; the shipped mat floor is 170 and the cliff is 7000"},{"citation":"resolved","description":"Lines 159-167 carry the @notice/@dev for the annual stability fee (including the MUST-call-drip-in-the-same-transaction rule a subclass governor needs to know) immediately followed by a second @notice for the redemption divisor, and the whole block documents redemptionDivisor() at 168; duty() at 172 is undocumented. Generated ABI docs will show the fee rule under the wrong function. Fix: move lines 159-165 above duty().","line":166,"path":"src/CDPVault.sol","reproduction":"forge doc / natspec extraction for CDPVault.duty() yields no documentation, while redemptionDivisor() carries two @notice tags.","severity":"info","snippet":"    /// @notice Each redemption raises the fee base by redeemed / supply / this. The base vault reads","title":"The stability-fee NatSpec (duty, drip-before-change requirement) is attached to redemptionDivisor(); duty() has none"}],"hash":"22d0b67a6ba06b7cfe937295ad77e3522116d5dc753539eaab7cff1e9050a867","nodeId":"ea4773e6-895a-4e11-9b88-ec2bbbc3188e","outcome":"completed","summary":"The review is complete. Findings are in `.imd-findings.json` (2 low, 6 info, no critical or high); the two scratch tests under `test/scratch/` reproduce the two low items and fail on the current tree. Only `.imd-findings.json` and `test/scratch/` were created; no source changed and the existing suite is green (490 passed, 4 skipped).\n\n## Answers to the eight questions\n\n**1. Positions.** Nothing wrong. `lock` and `lockIMD` credit the measured balance delta, so a share vault that over-reports `deposit` cannot inflate the credit, and every state-changing entry point is `nonReentrant`, so a callback from sIMD or IMD during the deposit cannot interleave another credit. `free` with debt is feed-gated and health-checked on the remaining collateral; `draw` checks health on principal plus accrued fees plus the new amount. No path moves another account's funds except the documented ones (`bite`, `cash` against a candidate, `cover` spending the Treasury).\n\n**2. Liquidation.** The formula holds: seizure is floor(debt × 1.2e18 / price), the marker and protocol cuts come out of the bonus and Parameters enforces chip + cut ≤ 10000. The dust sweep only ever adds less than the seizure for one wei of debt (about 13,800 raw sIMD, worth 1.2 wei of debt), so it cannot be gamed. No permanent freeze is reachable: any position with collateral worth more than 1.2 wei of debt can be partially bitten, and the sweep then drains it. Two notes: a borrower can mark themselves and recover the 10% marker share, so the 20% penalty in the parameter record is the keeper-captured maximum, not the borrower's floor (info finding); and liquidation halts whenever the primary and spot feeds diverge by more than skew, which a pool mover can cause for the life of a spot attestation. That is the documented cost of the divergence guard, not a defect.\n\n**3. Redemption.** The fee rounds up to whole basis points, the payout rounds down, the reserve part of the burn rounds reserve-funded debt down so every wei a candidate surrenders is covered by cancelled debt, and the ratio guard is exact on fractions. Two issues:\n- **Low.** The backing-per-unit figure falls across every position-funded redemption once the aggregate mat cap binds, because cancelling one imdUSD of principal removes 1.70 to 2.00 of counted backing while supply falls by one. In the reproduction the first redeemer is paid at par, the second at 88.09 per 100, and the public `backingPerUnit` goes from 1.0 to 0.927 to 0.756 with no price move. The comments in `cash` claiming the channel is neutral and \"strictly improves backing in every state\", and the claim that the retained fee \"remains in this position as backing\", are not properties the code has: a candidate with no principal left contributes zero.\n- **Low, known.** Chunking burns against fresh principal pays only the floor fee and leaves the base at zero: 55 bps for a 4%-of-supply burn against a day-old borrower, versus 154 bps chunked against aged principal and 250 bps one-shot. This is the open advisory 08f0352c; I report it because question 3 asks directly, it holds across blocks rather than only within one, and a per-candidate fresh base is a fix that keeps the self-redemption mitigation.\n\n**4. Bad debt.** Consistent. `totalBadDebt` and `_recordedBadDebt` are only ever written together; `cover` requires zero collateral and a nonzero record, and `_reduceDebt` refuses more than is owed, so coverage cannot exceed the debt, reach a collateralised position, or be spent twice. Re-collateralising a drained position freezes the record at its old value until debt falls below it, which the NatSpec documents as intended.\n\n**5. Stability fee.** Correct. The index is linear from the last checkpoint, `Parameters._apply` calls `drip()` before the new rate becomes readable, and `chiOf` is only ever set to the current `chi()`, which never decreases. Fee accrual truncates by under one wei per touch.\n\n**6. Price gating.** Every value-moving action checks primary, NHI, the s","treeHash":null,"usage":{"cachedInputTokens":2990716,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":95770,"runtime":"claude","turns":40,"wallClockMs":1399276}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"a4c81f495eb81dd0","findings":[{"citation":"resolved","description":"cover() is the only path that retires realized bad debt without the borrower's cooperation, and it refuses any position whose collateral is nonzero. After a liquidation drains a position (collateral 0, _recordedBadDebt = debtOf, totalBadDebt raised), the owner may still call lock(amount) for any amount >= 1: lock has no price gate, no minimum and no health check. With mainnet collateral (sIMD, price about 8.7e13 per 1e18 raw units) one wei of debt seizes floor(1.2e18/price), about 13,800 raw units, in bite(), so a position holding fewer raw units than that can never be bitten (every bite reverts InsufficientCollateral, and the dust sweep only runs inside a successful bite); redemption against it reverts RedemptionWorsensRatio because collateral*amount/debt rounds to 0; and cover() reverts NoRealizedBadDebt because collateral != 0. Only the owner's own wipe() can now reduce _recordedBadDebt, and nothing compels it. Consequences, all permanent: (1) Treasury.withdraw(imdUSD) and Treasury.payStream refuse to take the Treasury's imdUSD below totalBadDebt (BadDebtFirst), so protocol revenue up to the frozen amount can neither be withdrawn, streamed nor spent on cover; (2) ParameterizedVault.backedDebt subtracts totalBadDebt, so the work ceiling loses earnMat x frozen for ever; (3) _securedCollateralValue subtracts totalBadDebt from the debt the mat cap applies to, lowering backingPerUnit for every redeemer. Cost to the griefer: one raw unit of sIMD (about 1e-22 dollars) and gas; the drained borrower has no reason not to, since the imdUSD they borrowed is already theirs. A milder variant needs no dust: a drained borrower who later re-collateralises to health keeps _recordedBadDebt until they repay, so a healthy, fully collateralised position holds the Treasury's imdUSD hostage for as long as they like. Even above the one-wei threshold, topping a drained position up with worthless dust works in practice because no keeper will pay gas to bite dust worth less than a cent. Reachable with the constants as committed (CHOP_PERCENT 20, sIMD 24-decimal collateral, LINE $1M): a $50k shortfall from one crash locks $50k of fee revenue. Smallest fix: in cover(), accept a position with nonzero collateral when it is underwater at the current price (_collateralRatio(collateral, debt, _price()) < 100, i.e. the collateral is worth less than the debt it stands behind), sweep that collateral to the Treasury (the payer takes what is left) and set position.collateral = 0 before _reduceDebt; keep refusing positions whose collateral covers their debt. Alternatively let bite() seize the whole remaining collateral when it is below the one-wei seizure, but that still leaves the uneconomic-dust and healthy-recapitalisation variants, so the cover-side change is the one that closes the Treasury lock.","line":488,"path":"src/CDPVault.sol","reproduction":"Fresh ParameterizedVault over an sIMD-shaped share (24 decimals, 1 share = 7.95 IMD), IMD/ETH 0.00546e18, ETH/USD 2000e8, NHI 0.85 (mat 170, grace 6h). BORROWER lockIMD(1000 IMD) [worth $10,920], draw(6400). KEEPER lockIMD(5000 IMD), draw(20000). Primary feed halves to 0.00273e18. bark(BORROWER); warp 6h; KEEPER bite(BORROWER, collateral*price/1.2e18) and, if a one-wei-seizure remainder is left, bite(BORROWER, 1): collateral == 0, totalBadDebt == debtOf(BORROWER) == 1,850.19 imdUSD. KEEPER transfers 1,851.19 imdUSD to the Treasury (stands in for collected fees). BORROWER: share.deposit(1 IMD) then vault.lock(1) [one raw unit]. Expected: the loss is still realized (the dust cannot be seized: one wei of debt seizes 27,645 raw units at this price), so cover(BORROWER, 1850.19e18) succeeds, totalBadDebt == 0 and the operator can withdraw the Treasury's imdUSD. Actual: cover reverts NoRealizedBadDebt(); bite(BORROWER, 1) reverts InsufficientCollateral(); cash(1e18, 0, BORROWER) with the reserve empty reverts RedemptionWorsensRatio(); Treasury.withdraw(imdUSD, operator, spare+1) reverts BadDebtFirst(1850194630136986297600); backedDebt() reads 20,000e18 while totalDebt is 21,850.19e18. Nothing but the borrower's own wipe can ever change this.","severity":"medium","snippet":"        if (_positions[owner].collateral != 0 || _recordedBadDebt[owner] == 0) revert NoRealizedBadDebt();","title":"cover: one raw unit of sIMD locked on a drained position makes its realized bad debt permanently uncoverable, unliquidatable and unredeemable, and freezes the Treasury's imdUSD behind BadDebtFirst"},{"citation":"resolved","description":"_redemptionRate(amount) returns decayed base + amount/priorSupply/divisor, capped, and cash() charges that single rate on the whole burn. The increase is measured against the supply before each call, and the base after a call is exactly that call's rate, so a sequence of n burns pays the rate at the end of each chunk rather than the rate at the end of the whole. DeploymentConfig.sol:129-130, Parameters.sol:85-87 and docs/PARAMETERS-2026-10-05.md present the schedule as a property of the fraction redeemed ('redeeming 10% of supply at once costs 5.5% (the 5% cap)'); in fact it is a property of a single call, and a redeemer who splits pays roughly the integral of the rate (about half the increase) for the same total. The brake on a run is therefore weaker than the parameter record assumes, and the fee a redeemer pays for its size is path dependent. This is the same shape as Liquity's fee and may be accepted; if the documented numbers are meant to hold, the smallest fix is to charge each burn the mean of the pre- and post-burn rates (floor + decayed + increase/2), which makes the total fee independent of how a redemption is split and leaves the base-rate update unchanged; otherwise correct the three documents to say 'in one call'. Reachable with the constants as committed (divisor 2).","line":687,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault over 18-decimal IMD at $1, supply 1,000,000 imdUSD (one position: lock 4M IMD, draw 1M = LINE), Treasury holding 500,000 IMD so every redemption is reserve-only, same block, fresh base. cash(100_000e18, 0, 0) in one call: 95,000 IMD out, effective fee 500 bps, redemptionBaseRate after = 4.5e16 (cap). Ten calls of cash(10_000e18): 96,753 IMD out, effective fee 324 bps, base after = 4.5e16. One hundred calls of cash(1_000e18): 96,939 IMD out, effective fee 306 bps, base after = 4.5e16. Expected per the parameter record: 10% of supply costs 5% whichever way it is burned. Actual: 3.06% when split, with the identical base rate left behind for the next redeemer.","severity":"low","snippet":"        uint256 increase = amount == 0 ? 0 : prior == 0 ? cap : Math.mulDiv(amount, 1e18, prior) / redemptionDivisor();","title":"cash: the redemption fee is charged at the post-increase rate per call, so splitting one redemption into several calls pays well under the documented fee for its size while leaving the same base rate "},{"citation":"resolved","description":"bite() seizes floor(debtToRepay * 1.2e18 / price) (CHOP_PERCENT = 20, decided 2026-10-05), but the function's NatSpec says 1.1e18 / 110%. The same stale figure appears at src/CDPVault.sol:177 ('it splits the existing 10% bonus'), src/CDPVault.sol:882 ('including the 10% payout') and src/ParameterizedVault.sol:218 ('seizable at the usual 10% bonus'). An integrator sizing a liquidation or a bad-debt estimate from these comments is off by a factor of two on the bonus. Fix: state (100 + CHOP_PERCENT)% / 1.2e18 or refer to CHOP_PERCENT.","line":779,"path":"src/CDPVault.sol","reproduction":"bite(owner, 100e18) at price 1e18 seizes 120e18 collateral (line 795), not the 110e18 the comment on line 779 states.","severity":"info","snippet":"    /// @dev Payout is floor(debtToRepay * 1.1e18 / price) IMD, i.e. collateral worth 110% of the imdUSD burned","title":"NatSpec still describes a 10% liquidation bonus and a 1.1e18 payout; CHOP_PERCENT is 20"},{"citation":"resolved","description":"Since commit e52a025 cover(owner, amount) burns the Treasury's imdUSD against a drained position through _reduceDebt, which lowers the position's debt, _recordedBadDebt and totalBadDebt. That is exactly the insurance / forgiveness the comments at src/CDPVault.sol:270-271 and src/CDPVault.sol:883 say does not exist. Update both to say that only repayment and cover() reduce a recorded residual.","line":270,"path":"src/CDPVault.sol","reproduction":"Drain a position (see the medium finding), fund the Treasury with imdUSD equal to the residual, call cover(owner, residual) from any account: debtOf(owner) and totalBadDebt both read 0 (test/Cover.t.sol test_coverRetiresRealizedBadDebtWithTheTreasurysImdUSD), while the NatSpec says 'Only debt repayment reduces a recorded residual'.","severity":"info","snippet":"    /// @dev Measurement only: no insurance or debt forgiveness. Only debt repayment reduces a recorded","title":"NatSpec on totalBadDebt and badDebtOf claims 'no insurance or debt forgiveness'; cover() now retires it with the Treasury's imdUSD"},{"citation":"resolved","description":"_badDebtOf computes a mark-to-market shortfall at the current price for any position (including one that still holds collateral), while _recordBadDebt (lines 1041-1051) records debtOf(owner) only once collateral is zero and never calls _badDebtOf. The two legitimately disagree whenever a position is underwater but not drained, which the comment at lines 1034-1040 explains; the sentence at 889-891 promising they 'can never disagree' is the stale half. Replace it with a pointer to the realized-only rule.","line":890,"path":"src/CDPVault.sol","reproduction":"Position with collateral 170e18, debt 100e18, price falls to 0.5e18 and nobody liquidates: badDebtOf(owner) returns 100e18 - 170e18*0.5/1.2 = 29.17e18 while totalBadDebt and the position's recorded residual are 0.","severity":"info","snippet":"    /// the full liquidation payout. Shared with _recordBadDebt so the accumulator and this view can","title":"NatSpec says _badDebtOf is 'shared with _recordBadDebt'; the accumulator never calls it and measures a different quantity"},{"citation":"resolved","description":"CDPVault.cover (src/CDPVault.sol:493) calls stablecoin.burn(payer, amount) with payer = the Treasury, which is not msg.sender and gave no allowance. The design is intentional; the token's documentation of its own trust boundary should say that the vault may burn its Treasury's balance as well as a caller's.","line":61,"path":"src/ImdUSD.sol","reproduction":"cover(owner, amount) from STRANGER reduces stable.balanceOf(treasury) by amount and STRANGER's balance by nothing (test/Cover.t.sol lines 60-81).","severity":"info","snippet":"    /// @dev CDPVault only burns the caller's tokens during repayment, liquidation or redemption.","title":"ImdUSD.burn NatSpec says the vault only burns the caller's tokens; cover() burns the Treasury's"},{"citation":"resolved","description":"The arithmetic is decimal-agnostic (every formula is per 1e18 raw units, see SharePriceFeed), so this is documentation only, but the first line a reader meets is wrong for mainnet: gem is sIMD with 24 decimals, and _price() is USD per 1e18 raw units of the share, not per IMD. The @param at line 279 ('MockIMD collateral (18 decimals)') has the same problem. State the per-1e18-raw-units convention here.","line":28,"path":"src/CDPVault.sol","reproduction":"With gem = sIMD, gem.decimals() == 24 and collateralPriceFeed.latestValue() is about 8.7e13 (USD per 1e18 raw share units), not 'USD per IMD scaled by 1e18' (about 1.09e19).","severity":"info","snippet":"/// @dev Both tokens use 18 decimals; price is USD per IMD scaled by 1e18.","title":"Contract-level NatSpec says both tokens use 18 decimals and price is USD per IMD; the deployed collateral is 24-decimal sIMD priced per 1e18 raw units"},{"citation":"resolved","description":"Lines 159-165 describe the annual stability fee and the rule that a governor MUST drip in the same transaction as a rate change, but the next function declared is redemptionDivisor() (line 168), so the generated documentation attributes the fee text and its second @notice to the divisor and leaves duty() (line 172) undocumented. Move the block above duty().","line":159,"path":"src/CDPVault.sol","reproduction":"forge doc or any NatSpec consumer shows redemptionDivisor() with two @notice entries ('Annual stability fee...' and 'Each redemption raises the fee base...') and duty() with none.","severity":"info","snippet":"    /// @notice Annual stability fee on open debt, in basis points, accrued linearly from the last","title":"duty()'s NatSpec (stability fee, drip requirement) is attached to redemptionDivisor(); duty() has none"},{"citation":"resolved","description":"Parameters._validate only checks that the asset has code and decimals and that the feed answers isStale()/latestValue() in a well-formed way; nothing bounds the value a listing may claim. After the 48-hour delay a listing of a governor-controlled token at a governor-controlled feed makes Treasury.reserveValueUsd arbitrarily large, so ParameterizedVault.earnLine() (reserveValue + ...) admits any amount of work minting to rights holders, and _redemptionReserveBacking counts the same figure toward backingPerUnit (capped at par, so redeemers are not overpaid, but a genuinely under-backed system reads as fully backed). This is the intended power of the role, visible for two days, and not a bypass; it is recorded because ParameterizedVault's header (lines 23-26) says the governor can change 'never where the price comes from', which is true for collateral pricing and false for reserve pricing. Mitigation if wanted: cap earnLine's reserve term at a multiple of totalDebt, or require listed feeds to be protocol-deployed (UsdPriceFeed / SharePriceFeed) rather than arbitrary addresses.","line":230,"path":"src/Parameters.sol","reproduction":"Governor proposes proposeReserveAsset(T, F, 10000) where T is any ERC20 it minted to the Treasury and F returns (1e30, now) with isStale() false; after 48h applyPending succeeds (Treasury.validateReserveAsset passes). earnLine() rises by balance x 1e30 / 1e18; a rights holder's earn(amount) up to that figure succeeds. Expected per the header comment: governance cannot change where a price comes from.","severity":"info","snippet":"    function proposeReserveAsset(IERC20 asset, ISwarmFeed priceFeed, uint256 haircutBps) external {","title":"Trust assumption: the governor can list any token with any price source and haircut as reserve, which raises the work ceiling and the redemption backing without a bound"}],"hash":"4c3b66af11a67164bd08a4a42e515a47351d0b07e489182bf41d82b51cbd8489","nodeId":"b5c93217-63d7-48ec-b777-18407881ec38","outcome":"completed","summary":"The review is complete. The working tree is clean apart from the two allowed outputs, both excluded by the harness's git exclude rules, so nothing stray remains.\n\n**Outcome.** The vault's core accounting held up under every numbered question. I found one Medium defect with a failing Foundry proof, one Low economic deviation with measured numbers, and seven informational items (six stale NatSpec claims and one governance trust assumption). All are in `.imd-findings.json`, with each snippet verified against the tree.\n\n**The Medium (CDPVault.cover, line 488).** A borrower whose position was drained by liquidation can lock one raw unit of sIMD afterward. With mainnet pricing, one wei of debt seizes roughly 13,800 raw units, so no `bite` can ever succeed again, redemption against it reverts, and `cover` refuses it because collateral is nonzero. The realized bad debt therefore stays in `totalBadDebt` forever, which locks the Treasury's imdUSD behind `BadDebtFirst`, lowers the work ceiling, and lowers redemption backing. The proof test in `test/scratch/DustLockedBadDebt.t.sol` fails on the current code with `NoRealizedBadDebt()` after confirming the Treasury lock. Suggested fix: let `cover` accept an underwater position by sweeping its collateral to the Treasury first.\n\n**The Low (CDPVault._redemptionRate, line 687).** The fee is charged at each call's post-increase rate, so splitting a 10%-of-supply redemption into a hundred calls costs 306 bps instead of the documented 500 bps, with the same base rate left for everyone else. This matches Liquity's design and may be accepted, but the parameter record's table only holds for a single call.\n\n**Answers to the numbered questions, briefly.**\n1. Positions: no path leaves a position below mat or moves funds for a non-caller. `lockIMD` credits by balance delta behind `nonReentrant`, so misreporting or reentrancy cannot inflate it.\n2. Liquidation: payouts match the formula; the dust sweep is worth about one wei of debt and cannot be gamed. The only freeze is the dusted drained position above.\n3. Redemption: eligibility, the ratio guard and the pro-rata cap are correct; the reserve cannot be drained below pro-rata backing. The fee-splitting item is the one deviation.\n4. Bad debt: coverage cannot exceed what is owed or be spent twice, and the accumulator stays consistent. The uncoverable-dust case is the gap.\n5. Stability fee: `Parameters._apply` drips before changing the rate, and the index is monotone, so `chiOf` can never exceed `chi`.\n6. Price gating: every value-moving action checks freshness and divergence; `lock`, `wipe`, debt-free `free` and `cover` are safe without a price.\n7. Work issuance: same-transaction debt is excluded via the transient slot; the accepted two-transaction round trip is documented in the prior audits.\n8. Arithmetic: no overflow at realistic extremes, all paying divisions round against the recipient, and units are consistent for 24-decimal sIMD priced per 1e18 raw units.\n\n**Coverage.** Read in full: CDPVault, ParameterizedVault, ImdUSD, Parameters, Governed, Treasury, TreasuryFactory, UsdPriceFeed, SharePriceFeed, SpotFeed, DeploymentConfig, the interfaces, and the relevant parts of SwarmFeed. Not reached: the SwarmRelay, OracleAsker, SwarmWorkOracle and WorkOracleFactory internals, and the live sIMD contract, whose transfer-hold semantics I took from the repository's comments.","treeHash":null,"usage":{"cachedInputTokens":6281415,"inputTokens":1090,"model":"claude-fable-5-1","outputTokens":106377,"runtime":"claude","turns":47,"wallClockMs":1839278}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"866bf60e68b081d9","findings":[{"citation":"resolved","description":"_securedCollateralValue (CDPVault.sol:606-615) and _redemptionRate (CDPVault.sol:681-689) exclude only what the CURRENT TRANSACTION added, through the transient slots SECURED_THIS_TX_SLOT and MINTED_THIS_TX_SLOT. The EVM clears transient storage at the end of every transaction, so a position opened in transaction 1 of a block counts in full as secured collateral and as prior supply in transaction 2 of the same block, and is closed in transaction 3. The comment at CDPVault.sol:223-228 says this slow round trip 'is the accepted design ... and costs real capital in an open position, not gas'. It does not: inside one block (a Flashbots bundle, or three consecutive transactions from one key) the capital is exposed for zero seconds, accrues a zero-second stability fee and faces no liquidation (the position is at 200%). The only cost is the redemption fee on the reserve part, which is itself diluted by the attacker's own minted supply (450 bps instead of 500). Who loses: every remaining imdUSD holder. When backing per unit is below 1 (the stressed state the cash() comment block discusses at length, reached after work-minted imdUSD outlives the debt it was minted against), the attacker turns the pro-rata payout into a par payout and takes the whole reserve; backing for the rest of the supply falls from 0.4 to about 0.03 in the reproduction. Reachable with the constants as committed (mat 170, earnMat 2500, divisor 2, line $1M). Call sequence (external caller, three transactions, one block): lock(2000 IMD); draw(1000) | cash(100, 0, address(0)) | wipe(1000); free(2000). Smallest fix: make the exclusion block-scoped rather than transaction-scoped, with the same idiom: replace the two transient tallies with a storage pair (uint256 tallyBlock, uint256 tallied) that resets when block.number changes, and do the same for ParameterizedVault's DEBT_AT_TX_START_SLOT. That closes the bundle at the cost of two SSTOREs per touch. A stronger fix, consistent with the existing fresh-debt record, is to count a position's secured term and its principal toward prior only once its principal is older than FRESH_DEBT_WINDOW. Either way, correct the comment at lines 226-228.","line":609,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Math} from \"@openzeppelin/contracts/utils/math/Math.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {CDPVault} from \"src/CDPVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {MockWorkOracle} from \"src/MockWorkOracle.sol\";\nimport {Treasury} from \"src/Treasury.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract ProofFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract ProofMirror is ISwarmFeed {\n    ISwarmFeed private immutable primary;\n\n    constructor(ISwarmFeed p) {\n        primary = p;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return primary.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return primary.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return primary.maxAge();\n    }\n}\n\ncontract ProofAggregator {\n    /// @dev A pure constant: `vm.etch` copies code only, so a storage initializer would read as zero.\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @notice The backing cap on redemption, and the work ceiling's debt term, are both defeated by a\n/// three-transaction round trip inside ONE block: open a 200% position, redeem (or earn), close.\n/// The transient-storage exclusions only see a single transaction; the source comments say the\n/// cross-transaction version \"costs real capital in an open position, not gas\", but here the\n/// capital is exposed for zero seconds and the attacker takes the whole reserve at par while the\n/// burn found backing at 0.4.\ncontract BackingRoundTripProof is Test {\n    address private constant BORROWER = address(0xBA);\n    address private constant WORKER = address(0xCA);\n    address private constant ATTACKER = address(0xA7);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    Treasury private treasury;\n    MockWorkOracle private oracle;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new ProofAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        // 1 IMD = 1/2000 ETH and 1 ETH = $2000, so the vault prices IMD at exactly $1.\n        ProofFeed primary = new ProofFeed(uint256(1 ether) * 1e18 / 2000 ether);\n        ProofFeed health = new ProofFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new ProofMirror(primary))\n        );\n        stable = vault.stablecoin();\n        treasury = vault.treasury();\n        oracle = MockWorkOracle(address(vault.oracle()));\n        vm.startPrank(APPROVED_OPERATOR);\n        oracle.grantRights(WORKER, 1_000 ether);\n        imd.mint(BORROWER, 1_700 ether);\n        imd.mint(ATTACKER, 2_000 ether);\n        imd.mint(address(treasury), 100 ether); // the reserve\n        vm.stopPrank();\n        vm.prank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(ATTACKER);\n        imd.approve(address(vault), type(uint256).max);\n    }\n\n    /// @dev A borrower opens 1000 of debt, a worker mints 250 against it, the borrower unwinds. 100 of\n    /// reserve now stands behind 250 of work-issued imdUSD: backing 0.4, the state the\n    /// `test_debtUnwindCannotLeaveReserveRedemptionWorseningBacking` test pins.\n    function _underback() private {\n        vm.startPrank(BORROWER);\n        vault.lock(1_700 ether);\n        vault.draw(1_000 ether);\n        vm.stopPrank();\n        vm.prank(WORKER);\n        vault.earn(250 ether);\n        vm.startPrank(BORROWER);\n        vault.wipe(1_000 ether);\n        vault.free(1_700 ether);\n        vm.stopPrank();\n        assertEq(stable.totalSupply(), 250 ether);\n        assertEq(vault.backingPerUnit(), 0.4 ether, \"100 of reserve behind 250 of supply\");\n        // The worker hands the attacker 100 imdUSD to redeem with.\n        vm.prank(WORKER);\n        stable.transfer(ATTACKER, 100 ether);\n    }\n\n    function test_sameBlockRoundTripTakesTheReserveAtParWhileBackingIsPointFour() public {\n        _underback();\n        uint256 backingBefore = vault.backingPerUnit();\n        uint256 reserveBefore = imd.balanceOf(address(treasury));\n        uint256 blockBefore = block.number;\n\n        // Transaction 1: open a 200% position. Transaction 2: redeem. Transaction 3: close it.\n        // `isolate = true` makes each call its own transaction; block.number never moves.\n        vm.startPrank(ATTACKER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        uint256 out = vault.cash(100 ether, 0, address(0));\n        vault.wipe(1_000 ether);\n        vault.free(2_000 ether);\n        vm.stopPrank();\n        assertEq(block.number, blockBefore, \"the whole sequence fits in one block\");\n\n        // What the burn should have been paid at the backing it found: 100 x 0.4 less the fee.\n        uint256 feeBps = 50 + Math.ceilDiv(Math.mulDiv(100 ether, 1e18, 250 ether) / vault.redemptionDivisor(), 1e14);\n        if (feeBps > 500) feeBps = 500;\n        uint256 proRata = Math.mulDiv(100 ether, Math.mulDiv(backingBefore, 10_000 - feeBps, 10_000), 1e18);\n        assertEq(proRata, 38 ether, \"pro-rata payout for 100 of a 250 supply backed at 0.4\");\n\n        // Actual: paid at par less the fee, and the attacker's collateral is gone again.\n        (uint256 collateral, uint256 debt) = vault.positions(ATTACKER);\n        assertEq(collateral, 0);\n        assertEq(debt, 0);\n        assertLe(out, proRata, \"a redemption may not take more than its pro-rata share of backing\");\n        assertGe(imd.balanceOf(address(treasury)), reserveBefore - proRata, \"reserve drained beyond pro-rata\");\n    }\n}","reproduction":"State: ParameterizedVault with IMD priced at $1 (primary 5e14 wei/IMD, Chainlink 2000e8, 8 decimals), NHI 0.85 (mat 170). BORROWER lock 1700, draw 1000; WORKER earn 250 (ceiling = 25% of 1000); BORROWER wipe 1000, free 1700. Treasury holds 100 IMD. Now totalSupply = 250, reserve = 100, backingPerUnit() = 0.4e18 (the same state test_debtUnwindCannotLeaveReserveRedemptionWorseningBacking pins). ATTACKER holds 100 imdUSD and 2000 IMD. Tx 1: lock(2000), draw(1000). Tx 2: cash(100e18, 0, address(0)). Tx 3: wipe(1000), free(2000). All three in one block (block.number unchanged). Expected: payout <= 100 x 0.4 x (1 - 5%) = 38 IMD, the pro-rata share of the backing the burn found. Actual: cash returns 95.5e18 IMD (feeBps 450, payoutScale 0.955e18, backingPerUnit read as 1e18 because the attacker's 1700 of mat-capped secured collateral was counted), the Treasury keeps 4.5 IMD against 150 imdUSD of remaining supply, and the attacker's position is empty again. Run: forge test --match-path test/scratch/BackingRoundTripProof.t.sol","severity":"medium","snippet":"        uint256 held = secured > added ? secured - added : 0;","title":"Backing cap on redemption is transaction-scoped: a same-block borrow / redeem / unwind bundle takes the reserve at par while backing is 0.4"},{"citation":"resolved","description":"backedDebt() caps totalDebt at _debtAtTransactionStart(), a transient slot written by _debtChanged and cleared when the transaction ends. The REVISION note for finding 4d30331c (ParameterizedVault.sol:204-212) closes the single-transaction round trip and says the cross-transaction version is accepted because 'the cost of it is real capital at risk in an open position, not gas'. In one block the position is open for zero seconds: a rights holder borrows in transaction 1, earns a quarter of that debt in transaction 2 (backedDebt reads the full debt because the slot is empty), repays and withdraws in transaction 3. The block ends with work-minted imdUSD, no debt, no collateral and, at launch, an empty register, so backingPerUnit() is 0 for that supply. Who loses: every imdUSD holder, by dilution of backing; the minter needed only rights (attested work) and collateral worth 8x the amount minted for the length of a block. Reachable with the constants as committed (earnMat 2500, mat 170). Call sequence (one key, three transactions, one block): lock(2000); draw(1000) | earn(250) | wipe(1000); free(2000). Smallest fix: make DEBT_AT_TX_START_SLOT block-scoped (store block.number alongside the recorded debt in ordinary storage and treat a record from an older block as absent), or exclude principal younger than FRESH_DEBT_WINDOW from the debt term using the per-position recentlyMinted record that draw already maintains. Correct the comment at lines 211-212 either way.","line":221,"path":"src/ParameterizedVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {CDPVault} from \"src/CDPVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {MockWorkOracle} from \"src/MockWorkOracle.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract CeilingFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract CeilingMirror is ISwarmFeed {\n    ISwarmFeed private immutable primary;\n\n    constructor(ISwarmFeed p) {\n        primary = p;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return primary.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return primary.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return primary.maxAge();\n    }\n}\n\ncontract CeilingAggregator {\n    /// @dev A pure constant: `vm.etch` copies code only, so a storage initializer would read as zero.\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @notice `backedDebt` excludes debt created in the current TRANSACTION. A worker who borrows in one\n/// transaction, earns in the next and unwinds in a third, all in one block, mints work-backed imdUSD\n/// against debt that never outlives the block. The comment on `backedDebt` says the cross-transaction\n/// version costs \"real capital at risk in an open position, not gas\"; inside one block it risks nothing.\ncontract WorkCeilingRoundTripProof is Test {\n    address private constant WORKER = address(0xCA);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    MockWorkOracle private oracle;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new CeilingAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        // 1 IMD = 1/2000 ETH and 1 ETH = $2000, so the vault prices IMD at exactly $1.\n        CeilingFeed primary = new CeilingFeed(uint256(1 ether) * 1e18 / 2000 ether);\n        CeilingFeed health = new CeilingFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new CeilingMirror(primary))\n        );\n        stable = vault.stablecoin();\n        oracle = MockWorkOracle(address(vault.oracle()));\n        vm.startPrank(APPROVED_OPERATOR);\n        oracle.grantRights(WORKER, 1_000 ether);\n        imd.mint(WORKER, 2_000 ether);\n        vm.stopPrank();\n        vm.prank(WORKER);\n        imd.approve(address(vault), type(uint256).max);\n    }\n\n    function test_sameBlockRoundTripMintsWorkAgainstDebtThatIsGoneBeforeTheBlockEnds() public {\n        // A fresh vault: no debt, no reserve, so the ceiling is zero and nothing may be earned.\n        assertEq(vault.earnLine(), 0);\n        vm.prank(WORKER);\n        vm.expectRevert(CDPVault.WorkCeilingReached.selector);\n        vault.earn(1);\n\n        uint256 blockBefore = block.number;\n        // Transaction 1: borrow. Transaction 2: earn a quarter of that debt. Transaction 3: unwind.\n        // `isolate = true` makes each call its own transaction; block.number never moves.\n        vm.startPrank(WORKER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        // Expected: the ceiling's debt term is backed only by positions that outlive the block, so\n        // debt opened in this block does not authorise this mint.\n        vm.expectRevert(CDPVault.WorkCeilingReached.selector);\n        vault.earn(250 ether);\n        vault.wipe(1_000 ether);\n        vault.free(2_000 ether);\n        vm.stopPrank();\n        assertEq(block.number, blockBefore, \"the whole sequence fits in one block\");\n\n        // Actual on the current code: the earn succeeds, and the block ends with 250 work-minted imdUSD,\n        // no debt, no collateral and an empty reserve.\n        assertEq(vault.totalDebt(), 0);\n        assertEq(imd.balanceOf(address(vault)), 0, \"no collateral is left in the vault\");\n        assertEq(stable.totalSupply(), 0, \"no work-minted imdUSD should have been issued\");\n    }\n}","reproduction":"State: fresh ParameterizedVault, IMD at $1, NHI 0.85, empty register, WORKER holds 1000 of work rights and 2000 IMD. earnLine() == 0 and earn(1) reverts WorkCeilingReached. Tx 1: lock(2000), draw(1000). Tx 2: earn(250). Tx 3: wipe(1000), free(2000). Same block throughout. Expected: the earn in tx 2 reverts WorkCeilingReached, because no debt that outlives the block backs it. Actual: earn succeeds (backedDebt() = 1000, earnLine() = 250), and after tx 3 totalDebt == 0, the vault holds 0 collateral, totalSupply == 250e18 and backingPerUnit() == 0. Run: forge test --match-path test/scratch/WorkCeilingRoundTripProof.t.sol","severity":"medium","snippet":"        uint256 debt = Math.min(totalDebt, _debtAtTransactionStart());","title":"backedDebt excludes only same-transaction debt: a same-block borrow / earn / unwind bundle mints work imdUSD against debt that never outlives the block"},{"citation":"resolved","description":"The fix for finding fcd5b261 measures the fee increase against 'the supply that existed before this transaction' (comment at CDPVault.sol:676) by subtracting MINTED_THIS_TX_SLOT from totalSupply. Only draw() adds to that slot (CDPVault.sol:424). earn() (CDPVault.sol:447-465) mints imdUSD to the caller without touching it, so supply minted by earn in the same transaction counts as prior supply. A rights holder who earns and redeems in one call (a contract, or any bundle inside one transaction) pays a smaller increase and, because redemptionBaseRate is stored from the same figure, lowers the base every later redeemer pays. The dilution is bounded by the work ceiling: with earnMat 2500 and an empty register, earn can add up to 25% of debt, which cuts the increase by up to 20%. Unlike the draw path, the earned supply is permanent, so the attacker's own holding absorbs the fee it saved; the harm is to the brake the base rate is meant to be during a run, and to the candidate whose collateral is bought at the lower fee. Reachable with the constants as committed. Smallest fix: add _transientAdd(MINTED_THIS_TX_SLOT, amount) to earn() (the figure is already netted out of the supply-based reads that should see pre-transaction supply), and fix the comment at line 676 if cover's fee re-mint is meant to be excluded as well.","line":686,"path":"src/CDPVault.sol","reproduction":"State: ParameterizedVault, IMD at $1, BORROWER lock 2000, draw 1000 (totalSupply 1000), Treasury holds 1000 IMD, divisor 2. redemptionFeeBps(100e18) == 500 (100/1000/2 = 5% -> cap 4.5% + floor). earnLine() == 250e18. A contract holding 250 of work rights calls, in ONE transaction: vault.earn(250e18); fee = vault.redemptionFeeBps(100e18); vault.cash(100e18, 0, address(0)). Expected: fee 500 bps and redemptionBaseRate 4.5e16, the increase measured against the 1000 that existed before the transaction. Actual: fee == 450 (100/1250/2 = 4%), redemptionBaseRate == 4e16. Scratch test test/scratch/EarnDilutesFee.t.sol (test_earnInTheSameTransactionDilutesTheRedemptionFeeBase) asserts the actual values and passes on this tree.","severity":"low","snippet":"        uint256 prior = supply > minted ? supply - minted : 0;","title":"_redemptionRate nets out same-transaction draw mints but not same-transaction earn mints, so a worker dilutes the redemption fee base"},{"citation":"resolved","description":"setReserveAsset special-cases the vault's gem by fixing its decimals at 18 (because the vault prices collateral per 1e18 raw units) but accepts whatever priceFeed the proposal names. ParameterizedVault creates two feeds: usdPriceFeed (USD per 1e18 raw IMD, 'the price source the Treasury's register is expected to hold for IMD', ParameterizedVault.sol:40-41) and collateralPriceFeed (USD per 1e18 raw sIMD, about 1/125,786 of the former at 7.95 IMD per share and 24 decimals). Nothing on chain ties a listing of gem to collateralPriceFeed, and the documentation points governance at usdPriceFeed for 'IMD', which on mainnet is not the collateral. If the operator proposes sIMD with usdPriceFeed (the 48-hour delay makes it visible, but nothing refuses it), reserveValueUsd values every raw sIMD unit at the price of a raw IMD unit: in the reproduction $8,681 of sIMD reads as $1.092 billion, and earnLine() becomes $1.092 billion, so any rights holder can mint work imdUSD up to their rights with no backing. This is a governance error, not a permission bypass, so it is reported as a trust-assumption gap with a cheap guard. Smallest fix: in validateReserveAsset, when asset == vault.gem(), require address(priceFeed) == vault.collateralPriceFeed() (read through the same _linked probe used for gem()), so the one asset whose unit convention differs cannot be listed against the wrong feed.","line":188,"path":"src/Treasury.sol","reproduction":"State: ParameterizedVault over a 24-decimal share token at 7.95e12 raw IMD per 1e18 raw share, IMD at $10.92 (primary 0.00273 ETH, Chainlink 4000e8). The Treasury holds 100e24 raw sIMD (795 IMD, true value 8,681.4e18 USD by collateralPriceFeed). APPROVED_OPERATOR calls parameters.proposeReserveAsset(share, vault.usdPriceFeed(), 10000); after 48 hours anyone calls applyPending(). Expected: a listing of the collateral token is valued at its collateral price, about 8,681e18 USD, or refused. Actual: vault.reserveValue() == 1.092e27 (USD 1e18-scaled), 125,786 times the true value, and vault.earnLine() == 1.092e27. Scratch test test/scratch/ShareScale.t.sol (test_misListingTheShareInflatesTheReserve) prints and asserts these figures and passes on this tree.","severity":"low","snippet":"        uint8 places = address(asset) == _linked(abi.encodeWithSignature(\"gem()\"))","title":"A reserve listing of the collateral token accepts any price source; listing sIMD against the per-IMD usdPriceFeed values the Treasury's sIMD 125,786x and raises earnLine accordingly"},{"citation":"resolved","description":"bite() seizes Math.mulDiv(debtToRepay, (100 + CHOP_PERCENT) * 1e16, price) with CHOP_PERCENT = 20, i.e. floor(debtToRepay * 1.2e18 / price), collateral worth 120% of the imdUSD burned. The NatSpec at CDPVault.sol:779-780 still says floor(debtToRepay * 1.1e18 / price) and 110%. The same stale figure appears at CDPVault.sol:177 ('it splits the existing 10% bonus'), CDPVault.sol:882 ('including the 10% payout') and ParameterizedVault.sol:218-219 ('seizable at the usual 10% bonus'). The constants and arithmetic are consistent with 20% (verified: a keeper who did not mark receives exactly $1,160 of sIMD for $1,000 repaid at the real sIMD scale); only the comments are wrong, and a reader sizing liquidation risk from them would understate the borrower's loss by half. Fix: update the four comments to 1.2e18 / 120% / 20%.","line":779,"path":"src/CDPVault.sol","reproduction":"Read CDPVault.sol:795: collateralSeized = Math.mulDiv(debtToRepay, (100 + CHOP_PERCENT) * 1e16, price) with CHOP_PERCENT == 20 (line 112). Expected comment: floor(debtToRepay * 1.2e18 / price), 120%. Actual comment at line 779: 1.1e18, 110%. test/scratch/ShareScale.t.sol test_realScaleArithmetic shows a $1,000 bite returning $1,160 to a non-marking keeper, i.e. a 20% bonus split 10/10/80.","severity":"info","snippet":"    /// @dev Payout is floor(debtToRepay * 1.1e18 / price) IMD, i.e. collateral worth 110% of the imdUSD burned","title":"NatSpec still describes a 10% liquidation bonus and a 1.1e18 payout scale; CHOP_PERCENT is 20"},{"citation":"resolved","description":"The header says 'Both tokens use 18 decimals; price is USD per IMD scaled by 1e18'. On mainnet the collateral is StakedIMD (24 decimals, docs/MAINNET-RUNBOOK.md step 2) and ParameterizedVault prices it per 1e18 RAW units through SharePriceFeed, so the price is USD per 1e18 raw sIMD (about 8.68e13 at $10.92/IMD and 7.95 IMD/share), not USD per IMD. The arithmetic is decimal-agnostic and correct (checked at real scale: collateralRatio, bite seizure, redemption payout and the dust threshold all agree), so this is a documentation defect only, but it is the first line a reader meets and it contradicts the unit convention every formula depends on. Fix: state that collateral is priced per 1e18 raw units of whatever gem is, with any decimals, and that debt is 18-decimal imdUSD.","line":28,"path":"src/CDPVault.sol","reproduction":"Deploy ParameterizedVault over a 24-decimal share token (test/scratch/ShareScale.t.sol). collateralPriceFeed().latestValue() returns 86,814,000,000,000 for IMD at $10.92: a USD price per 1e18 raw share units, not 'USD per IMD scaled by 1e18' and not per whole 18-decimal token. Expected: the header describes the per-1e18-raw-unit convention; actual: it asserts 18 decimals for both tokens.","severity":"info","snippet":"/// @dev Both tokens use 18 decimals; price is USD per IMD scaled by 1e18.","title":"Contract NatSpec claims both tokens use 18 decimals; the mainnet collateral is 24-decimal sIMD"},{"citation":"resolved","description":"ImdUSD.burn is documented with 'CDPVault only burns the caller's tokens during repayment, liquidation or redemption'. CDPVault.cover (CDPVault.sol:486-500) calls stablecoin.burn(payer, amount) where payer is the Treasury returned by _surplus(), not msg.sender, and anyone may call cover. The behaviour is intended (docs/PARAMETERS-2026-10-05.md, 'Bad debt first') and bounded (only realized bad debt, only up to what is owed, only against a drained position), but the token's own documentation of what its single burner may do is no longer true. Fix: amend the comment to name cover and the Treasury.","line":61,"path":"src/ImdUSD.sol","reproduction":"State from test/Cover.t.sol test_coverRetiresRealizedBadDebtWithTheTreasurysImdUSD: a drained position with realized bad debt and a Treasury holding imdUSD. STRANGER calls vault.cover(BORROWER, bad). Expected per the ImdUSD comment: only the caller's tokens are ever burned. Actual: the Treasury's balance falls by bad and STRANGER's is unchanged (stable.balanceOf(STRANGER) == 0).","severity":"info","snippet":"    /// @dev CDPVault only burns the caller's tokens during repayment, liquidation or redemption.","title":"ImdUSD NatSpec says the vault only burns the caller's tokens; cover() burns the Treasury's"},{"citation":"resolved","description":"Parameters.sol:217-220 documents 'Queue a listing, repricing or (with a zero price source) delisting of one of the Treasury's reserve assets' directly above proposeRedemptionDivisor (line 221), while proposeReserveAsset (line 230) has no NatSpec; generated docs and ABI explorers will attribute the register's rules to the divisor setter. Similarly Treasury.sol:437-438 ('Release reserve IMD for a redemption priced and burned by this Treasury's vault. Neither the caller nor governance can select another reserve asset through this path.') sits above the oracle-budget section comment and oracleDay, detached from redeemIMD at line 511; CDPVault.sol:166-167's @notice for redemptionDivisor is placed under duty()'s @dev block and duty() itself (line 172) is undocumented; and CDPVault.sol:192-194 says the ParameterizedVault ceiling is 'reserveValueUsd + totalDebt * earnMat / 10000' where the implementation uses backedDebt() (totalDebt capped at the transaction start, less totalBadDebt). Fix: move each comment to the function it describes and name backedDebt in the earnLine description.","line":217,"path":"src/Parameters.sol","reproduction":"Read Parameters.sol:217-223: the @notice text about listings is followed by function proposeRedemptionDivisor. Expected: it precedes proposeReserveAsset (line 230). Actual: proposeReserveAsset has no documentation and the divisor setter is documented as the listing path.","severity":"info","snippet":"    /// @notice Queue a listing, repricing or (with a zero price source) delisting of one of the","title":"Misattached NatSpec: proposeRedemptionDivisor carries the reserve-listing documentation, redeemIMD and duty() have none"}],"hash":"5ce98d39676af46e30c4bf73f2e32ba2960e27fa97cb81e5b6a763967ed3d8a5","nodeId":"2c278474-7bc5-4213-8447-a8959a87d5f7","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 8 findings (2 medium with Foundry proofs, 2 low, 4 info); the full existing suite is green (490 passed, 4 fork skips) and both proofs fail on this tree at the intended assertion.\n\n## Findings\n\n**Medium, `src/CDPVault.sol:609` `_securedCollateralValue` / `_redemptionRate`.** The backing cap and the fee's supply base exclude only what the current transaction added (transient storage). Three transactions in one block defeat it: `lock(2000); draw(1000)` | `cash(100, 0, 0)` | `wipe(1000); free(2000)`. With 100 IMD of reserve behind 250 work-minted imdUSD (backing 0.4), the redeemer is paid 95.5 IMD where pro-rata is 38, and the fee is 450 bps instead of 500. Remaining holders go from 0.4 backing to about 0.03. The comment at lines 226-228 says the cross-transaction path \"costs real capital in an open position, not gas\"; in one block the exposure is zero seconds. Smallest fix: make the tallies block-scoped (storage pair keyed by `block.number`), or count a position only once its principal is older than the existing 12-hour fresh window. Proof: `test/scratch/BackingRoundTripProof.t.sol`.\n\n**Medium, `src/ParameterizedVault.sol:221` `backedDebt`.** Same root cause for the work ceiling: `lock; draw(1000)` | `earn(250)` | `wipe; free` in one block leaves 250 imdUSD with no debt, no collateral and backing 0. The comment at lines 211-212 makes the same \"real capital at risk\" claim. Proof: `test/scratch/WorkCeilingRoundTripProof.t.sol`.\n\n**Low, `src/CDPVault.sol:686`.** `earn` mints are not added to the minted-this-transaction slot, so an earn-then-cash call measures the fee against the diluted supply (450 bps vs 500, stored base 4% vs 4.5%). Fix: tally the earn in the slot.\n\n**Low, `src/Treasury.sol:188`.** Listing the collateral token accepts any feed. Listing sIMD against the per-IMD `usdPriceFeed` values $8,681 of sIMD at $1.09 billion and lifts `earnLine` to match. Governance-only, behind the timelock, but nothing refuses it. Fix: require `collateralPriceFeed` when the asset is `gem`.\n\n**Info.** Stale NatSpec: 10% / 1.1e18 / 110% bonus wording (`CDPVault.sol:177, 779, 882`, `ParameterizedVault.sol:219`); \"both tokens use 18 decimals\" (`CDPVault.sol:28`); ImdUSD says only the caller's tokens are burned but `cover` burns the Treasury's (`ImdUSD.sol:61`); misattached docs on `proposeRedemptionDivisor`, `redeemIMD`, `duty()` and the `earnLine` description.\n\n## Answers to the numbered questions\n\n1. **Positions.** No path leaves a position below mat or moves another caller's funds outside the designed routes. `lockIMD` credits by balance delta, so a misreporting share vault cannot inflate credit; reentrancy is blocked by `nonReentrant` on every mutator. Nothing wrong.\n2. **Liquidation.** Payout, chip and cut match the formula at real sIMD scale (a non-marking keeper nets exactly $1,160 per $1,000). The dust sweep threshold is worth 1 wei of USD, so it cannot be gamed. A position cannot be frozen: marks clear only when healthy at fresh prices, and any remainder is seizable. Nothing wrong.\n3. **Redemption.** The ratio guard is exact and redemption cannot create bad debt. The reserve can be drained beyond pro-rata only via the same-block round trip (medium above); the same-transaction `earn` dilution is the low. Chunking a redemption to pay roughly half the single-shot fee is real but already recorded as approved design.\n4. **Bad debt.** `totalBadDebt` equals the sum of per-position records on every writer. `cover` cannot exceed what is owed, cannot touch a position with collateral, and cannot be spent twice. Covering the fee portion costs the Treasury nothing (burn then re-mint), which the existing test documents as intended.\n5. **Stability fee.** `Parameters._apply` drips before changing the rate, the index is monotonic and `chiOf` is always set from `chi()`, so it can never exceed it. Nothing wrong.\n6. **Price gating.** Every value-moving action checks freshness and divergence, including `ea","treeHash":null,"usage":{"cachedInputTokens":4594839,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":108022,"runtime":"claude","turns":48,"wallClockMs":1749464}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"93678b4d542094a9","findings":[],"hash":"95adc7f93d1bd19653cefc52eeca37e2c2f41107f38fe91858fff5bc0e05a6a7","nodeId":"b5c93217-63d7-48ec-b777-18407881ec38","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":4,"wallClockMs":616286}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"560d41abbfbbcbfe","findings":[{"citation":"resolved","description":"CDPVault.bite (line 796) refuses any bite whose formula seizure exceeds the position's collateral, and the seizure for ONE wei of debt is floor(1.2e18 / price) raw collateral units. With the mainnet collateral (sIMD, 24 decimals, priced per 1e18 raw units, about 8.68e13 at IMD $10.92 and 7.95 IMD per sIMD) that is 13,824 raw units, i.e. about 1e-20 sIMD. CDPVault.cover (line 488) refuses any position whose collateral is non-zero. CDPVault.lock is ungated and only the owner can lock into their own position. So after a liquidation drains a position and realizes its residual (totalBadDebt > 0), the owner locks 1 wei of sIMD back in and the position becomes unreachable by every path: bite(owner, 1) reverts InsufficientCollateral (13,824 > 1) at every price the collateral will ever have (the one-wei seizure exceeds one raw unit whenever price < 6e17 per 1e18 raw units), cover(owner, x) reverts NoRealizedBadDebt, and cash cannot name it (gemOut - reserveOut must be <= collateral * debtCancelled / debt = 0). The record `_recordedBadDebt[owner]` is deliberately left untouched by lock ('adding collateral cannot hide it', line 271), so totalBadDebt keeps the residual permanently. Consequences: (1) Treasury.withdraw of imdUSD reverts BadDebtFirst below that residual and Treasury.payStream treats it as not spare, so protocol imdUSD equal to the residual is locked for ever by a 1-wei deposit; (2) the 'bad debt first' policy (docs/PARAMETERS-2026-10-05.md) cannot be executed for that position; (3) the position's principal stays in totalDebt accruing fees that can never be collected. The same frozen state is reachable WITHOUT the owner: the sweep at line 816 uses a strict `<`, so the largest coverable bite can leave a remainder EQUAL to the one-wei seizure (the proof hits exactly this: 27,645 raw units left against a 27,645 one-wei seizure); that remainder is biteable for exactly one wei of debt only while the price has not fallen, after which the residual debt is neither recorded as bad debt (collateral != 0) nor liquidatable, so totalBadDebt undercounts and backedDebt/earnLine overcount it until the price recovers. Reachable with the constants as committed (CHOP_PERCENT 20, sIMD collateral, any NHI). Smallest fix (verified: the existing Liquidation, BadDebtSweep, Cover, MarkerBadDebt, Protocol.invariant and BadDebtSequences suites, 69 tests, still pass, and the proof passes): in bite, when collateralSeized > position.collateral and 0 < position.collateral < Math.mulDiv(1, (100 + CHOP_PERCENT) * 1e16, price), set collateralSeized = position.collateral instead of reverting, and compute the bonus saturating (bonus = collateralSeized > principalPart ? collateralSeized - principalPart : 0). A one-wei bite then always drains sub-threshold dust, _recordBadDebt fires, and cover works in the same transaction, so an owner re-locking dust cannot interpose. Alternatively let cover accept a position whose collateral is below the one-wei seizure, but that makes cover price-gated.","line":796,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault with sIMD-shaped collateral (24 decimals, 7.95 IMD per share), IMD/ETH 5.46e15, ETH/USD 2000e8, NHI 0.85 (mat 170, grace 6h). BORROWER locks 1962264150943396226415094 raw shares (15.6 IMD, $170.35) and draws 100e18; KEEPER locks 200 IMD worth and draws 250e18. Price halves (collateral price 43407000000000); bark(BORROWER); warp 6h; KEEPER bites repayable = floor(C * price / 1.2e18) = 70.98e18. Actual: remainder 27645 == floor(1.2e18 / 43407000000000) = 27645, NOT swept (strict `<` at line 816); a further bite(BORROWER, 1) takes it; collateral 0; totalBadDebt = residual (about 29.05e18 = 100e18 - 70.98e18 + 6h of fees). Treasury is funded with `residual` imdUSD. BORROWER calls lock(1). Expected: the protocol can still retire the realized loss (bite(BORROWER,1) drains the wei, or cover applies). Actual: bite(BORROWER, 1) reverts InsufficientCollateral (seizure 27645 > 1); cover(BORROWER, residual) reverts NoRealizedBadDebt; totalBadDebt stays at residual for ever; Treasury.withdraw(imdUSD) below residual reverts BadDebtFirst. test/scratch/DustRelockFreeze.t.sol fails on this code with NoRealizedBadDebt and passes with the bite fix above.","severity":"medium","snippet":"        if (collateralSeized > position.collateral) revert InsufficientCollateral();","title":"Drained position re-locked with one wei of sIMD can be neither bitten nor covered: residual bad debt and the Treasury imdUSD backing it are frozen for ever"},{"citation":"resolved","description":"CDPVault._redemptionRate charges a burn at the rate AFTER its own increase (decayed + amount/prior/divisor), and cash writes that post-increase rate into redemptionBaseRate. Because each chunk is charged at its own post-increase rate while the increase is linear in the amount, the total fee for N chunks of a/N is roughly the integral plus half the increase of one chunk, i.e. the quadratic term shrinks toward half the single-shot term as N grows, while the cap (4.5% over the 0.5% floor) binds the single shot sooner than the chunks. docs/PARAMETERS-2026-10-05.md tabulates the fee as a function of 'redeemed at once', and _redemptionRate's REVISION comment (fcd5b261) describes the intent that a redeemer pay 'the fee-adjusted price for that size'; with the committed divisor 2 that is not what a same-block sequence of calls pays. The redeemer also leaves redemptionBaseRate slightly HIGHER after the split (2.55% vs 2.50%), so everyone after pays more while the splitter paid less. No same-transaction mint is needed (the fcd5b261 netting does not apply); the chunks can be separate transactions in the same block or a single multicall. Fix if the design wants size-invariance: charge each burn at the average of its pre- and post-increase rates (decayed + increase / 2, capped), which makes the fee the exact integral of the linear rate and chunking neutral; this is an economic change to the shipped formula, so it is the requester's decision. Reachable with the constants as committed.","line":687,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault, collateral $1, supply 100,000e18 (one borrower), Treasury holds 50,000 IMD reserve, redemptionBaseRate 0, divisor 2. Single call cash(5_000e18, 0, address(0)): quoted fee 300 bps, gemOut 4,850e18, fee paid 150e18. Same state, five calls cash(1_000e18, 0, address(0)) in one block: total gemOut 4,898.7e18, fee paid 101.3e18 (32% less for the same 5,000 redeemed), redemptionBaseRate afterwards 25515518375422644 (2.55%) versus 2.50% after the single call. test/scratch/FeeSplit.t.sol prints these figures.","severity":"low","snippet":"        uint256 increase = amount == 0 ? 0 : prior == 0 ? cap : Math.mulDiv(amount, 1e18, prior) / redemptionDivisor();\n        return Math.min(decayedRedemptionBaseRate() + increase, cap);","title":"Redemption fee for a given size is not split-invariant: chunking a redemption in one block pays about a third less and leaves the base rate for others higher"},{"citation":"resolved","description":"Comments that state something the code does not do: (1) line 779, bite: 'Payout is floor(debtToRepay * 1.1e18 / price) IMD, i.e. collateral worth 110%' - the code seizes (100 + CHOP_PERCENT) = 120% (line 795). (2) line 177, cut(): 'it splits the existing 10% bonus' - the bonus is 20%. (3) line 882, badDebtOf: 'including the 10% payout' - 20%. (4) src/ParameterizedVault.sol:218, backedDebt: 'seizable at the usual 10% bonus' - 20%. (5) line 28: '@dev Both tokens use 18 decimals' - the mainnet collateral is sIMD with 24 decimals; the arithmetic is per 1e18 raw units so it is correct, but the claim is false and is exactly the assumption SharePriceFeed warns about. (6) line 124 and line 442: 'Supply = totalDebt + totalEarned - totalNonPrincipalRedeemed' - stability fees minted to the Treasury by _payDebt and cover (totalFeesMinted) are also supply; the suite's own identity (test/Protocol.invariant.t.sol:447) includes totalFeesMinted. (7) lines 159-170: the NatSpec block describing the annual stability fee, its setter-less nature and the drip requirement is attached to redemptionDivisor(); duty() at line 172 carries no NatSpec and the redemption-divisor sentence is appended to the fee text. (8) line 806-808, the sweep comment claims the remainder it folds in is 'smaller than the seizure for a single wei of debt'; the condition is strict so a remainder equal to it is left in place (see the medium finding). None of these change behaviour; they mislead a reader or integrator about the liquidation penalty, the supply identity and the collateral decimals.","line":779,"path":"src/CDPVault.sol","reproduction":"Read the cited lines against bite (line 795: (100 + CHOP_PERCENT) * 1e16 = 1.2e18), CHOP_PERCENT = 20 (line 112), and _payDebt / cover minting feePaid to feeRecipient (lines 495-496 and 1029-1030). Expected: NatSpec matches; actual: it describes the former 10% bonus, 18-decimal collateral and a supply identity without totalFeesMinted.","severity":"info","snippet":"    /// @dev Payout is floor(debtToRepay * 1.1e18 / price) IMD, i.e. collateral worth 110% of the imdUSD burned","title":"CDPVault NatSpec claims properties the code does not have (110%/10% bonus, 18-decimal tokens, supply identity, misplaced redemptionDivisor/duty docs)"},{"citation":"resolved","description":"The @notice at lines 217-220 ('Queue a listing, repricing or (with a zero price source) delisting of one of the Treasury's reserve assets ...') documents proposeReserveAsset (line 230, which has no NatSpec) but is attached to proposeRedemptionDivisor (line 221), which queues a divisor change validated to MIN_/MAX_REDEMPTION_DIVISOR. Generated ABI docs (docs/ABI.md) and any reader of the governed divisor will be told the wrong thing. Fix: move the block above proposeReserveAsset and give proposeRedemptionDivisor a one-line notice ('Queue a change to the redemption fee divisor, refused outside 1..8').","line":217,"path":"src/Parameters.sol","reproduction":"Read src/Parameters.sol lines 217-223 against _validate (lines 349-352) and _apply (lines 422-425): the function stores a uint256 divisor and never touches the register. Expected: NatSpec describes the divisor; actual: it describes a reserve-asset listing.","severity":"info","snippet":"    /// @notice Queue a listing, repricing or (with a zero price source) delisting of one of the\n    /// Treasury's reserve assets. The Treasury's own rules apply at proposal — imdUSD is refused with\n    /// `StablecoinIsNotReserve`, a haircut must be at most 10000 — so a change the register would refuse\n    /// never occupies the slot. Applying it, like every other change, is anyone's to do after the delay.\n    function proposeRedemptionDivisor(uint256 divisor) external {","title":"Parameters.proposeRedemptionDivisor carries proposeReserveAsset's NatSpec"},{"citation":"resolved","description":"ImdUSD.burn is documented as 'CDPVault only burns the caller's tokens during repayment, liquidation or redemption'. CDPVault.cover (src/CDPVault.sol:493) burns `amount` from `_surplus()`, the Treasury, on behalf of any caller. The allowance-free burn is therefore also exercised against a third party's balance. The Treasury is the vault's own contract and the design intends it, so this is a documentation gap, not an access gap; a holder reading ImdUSD alone cannot tell that the vault can burn an account other than msg.sender. Fix: state that the vault burns the caller's tokens in wipe/bite/cash and its Treasury's in cover.","line":61,"path":"src/ImdUSD.sol","reproduction":"src/CDPVault.sol:489-493: `address payer = _surplus(); ... stablecoin.burn(payer, amount);` with msg.sender unrestricted. Expected per NatSpec: only msg.sender's tokens are ever burned; actual: the Treasury's are.","severity":"info","snippet":"    /// @dev CDPVault only burns the caller's tokens during repayment, liquidation or redemption.","title":"ImdUSD.burn NatSpec says the vault only burns the caller's tokens, but cover burns the Treasury's"}],"hash":"aeca1a4d139d88110f92bc5dcdcf3c7deb70904e21268f1a298af5c7dba300f6","nodeId":"ccbc4ce8-3ddc-40e7-a65a-74e8ce209dda","outcome":"completed","summary":"The review is complete. The findings are in `.imd-findings.json` (5 entries: 1 medium with a proof test, 1 low, 3 info), the source tree is unchanged, and the proof fails on the pinned commit and passes with the proposed fix.\n\n## Verdict\n\nOne medium defect in the liquidation and bad-debt path, one low economic property in redemption, and several stale NatSpec claims. Everything else the eight questions ask about held up under concrete tracing.\n\n**Medium: a drained position re-locked with 1 wei of sIMD is frozen for ever** (`src/CDPVault.sol:796`). After a liquidation drains a position and realizes its residual, the owner locks one wei back. Now `bite` reverts because the seizure for one wei of debt is 13,824 raw sIMD units at launch price, `cover` reverts because collateral is non-zero, and `cash` cannot name it. The residual stays in `totalBadDebt` permanently, so the Treasury's imdUSD equal to it is locked against `withdraw` and `payStream`, and the \"bad debt first\" policy cannot run. The same state is reachable without the owner: the sweep uses a strict `<`, so the largest coverable bite leaves a remainder exactly equal to the one-wei seizure (the proof hits this: 27,645 against 27,645), which freezes on any price fall. Fix: when the formula seizure exceeds a non-zero collateral below the one-wei seizure, take the collateral whole with a saturating bonus. I verified that this fix passes the 69 tests in the existing liquidation, cover and invariant suites plus the proof.\n\n**Low: redemption fee is not split-invariant** (`src/CDPVault.sol:687`). Five same-block chunks of 1,000 pay 101.3 imdUSD of fee where one 5,000 burn pays 150, and leave the base rate for everyone else slightly higher. This is inherent in charging at the post-increase rate; charging at the midpoint rate would make it neutral. It is a design decision for the requester.\n\n**Info: NatSpec mismatches.** `bite`, `cut`, `badDebtOf` and `backedDebt` still describe a 10% bonus (code is 20%); the header claims both tokens are 18-decimal; the supply identity omits `totalFeesMinted`; `duty`'s NatSpec sits on `redemptionDivisor`; `proposeRedemptionDivisor` carries `proposeReserveAsset`'s notice; `ImdUSD.burn` says only the caller's tokens are burned while `cover` burns the Treasury's.\n\n## Answers to the numbered questions where nothing is wrong\n\n- **Positions**: lock credits by balance delta, lockIMD by share delta under `nonReentrant`; a misreporting `deposit` cannot inflate credit. No path moves funds for anyone but the caller except `cover`, by design.\n- **Liquidation**: marker, protocol and liquidator shares match the formula; the sweep is bounded by one seizure unit and not gameable; the freeze above is the only way to make a position unliquidatable.\n- **Redemption**: eligibility, the non-worsening check and the pro-rata cap are correct; payout at `backing × (1 − fee)` cannot drain the reserve below what backs the remaining supply.\n- **Bad debt**: `cover` cannot exceed what is owed, cannot be spent twice, and keeps `totalBadDebt` in sync. Covering accrued fees costs the Treasury nothing net, which is correct since fees were never minted.\n- **Stability fee**: `drip` is called before the rate changes; `chi` is monotone, so `chiOf` never exceeds it.\n- **Price gating**: every value-moving action checks freshness and divergence, including the share and ETH/USD legs; the exceptions (lock, wipe, debt-free free, cover) only reduce risk.\n- **Work issuance**: same-transaction debt is excluded through the transient slot, and a reserve listing cannot mint anything that redemption does not later burn one for one.\n- **Arithmetic**: all 512-bit `mulDiv`, saturating ratio, guarded `_secured` product; every paying division rounds against the payee.\n\n## Coverage\n\nRead in full: `CDPVault`, `ParameterizedVault`, `ImdUSD`, `Parameters`, `Governed`, `Treasury`, `TreasuryFactory`, `UsdPriceFeed`, `SharePriceFeed`, `SwarmFeed`, `PriceFeed`, `SwarmWorkOracle`, `WorkOracleFactory`, `MockIMD","treeHash":null,"usage":{"cachedInputTokens":4349257,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":90209,"runtime":"claude","turns":59,"wallClockMs":1290946}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2dc755dfe7bd177c","findings":[{"citation":"resolved","description":"Merged from audit_permissions 33d5623f and audit_flow 3ee7c31d (same mechanism). CDPVault.cover refuses any position whose collateral is non-zero (line 488). CDPVault.lock is ungated (no price, minimum or health check) and credits the caller's own position. CDPVault.bite reverts InsufficientCollateral whenever the formula seizure exceeds the position's collateral (line 796), and the seizure for ONE wei of debt is floor(1.2e18 / price) raw collateral units; the dust sweep at lines 813-819 only runs inside a bite that already passed that check. With the mainnet collateral (sIMD, 24 decimals, priced per 1e18 raw units, about 8.68e13) one wei of debt seizes about 13,800 raw units (27,645 after a 50% fall), so a position holding fewer raw units than that can never be bitten; this holds at every price below 0.6e18 per 1e18 raw units, i.e. always for sIMD. Redemption cannot name it either (gemOut - reserveOut must be <= collateral * debtCancelled / debt, which rounds to 0). So after a liquidation drains a position and records its residual in totalBadDebt, the owner calls lock(1) and the position is unreachable by every path except the owner's own wipe. lock deliberately leaves _recordedBadDebt in place, so totalBadDebt keeps the residual permanently. Consequences: (1) Treasury.withdraw(imdUSD) reverts BadDebtFirst and Treasury.payStream treats the amount as not spare, so protocol imdUSD equal to the frozen residual can never be withdrawn, streamed or spent on cover; (2) ParameterizedVault.backedDebt subtracts totalBadDebt for ever, lowering the work ceiling by earnMat x residual; (3) _securedCollateralValue subtracts it from the principal the mat cap applies to, lowering backingPerUnit for redeemers; (4) the 'bad debt first' policy in docs/PARAMETERS-2026-10-05.md cannot be executed for that position. Cost to the drained borrower: one raw unit of sIMD (about 1e-22 USD) and gas. Two related states come from the same two guards: (a) a drained borrower who later re-collateralises to health keeps the record until they choose to repay, with cover refused throughout; (b) with no action by the owner at all: the largest coverable bite leaves a remainder at or just above the one-wei seizure (the sweep at line 816 only takes a remainder strictly below it), and if the price falls further before a second bite that remainder becomes unseizable. Reproduced: 27,646 raw units left against a 27,645 seizure; after a further 10% fall the seizure is 30,717, bite(owner, 1) reverts InsufficientCollateral, cover reverts NoRealizedBadDebt, and 1,850 imdUSD of uncollateralised debt is neither recorded in totalBadDebt (it reads 0) nor liquidatable, so backedDebt and the secured-collateral cap keep counting it until the price recovers. Reachable with the constants as committed (CHOP_PERCENT 20, sIMD collateral, any NHI). Smallest fix: in bite, when collateralSeized > position.collateral and 0 < position.collateral < Math.mulDiv(1, (100 + CHOP_PERCENT) * 1e16, price), seize the whole remaining collateral instead of reverting and compute the bonus saturating (bonus = collateralSeized > principalPart ? collateralSeized - principalPart : 0); a one-wei bite then always drains sub-threshold dust, _recordBadDebt fires and cover works, so re-locking dust cannot interpose (I applied exactly this to a copy of the tree: the proof passes and the existing suites still pass, see reproduction). To also close variant (a) and dust that no keeper will pay gas for, let cover accept a position that is underwater at the current price (collateral worth less than its debt), sweeping that collateral to the Treasury first; that makes cover price-gated for such positions and is the requester's choice.","line":488,"path":"src/CDPVault.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 {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {Treasury} from \"src/Treasury.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract DustFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n\n    constructor(uint256 v) {\n        value = v;\n    }\n\n    function set(uint256 v) external {\n        value = v;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, uint64(block.timestamp));\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract DustMirror is ISwarmFeed {\n    ISwarmFeed private immutable primary;\n\n    constructor(ISwarmFeed p) {\n        primary = p;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return primary.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return primary.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return primary.maxAge();\n    }\n}\n\ncontract DustAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @dev sIMD's shape: an ERC-4626 share with 24 decimals, 7.95 IMD per share.\ncontract DustShare is ERC20 {\n    IERC20 public immutable underlying;\n    uint256 public constant RATE = 7.95e12; // raw IMD per 1e18 raw share\n\n    constructor(IERC20 asset_) ERC20(\"Staked IMD\", \"sIMD\") {\n        underlying = asset_;\n    }\n\n    function decimals() public pure override returns (uint8) {\n        return 24;\n    }\n\n    function asset() external view returns (address) {\n        return address(underlying);\n    }\n\n    function convertToAssets(uint256 shares) external pure returns (uint256) {\n        return shares * RATE / 1e18;\n    }\n\n    function deposit(uint256 assets, address receiver) external returns (uint256 shares) {\n        underlying.transferFrom(msg.sender, address(this), assets);\n        shares = assets * 1e18 / RATE;\n        _mint(receiver, shares);\n    }\n}\n\n/// @notice After a liquidation drains a position and realizes its residual, the owner locks ONE raw\n/// unit of sIMD back in. `cover` refuses the position (collateral != 0), `bite` cannot seize the unit\n/// (one wei of debt seizes floor(1.2e18 / price) raw units, far more than one), and redemption cannot\n/// name it. totalBadDebt keeps the residual for ever and the Treasury's imdUSD stays behind\n/// `BadDebtFirst`.\ncontract DustRelockFreezeProof is Test {\n    address private constant BORROWER = address(0xBA);\n    address private constant KEEPER = address(0xBEEF);\n\n    MockIMD private imd;\n    DustShare private share;\n    DustFeed private primary;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    Treasury private treasury;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new DustAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        share = new DustShare(imd);\n        // IMD at 0.00546 ETH with ETH at $2000: $10.92 per IMD, 8.6814e13 per 1e18 raw sIMD.\n        primary = new DustFeed(0.00546 ether);\n        DustFeed health = new DustFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(share), address(0), address(0), address(primary), address(health), address(new DustMirror(primary))\n        );\n        stable = vault.stablecoin();\n        treasury = vault.treasury();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 1_001 ether);\n        imd.mint(KEEPER, 5_000 ether);\n        vm.stopPrank();\n        vm.startPrank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        imd.approve(address(share), type(uint256).max);\n        share.approve(address(vault), type(uint256).max);\n        vm.stopPrank();\n        vm.prank(KEEPER);\n        imd.approve(address(vault), type(uint256).max);\n    }\n\n    function test_oneRawUnitRelockedOnADrainedPositionCannotFreezeItsBadDebt() public {\n        (uint256 price,) = vault.collateralPriceFeed().latestValue();\n        assertEq(price, 86_814_000_000_000, \"USD per 1e18 raw sIMD at the brief's figures\");\n\n        vm.startPrank(BORROWER);\n        vault.lockIMD(1_000 ether); // $10,920\n        vault.draw(6_400 ether); // 170.6%\n        vm.stopPrank();\n        vm.startPrank(KEEPER);\n        vault.lockIMD(5_000 ether);\n        vault.draw(20_000 ether);\n        vm.stopPrank();\n\n        // The price halves; the position is marked, grace runs, and it is liquidated to nothing.\n        primary.set(0.00273 ether);\n        (price,) = vault.collateralPriceFeed().latestValue();\n        vm.prank(KEEPER);\n        vault.bark(BORROWER);\n        vm.warp(block.timestamp + 6 hours);\n        (uint256 held,) = vault.positions(BORROWER);\n        vm.startPrank(KEEPER);\n        vault.bite(BORROWER, held * price / 1.2e18);\n        (held,) = vault.positions(BORROWER);\n        if (held != 0) vault.bite(BORROWER, 1);\n        vm.stopPrank();\n        (held,) = vault.positions(BORROWER);\n        assertEq(held, 0, \"drained\");\n        uint256 bad = vault.totalBadDebt();\n        assertGt(bad, 1_800 ether, \"the residual is realized\");\n        assertEq(vault.debtOf(BORROWER), bad);\n\n        // The Treasury holds imdUSD (standing in for collected stability fees) to cover it with.\n        vm.prank(KEEPER);\n        stable.transfer(address(treasury), bad + 1 ether);\n\n        // The drained borrower locks ONE raw unit of sIMD (1e-24 sIMD).\n        vm.startPrank(BORROWER);\n        share.deposit(1 ether, BORROWER);\n        vault.lock(1);\n        vm.stopPrank();\n        assertEq(uint256(1.2e18) / price, 27_645, \"one wei of debt seizes 27,645 raw units; the position holds 1\");\n\n        // Whatever path the protocol offers, the realized loss must still be retirable: a liquidator\n        // may take the dust, and the Treasury's imdUSD may then cover what is owed.\n        vm.startPrank(KEEPER);\n        try vault.barkFor(BORROWER, KEEPER) {} catch {}\n        try vault.bite(BORROWER, 1) {} catch {}\n        vm.stopPrank();\n        uint256 owed = vault.debtOf(BORROWER);\n        vault.cover(BORROWER, owed); // reverts NoRealizedBadDebt on the code as it is\n        assertEq(vault.totalBadDebt(), 0, \"the realized loss is retired\");\n        assertEq(vault.debtOf(BORROWER), 0);\n    }\n}","reproduction":"ParameterizedVault over an sIMD-shaped share (24 decimals, 7.95e12 raw IMD per 1e18 raw share), IMD/ETH 0.00546e18, ETH/USD 2000e8 (collateral price 86,814,000,000,000), NHI 0.85 (mat 170, grace 6h). BORROWER lockIMD(1000e18) [$10,920], draw(6400e18). KEEPER lockIMD(5000e18), draw(20000e18). Primary halves to 0.00273e18 (price 43,407,000,000,000). KEEPER bark(BORROWER); warp 6h; KEEPER bite(BORROWER, collateral * price / 1.2e18), then bite(BORROWER, 1) for the 27,646-unit remainder the first bite left (27,645 seized, 1 swept): collateral == 0, totalBadDebt == debtOf(BORROWER) == 1850.19e18. KEEPER transfers 1851.19 imdUSD to the Treasury. BORROWER deposits 1 IMD into the share vault and calls vault.lock(1). Expected: the realized loss can still be retired (a liquidator takes the dust, or cover applies). Actual (run on this tree): bite(BORROWER, 1) reverts InsufficientCollateral() (seizure 27,645 > 1); cover(BORROWER, 1850194630136986297600) reverts NoRealizedBadDebt(); totalBadDebt stays at the residual and only BORROWER's own wipe can change it. Run: forge test --match-path test/scratch/DustRelockFreeze.t.sol -> FAIL: NoRealizedBadDebt(). With the bite fix applied to a copy of the tree the same test passes and the rest of the suite is unchanged (491 passed; the only failures were the two round-trip proofs below, which that fix does not address). Variant (b) was run separately (test/scratch/DustVariant.t.sol): same setup, stop after the first bite, set the primary to 0.002457e18; bite(BORROWER, 1) reverts InsufficientCollateral and cover(BORROWER, 1) reverts NoRealizedBadDebt while totalBadDebt == 0 and debtOf(BORROWER) == 1850194630136986297601.","severity":"medium","snippet":"        if (_positions[owner].collateral != 0 || _recordedBadDebt[owner] == 0) revert NoRealizedBadDebt();","title":"One raw unit of sIMD re-locked on a drained position makes its realized bad debt uncoverable, unliquidatable and unredeemable, freezing Treasury imdUSD behind BadDebtFirst"},{"citation":"resolved","description":"Kept from audit_economics e657e37d, with the proof rewritten and the proposed fix corrected. _securedCollateralValue (lines 606-615) and _redemptionRate (lines 681-689) exclude only what the CURRENT TRANSACTION added, through the transient slots SECURED_THIS_TX_SLOT and MINTED_THIS_TX_SLOT, which the EVM clears when the transaction ends. A position opened in one transaction therefore counts in full as secured collateral (up to mat x its principal) and as prior supply in the next transaction, and is closed in a third. The comment at lines 223-228 calls this slow round trip 'the accepted design' on the ground that it 'costs real capital in an open position, not gas'. It does not: the three transactions fit in one block (zero seconds of fee, no liquidation exposure at 200%), and one block apart the cost I measured is 24 seconds of stability fee, 0.0000338 imdUSD on 1000 of principal. Who loses: every remaining imdUSD holder. When backing per unit is below 1 (work-minted imdUSD outliving the debt it was minted against, the stressed state the cash() comment block is written for), the caller turns the pro-rata payout into a par payout and empties the reserve; the fee is also diluted by the caller's own minted supply (450 bps instead of the 500 cap). This is the same harm as findings 8936befa (high) and 7cd5035c (medium) that the secured-collateral accounting was built to close; those used a debt-free deposit, this uses a healthy indebted one. Reachable with the constants as committed (mat 170, earnMat 2500, divisor 2, LINE $1M). Call sequence from an external account, three transactions: lock(2000e18); draw(1000e18) | cash(100e18, 0, address(0)) | wipe(1000e18); free(2000e18). Fix: the exclusion has to be by AGE, not by transaction. The specialist's block-scoped tally (storage keyed by block.number instead of transient storage) makes the attached proof pass (I applied it to a copy of the tree and ran it) but leaves the identical sequence open one block later at the cost quoted above, and it breaks 73 existing tests that open a position and act on it in one block. The fix that closes it counts a position's secured term and its principal toward 'prior' only once that principal is older than FRESH_DEBT_WINDOW, using the per-position recentlyMinted / mintedAt record draw already maintains (subtract a global tally of fresh principal and fresh secured collateral, aged out lazily when a position is next touched, erring toward excluding). That changes when honest new debt starts to count as backing, so it is the requester's decision; whichever is chosen, the comment at lines 223-228 claims a cost the code does not impose and should be corrected.","line":608,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Math} from \"@openzeppelin/contracts/utils/math/Math.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {CDPVault} from \"src/CDPVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {MockWorkOracle} from \"src/MockWorkOracle.sol\";\nimport {Treasury} from \"src/Treasury.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract ProofFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract ProofMirror is ISwarmFeed {\n    ISwarmFeed private immutable primary;\n\n    constructor(ISwarmFeed p) {\n        primary = p;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return primary.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return primary.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return primary.maxAge();\n    }\n}\n\ncontract ProofAggregator {\n    /// @dev A pure constant: `vm.etch` copies code only, so a storage initializer would read as zero.\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @notice The backing cap on redemption, and the work ceiling's debt term, are both defeated by a\n/// three-transaction round trip inside ONE block: open a 200% position, redeem (or earn), close.\n/// The transient-storage exclusions only see a single transaction; the source comments say the\n/// cross-transaction version \"costs real capital in an open position, not gas\", but here the\n/// capital is exposed for zero seconds and the attacker takes the whole reserve at par while the\n/// burn found backing at 0.4.\ncontract BackingRoundTripProof is Test {\n    address private constant BORROWER = address(0xBA);\n    address private constant WORKER = address(0xCA);\n    address private constant ATTACKER = address(0xA7);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    Treasury private treasury;\n    MockWorkOracle private oracle;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new ProofAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        // 1 IMD = 1/2000 ETH and 1 ETH = $2000, so the vault prices IMD at exactly $1.\n        ProofFeed primary = new ProofFeed(uint256(1 ether) * 1e18 / 2000 ether);\n        ProofFeed health = new ProofFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new ProofMirror(primary))\n        );\n        stable = vault.stablecoin();\n        treasury = vault.treasury();\n        oracle = MockWorkOracle(address(vault.oracle()));\n        vm.startPrank(APPROVED_OPERATOR);\n        oracle.grantRights(WORKER, 1_000 ether);\n        imd.mint(BORROWER, 1_700 ether);\n        imd.mint(ATTACKER, 2_000 ether);\n        imd.mint(address(treasury), 100 ether); // the reserve\n        vm.stopPrank();\n        vm.prank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(ATTACKER);\n        imd.approve(address(vault), type(uint256).max);\n    }\n\n    /// @dev A borrower opens 1000 of debt, a worker mints 250 against it thirteen hours later, the\n    /// borrower unwinds. 100 of reserve now stands behind 250 of work-issued imdUSD: backing 0.4, the\n    /// state `test_debtUnwindCannotLeaveReserveRedemptionWorseningBacking` pins. Every honest step is\n    /// in its own block and the debt is aged, so this setup is unaffected by a fix that scopes the\n    /// exclusion to the block or to principal older than the fresh-debt window.\n    function _underback() private {\n        vm.startPrank(BORROWER);\n        vault.lock(1_700 ether);\n        vault.draw(1_000 ether);\n        vm.stopPrank();\n        _nextBlock(13 hours);\n        vm.prank(WORKER);\n        vault.earn(250 ether);\n        _nextBlock(12);\n        // The worker lends the borrower the thirteen hours of stability fee, and funds the attacker.\n        vm.startPrank(WORKER);\n        stable.transfer(BORROWER, 1 ether);\n        stable.transfer(ATTACKER, 100 ether);\n        vm.stopPrank();\n        vm.startPrank(BORROWER);\n        vault.wipe(vault.debtOf(BORROWER));\n        vault.free(1_700 ether);\n        stable.transfer(address(0xdead), stable.balanceOf(BORROWER));\n        vm.stopPrank();\n        _nextBlock(12);\n        assertEq(vault.totalDebt(), 0);\n        assertEq(stable.totalSupply(), 250 ether);\n        assertEq(vault.backingPerUnit(), 0.4 ether, \"100 of reserve behind 250 of supply\");\n    }\n\n    function _nextBlock(uint256 secondsLater) private {\n        vm.roll(vm.getBlockNumber() + 1);\n        vm.warp(vm.getBlockTimestamp() + secondsLater);\n    }\n\n    function test_sameBlockRoundTripTakesTheReserveAtParWhileBackingIsPointFour() public {\n        _underback();\n        uint256 backingBefore = vault.backingPerUnit();\n        uint256 reserveBefore = imd.balanceOf(address(treasury));\n        uint256 blockBefore = vm.getBlockNumber();\n\n        // Transaction 1: open a 200% position. Transaction 2: redeem. Transaction 3: close it.\n        // `isolate = true` makes each call its own transaction; block.number never moves.\n        vm.startPrank(ATTACKER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        uint256 out = vault.cash(100 ether, 0, address(0));\n        vault.wipe(1_000 ether);\n        vault.free(2_000 ether);\n        vm.stopPrank();\n        assertEq(vm.getBlockNumber(), blockBefore, \"the whole sequence fits in one block\");\n\n        // What the burn should have been paid at the backing it found: 100 x 0.4 less the capped fee.\n        uint256 proRata = Math.mulDiv(100 ether, Math.mulDiv(backingBefore, 10_000 - 500, 10_000), 1e18);\n        assertEq(proRata, 38 ether, \"pro-rata payout for 100 of a 250 supply backed at 0.4\");\n\n        // Actual: paid at par less a diluted fee, and the attacker's position is empty again.\n        (uint256 collateral, uint256 debt) = vault.positions(ATTACKER);\n        assertEq(collateral, 0);\n        assertEq(debt, 0);\n        assertLe(out, proRata, \"a redemption may not take more than its pro-rata share of backing\");\n        assertGe(imd.balanceOf(address(treasury)), reserveBefore - proRata, \"reserve drained beyond pro-rata\");\n    }\n}","reproduction":"State: ParameterizedVault, 18-decimal IMD at $1 (primary 5e14 wei per IMD, Chainlink 2000e8), NHI 0.85 (mat 170), Treasury holding 100 IMD, empty register. BORROWER lock(1700e18), draw(1000e18); 13 hours and one block later WORKER earn(250e18); next block BORROWER wipe(debtOf), free(1700e18). Now totalDebt == 0, totalSupply == 250e18, backingPerUnit() == 0.4e18. ATTACKER holds 100 imdUSD and 2000 IMD and sends, in ONE block as three separate transactions: lock(2000e18) and draw(1000e18); cash(100e18, 0, address(0)); wipe(1000e18) and free(2000e18). Expected: gemOut <= 100 x 0.4 x (1 - 5%) = 38e18 IMD, the pro-rata share of the backing the burn found. Actual: cash returns 95.5e18 IMD (feeBps 450 because the attacker's 1000 counted as prior supply; backingPerUnit read as 1e18 because 1700 of the attacker's mat-capped collateral counted), the Treasury keeps 4.5 IMD against 150 imdUSD still outstanding (backing 0.03), and the attacker's position is empty again. Run: forge test --match-path test/scratch/BackingRoundTrip.t.sol -> FAIL 'a redemption may not take more than its pro-rata share of backing: 95500000000000000000 > 38000000000000000000'. The specialist's original proof was replaced because its own setup borrowed and earned in one block, so it reverted WorkCeilingReached under the fix it proposed; this version puts every honest setup step in its own block with the debt aged 13 hours, and passes with either a block-scoped or an age-based exclusion.","severity":"medium","snippet":"        uint256 added = _transient(SECURED_THIS_TX_SLOT);\n        uint256 held = secured > added ? secured - added : 0;\n        uint256 minted = _transient(MINTED_THIS_TX_SLOT);\n        uint256 prior = totalDebt > minted ? totalDebt - minted : 0;","title":"Redemption backing cap excludes only same-transaction capital: a borrow / redeem / unwind sequence over adjacent transactions takes the reserve at par while backing is 0.4"},{"citation":"resolved","description":"Kept from audit_economics 40d9da19; its proof was run and fails for the reason given. backedDebt() caps totalDebt at _debtAtTransactionStart(), a transient slot written by _debtChanged and cleared when the transaction ends. The REVISION note for finding 4d30331c (lines 204-212) and docs/COMPUTE-BACKING-DESIGN.md (section on 'D counts only debt that existed before the transaction began') close the single-transaction round trip and accept the cross-transaction one because 'the cost of it is real capital at risk in an open position, not gas'. That premise does not hold: a rights holder borrows in transaction 1, earns a quarter of that debt in transaction 2 (the slot is empty, so backedDebt reads the whole debt), and repays and withdraws in transaction 3. In one block the position is open for zero seconds; across three blocks I measured the whole cost at 0.0000338 imdUSD of stability fee per 1000 borrowed. The block ends with work-minted imdUSD, no debt and no collateral, and with the launch configuration (an empty register) backingPerUnit() is 0 for that supply. The honest sequence 'someone borrows, a worker earns against it, the borrower later repays' reaches the same end state and is the documented design; what this adds is that the minter needs no one else's debt and no exposure: only rights and collateral worth 8x the mint for one block, up to 25% of whatever LINE has left ($250k at the committed $1M). Who loses: every imdUSD holder, by dilution of backing. Reachable with the constants as committed (earnMat 2500, mat 170). Call sequence, one key, three transactions: lock(2000e18); draw(1000e18) | earn(250e18) | wipe(1000e18); free(2000e18). Fix: as for the redemption cap, the exclusion must be by age. Making DEBT_AT_TX_START_SLOT block-scoped makes the attached proof pass (verified on a patched copy) but leaves the same mint open one block later for 24 seconds of fee; the ratio term should count only principal older than FRESH_DEBT_WINDOW (a global tally of fresh principal maintained beside each position's recentlyMinted record and subtracted in backedDebt). This narrows the accepted design, so it needs the requester's decision; in either case the sentence at lines 211-212 states a cost the code does not impose.","line":221,"path":"src/ParameterizedVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {CDPVault} from \"src/CDPVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {MockWorkOracle} from \"src/MockWorkOracle.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract CeilingFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract CeilingMirror is ISwarmFeed {\n    ISwarmFeed private immutable primary;\n\n    constructor(ISwarmFeed p) {\n        primary = p;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return primary.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return primary.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return primary.maxAge();\n    }\n}\n\ncontract CeilingAggregator {\n    /// @dev A pure constant: `vm.etch` copies code only, so a storage initializer would read as zero.\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @notice `backedDebt` excludes debt created in the current TRANSACTION. A worker who borrows in one\n/// transaction, earns in the next and unwinds in a third, all in one block, mints work-backed imdUSD\n/// against debt that never outlives the block. The comment on `backedDebt` says the cross-transaction\n/// version costs \"real capital at risk in an open position, not gas\"; inside one block it risks nothing.\ncontract WorkCeilingRoundTripProof is Test {\n    address private constant WORKER = address(0xCA);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    MockWorkOracle private oracle;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new CeilingAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        // 1 IMD = 1/2000 ETH and 1 ETH = $2000, so the vault prices IMD at exactly $1.\n        CeilingFeed primary = new CeilingFeed(uint256(1 ether) * 1e18 / 2000 ether);\n        CeilingFeed health = new CeilingFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new CeilingMirror(primary))\n        );\n        stable = vault.stablecoin();\n        oracle = MockWorkOracle(address(vault.oracle()));\n        vm.startPrank(APPROVED_OPERATOR);\n        oracle.grantRights(WORKER, 1_000 ether);\n        imd.mint(WORKER, 2_000 ether);\n        vm.stopPrank();\n        vm.prank(WORKER);\n        imd.approve(address(vault), type(uint256).max);\n    }\n\n    function test_sameBlockRoundTripMintsWorkAgainstDebtThatIsGoneBeforeTheBlockEnds() public {\n        // A fresh vault: no debt, no reserve, so the ceiling is zero and nothing may be earned.\n        assertEq(vault.earnLine(), 0);\n        vm.prank(WORKER);\n        vm.expectRevert(CDPVault.WorkCeilingReached.selector);\n        vault.earn(1);\n\n        uint256 blockBefore = block.number;\n        // Transaction 1: borrow. Transaction 2: earn a quarter of that debt. Transaction 3: unwind.\n        // `isolate = true` makes each call its own transaction; block.number never moves.\n        vm.startPrank(WORKER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        // Expected: the ceiling's debt term is backed only by positions that outlive the block, so\n        // debt opened in this block does not authorise this mint.\n        vm.expectRevert(CDPVault.WorkCeilingReached.selector);\n        vault.earn(250 ether);\n        vault.wipe(1_000 ether);\n        vault.free(2_000 ether);\n        vm.stopPrank();\n        assertEq(block.number, blockBefore, \"the whole sequence fits in one block\");\n\n        // Actual on the current code: the earn succeeds, and the block ends with 250 work-minted imdUSD,\n        // no debt, no collateral and an empty reserve.\n        assertEq(vault.totalDebt(), 0);\n        assertEq(imd.balanceOf(address(vault)), 0, \"no collateral is left in the vault\");\n        assertEq(stable.totalSupply(), 0, \"no work-minted imdUSD should have been issued\");\n    }\n}","reproduction":"State: fresh ParameterizedVault, IMD at $1, NHI 0.85, empty register; WORKER holds 1000e18 of work rights and 2000 IMD. earnLine() == 0 and earn(1) reverts WorkCeilingReached. In one block, as separate transactions (foundry.toml sets isolate = true, so each top-level call is its own transaction): lock(2000e18), draw(1000e18); earn(250e18); wipe(1000e18), free(2000e18). Expected: earn reverts WorkCeilingReached, because no debt that outlives the caller's own round trip backs it. Actual: earn succeeds (backedDebt() == 1000e18, earnLine() == 250e18); afterwards totalDebt == 0, the vault holds no collateral, totalSupply == 250e18 and backingPerUnit() == 0. Run: forge test --match-path test/scratch/Proof_40d9da19ad31.t.sol -> FAIL 'next call did not revert as expected'. The same sequence with one block (12 s) between each step also succeeds on this tree: totalSupply 250e18, totalDebt 0, backingPerUnit 0, total fee paid 33789954337000 wei of imdUSD.","severity":"medium","snippet":"        uint256 debt = Math.min(totalDebt, _debtAtTransactionStart());","title":"backedDebt excludes only same-transaction debt: borrow, earn and unwind in adjacent transactions mints work imdUSD against debt that is gone a block later"},{"citation":"resolved","description":"Merged from audit_permissions 8f16cfdd and audit_flow 327a7c68. _redemptionRate(amount) returns decayed base + amount / priorSupply / divisor, capped, and cash() charges that single post-increase rate on the whole burn and stores it as the new base. A sequence of n burns therefore pays each chunk at the rate at the end of that chunk, roughly the integral of a linear rate (about half the increase) instead of the end-point rate a single call pays, and the 4.5% cap binds the single call sooner. docs/PARAMETERS-2026-10-05.md tabulates the fee by 'Redeemed at once', and DeploymentConfig.sol:129-130 and Parameters.sol:85-87 present the schedule as a property of the fraction redeemed ('redeeming 10% of supply at once costs 5.5% (the 5% cap)'); the REVISION note above _redemptionRate says a redeemer should pay 'the fee-adjusted price for that size'. With the committed divisor 2 a redeemer who splits pays materially less for the same size while leaving the base rate the same or slightly higher for everyone after, so the brake on a run is weaker than the parameter record assumes. No same-transaction mint is involved (the fcd5b261 netting does not apply); the chunks can be separate transactions in one block or one multicall. A related, already recorded effect is advisory 08f0352c (docs/INTERNAL-AUDIT-2026-10-04.md, docs/REDEMPTION-CHECKS.md): chunks against principal younger than FRESH_DEBT_WINDOW do not move the stored base at all. It is not reopened here; the specialist's proof for it (49d98342) runs and fails as described (39,776.1 received fresh against 39,382.2 aged for forty 1,000 chunks), and it shows the effect holds across blocks within the 12-hour window, not only 'same-block' as the earlier record words it. Reachable with the constants as committed. Fix, if size-invariance is wanted: charge each burn the mean of its pre- and post-increase rates (decayed + increase / 2, capped), which makes the total the exact integral and chunking neutral, and leave the stored base update as it is. That lowers the single-call fee relative to today, so it is an economic choice; the alternative is to correct the three documents to say the schedule is per call.","line":687,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault, 18-decimal IMD at $1, one borrower: lock(400_000e18), draw(100_000e18); Treasury holds 50,000 IMD so every burn is reserve-funded; redemptionBaseRate 0; divisor 2; next block. Single call cash(5_000e18, 0, address(0)): redemptionFeeBps(5_000e18) == 300, gemOut 4,850e18 (fee 150), redemptionBaseRate after 25000000000000000 (2.50%). Same starting state, five calls cash(1_000e18, 0, address(0)) in the same block: total gemOut 4,898.7e18 (fee 101.3, 32% less), base after 25515518375422644 (2.55%). Fifty calls of cash(100e18): total gemOut 4,909.89e18 (fee 90.11, 40% less), base after 25633493800422496. Expected per the parameter record: 5% of supply costs 3.00% however it is burned. Actual: 2.03% in five chunks, 1.80% in fifty. Reproduced with test/scratch/Explore.t.sol test_feeSplit (state snapshot between the three runs).","severity":"low","snippet":"        uint256 increase = amount == 0 ? 0 : prior == 0 ? cap : Math.mulDiv(amount, 1e18, prior) / redemptionDivisor();","title":"Redemption fee is charged at the post-increase rate per call, so splitting one redemption into chunks pays about a third less than the documented fee for its size"},{"citation":"resolved","description":"Kept from audit_math 347a44d3. cash() pays amount x backingPerUnit x (1 - fee) / price, and the comment at lines 540-542 argues this is 'exactly neutral on backing by construction' and 'strictly IMPROVES backing, in every state'. That holds for a pool of assets removed pro rata. The backing figure is not such a pool: _securedCollateralValue returns min(securedCollateral x price, (totalDebt - minted - totalBadDebt) x mat / 100) (line 614). When the second term binds, each imdUSD of principal a redemption cancels removes mat/100 (1.70 to 2.00) imdUSD of counted backing while supply falls by one, so the ratio falls with every position-funded burn. Consequences: (1) redeemers in a run are paid in order, the first at 1 - fee and later ones at a lower, falling multiple, the opposite of the pro-rata neutrality the code documents; (2) the public backingPerUnit() view drops below par purely because a redemption happened, with no price move; (3) the comment at line 652 ('Its discount remains in this position as backing') stops being true once the candidate's principal reaches zero, because _secured returns 0 for a debt-free position and the retained fee becomes freely withdrawable collateral. Reachable with the shipped constants (mat 170, earnMat 2500): it needs the cap to bind with supply above principal, which any repayment after work minting produces, since totalEarned never falls. Not a theft: no redeemer receives more than par less the fee. Smallest fix: decide which statement is the design. If pro-rata neutrality is wanted, the ratio term must shrink by no more than the principal cancelled when measured for redemption (value the secured term at min(collateral value, prior principal) for the payout calculation); otherwise delete the neutrality / 'every state' claim at lines 540-542 and the 'remains as backing' sentence at 652, and document that the payout declines through a run.","line":614,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault, 18-decimal IMD at $1, NHI 0.85 (mat 170, gap 50), empty Treasury, each step in its own block. A: lock(684e18), draw(400e18) (ratio 171). B: lock(1800e18), draw(600e18). W: earn(250e18). B: wipe(600e18). Now totalDebt 400e18, supply about 650e18, secured value 684, cap 400 x 1.7 = 680, backingPerUnit() == 1e18 (capped). R: cash(100e18, 0, A). Expected per lines 540-542: backingPerUnit() afterwards >= before. Actual: R receives 95e18 IMD (fee at the 500 bps cap), A's debt is 300, backing = min(589, 510) = 510 over 550, backingPerUnit() == 927272839310158516. A second cash(100e18, 0, A) pays 88.09e18 and leaves backingPerUnit() == 755555719374254448. Reproduced with test/scratch/Explore.t.sol test_backingFalls.","severity":"low","snippet":"        return Math.min(Math.mulDiv(held, price, 1e18), Math.mulDiv(prior, mat(), 100));","title":"backingPerUnit falls across a position-funded redemption whenever the aggregate mat cap binds; the 'neutral / strictly improves backing in every state' claim in cash() is false"},{"citation":"resolved","description":"Kept from audit_economics 126977f1. The fix for finding fcd5b261 measures the fee increase against 'the supply that existed before this transaction' (comment at line 676) by subtracting MINTED_THIS_TX_SLOT from totalSupply. Only draw() adds to that slot (line 424). earn() (lines 447-465) mints imdUSD to the caller without touching it, so supply earned earlier in the same transaction counts as prior supply. A rights holder who earns and redeems in one call pays a smaller increase and, because redemptionBaseRate is stored from the same figure, leaves a lower base for every later redeemer. Bounded by the work ceiling: with earnMat 2500 and an empty register earn can add at most 25% of debt, cutting the increase by at most 20%. Unlike the draw path the earned supply is permanent, so this is a weakening of the brake during a run and of the fee a candidate retains, not an extraction. Reachable with the constants as committed. Smallest fix: add _transientAdd(MINTED_THIS_TX_SLOT, amount) to earn(), but only into a tally _redemptionRate reads; _securedCollateralValue subtracts the same slot from totalDebt (line 611), and earned imdUSD is not debt, so either use a second slot for earn or subtract the earn tally in _redemptionRate alone.","line":686,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault, 18-decimal IMD at $1. A: lock(2000e18), draw(1000e18) (totalSupply 1000e18); Treasury holds 1000 IMD; divisor 2; next block. redemptionFeeBps(100e18) == 500 and earnLine() == 250e18. A contract holding 250e18 of work rights calls, in ONE transaction: vault.earn(250e18); fee = vault.redemptionFeeBps(100e18); out = vault.cash(100e18, 0, address(0)). Expected: fee 500 bps and redemptionBaseRate 45000000000000000, the increase measured against the 1000 that existed before the transaction. Actual: fee == 450 (100 / 1250 / 2 = 4%), out == 95.5e18, redemptionBaseRate == 40000000000000000. Reproduced with test/scratch/Explore.t.sol test_earnDilutes.","severity":"low","snippet":"        uint256 prior = supply > minted ? supply - minted : 0;","title":"_redemptionRate nets out principal drawn this transaction but not imdUSD earned this transaction, so earn-then-cash in one call dilutes the fee base and the stored base rate"},{"citation":"resolved","description":"Merged from audit_economics 5886ca9c and audit_flow dc6b6dcf. Outside the three contracts in scope, but question 7 asks whether a reserve listing can authorise unbacked minting, and ParameterizedVault.earnLine() adds Treasury.reserveValueUsd() unbounded. setReserveAsset special-cases the vault's gem by fixing its decimals at 18 (the vault prices collateral per 1e18 raw units) but accepts whatever priceFeed the proposal names; Parameters._validate and Treasury.validateReserveAsset only check that the asset has code and decimals and that the feed answers isStale() / latestValue() well-formed. ParameterizedVault creates two feeds: usdPriceFeed (USD per 1e18 raw IMD, documented at ParameterizedVault.sol:40-41 as 'the price source the Treasury's register is expected to hold for IMD') and collateralPriceFeed (USD per 1e18 raw sIMD, 7.95e-6 of the former). docs/MAINNET-RUNBOOK.md section 8 names the correct call, proposeReserveAsset(sIMD, vault.collateralPriceFeed(), haircut), but nothing on chain ties a listing of gem to that feed, and the two feed getters sit side by side. If the governor proposes sIMD with usdPriceFeed, every raw sIMD unit is valued at the price of a raw IMD unit and any rights holder can earn up to their rights with nothing behind it. More generally the governor can list any token against any feed after 48 hours, so the reserve term of the work ceiling and the reserve term of backingPerUnit (capped at par for payouts, but an under-backed system then reads as fully backed) are bounded only by trust in that key; ParameterizedVault's header (lines 25-26: the governor can change 'never where the price comes from') is true of collateral pricing and false of reserve pricing. This is the intended power of the role, visible for two days, not a bypass. Smallest guard: in validateReserveAsset, when asset == vault.gem(), require address(priceFeed) == vault.collateralPriceFeed() (read through the same _linked probe used for gem()); and reword lines 25-26 to say reserve price sources are governed.","line":188,"path":"src/Treasury.sol","reproduction":"ParameterizedVault over a 24-decimal share at 7.95e12 raw IMD per 1e18 raw share, IMD/ETH 0.00546e18, ETH/USD 2000e8 (IMD $10.92, collateralPriceFeed 86,814,000,000,000). The Treasury holds 100e24 raw sIMD (795 IMD, true value 8,681.4e18 USD). APPROVED_OPERATOR calls parameters.proposeReserveAsset(share, vault.usdPriceFeed(), 10000); 48 hours later anyone calls applyPending(). Expected: a listing of the collateral token is valued at its collateral price, about 8,681.4e18, or refused. Actual: vault.reserveValue() == 1092000000000000000000000000 (1.092e27, i.e. $1.092 billion) and vault.earnLine() == the same figure, 125,786 times the true value. Reproduced with test/scratch/Mislist.t.sol.","severity":"low","snippet":"        uint8 places = address(asset) == _linked(abi.encodeWithSignature(\"gem()\"))\n            ? 18\n            : IERC20Metadata(address(asset)).decimals();","title":"A reserve listing accepts any price source: the collateral token listed against the per-IMD usdPriceFeed is valued 125,786x and raises earnLine by the same factor (governor trust assumption with a che"},{"citation":"resolved","description":"Kept from audit_math 6239493d as the answer to question 2's 'can a marker or borrower extract more than the formula'. barkFor records the caller's chosen beneficiary as marker and bite pays that address chip() of the bonus; nothing excludes owner == beneficiary, and the owner is the party best placed to know the moment their position goes under. docs/PARAMETERS-2026-10-05.md states the borrower's cost of liquidation as 20% of the debt repaid; with a self-mark it is 18% (the 10% marker share of the 20% bonus returns to the borrower). A borrower who also supplies the imdUSD and bites itself pays only the protocol cut (2%), which is economically a repayment and extracts nothing from anyone. A second-key variant cannot be prevented on chain, and no one receives more than the formula's total seizure, so this is recorded only so that the 20% in the parameter record is read as the maximum a keeper-marked liquidation costs, not the borrower's floor.","line":826,"path":"src/CDPVault.sol","reproduction":"NHI 0.60 (mat 200, lull 0), 18-decimal IMD at $1. Borrower lock(200e18), draw(100e18). Price falls to $0.90 (ratio 180 < 200). Borrower calls barkFor(self, self) then bite(self, 100e18) in the same block: collateralSeized 133.33e18, bonus 22.22e18, protocolCut 2222222222222222222 to the Treasury, borrower receives 131111111111111111111 and keeps 66666666666666666667 in the position. Expected penalty per the parameter record: 20% of debt repaid (22.22 IMD at $0.90). Actual: 2.22 IMD. With a third-party liquidator and a self-mark the borrower recovers markerCut = 2.22 IMD of the 22.22 bonus. Reproduced with test/scratch/Explore.t.sol test_selfMark.","severity":"info","snippet":"        if (marker == msg.sender) {\n            gem.safeTransfer(msg.sender, collateralSeized - protocolCut);","title":"A borrower can name itself marker and recover the marker's share of the bonus: the effective liquidation penalty is 18% of debt repaid, not the 20% the parameter record states"},{"citation":"resolved","description":"Merged from four specialist entries (a20e5af1 item 1-4, 20a43855, d9cd8978, 21521360). bite computes collateralSeized = Math.mulDiv(debtToRepay, (100 + CHOP_PERCENT) * 1e16, price) (line 795) with CHOP_PERCENT = 20 (line 112): 1.2e18, collateral worth 120% of the imdUSD burned. The stale figure appears in four places: src/CDPVault.sol:779 ('floor(debtToRepay * 1.1e18 / price) IMD, i.e. collateral worth 110%'), src/CDPVault.sol:177 on cut() ('it splits the existing 10% bonus'), src/CDPVault.sol:882 on badDebtOf ('including the 10% payout', while the code uses 100 + CHOP_PERCENT), and src/ParameterizedVault.sol:218 on backedDebt ('seizable at the usual 10% bonus'). Two more comments in the same area claim what the code does not do: the sweep comment at lines 804-806 says the dust it folds in is 'smaller than the seizure for a single wei of debt' and that this prevents the freeze, but a remainder equal to or just above that seizure is left and freezes the same way once the price falls further (see the medium finding), and the unit 'IMD' in 779 is sIMD raw units on mainnet. Behaviour is unaffected; a reader sizing liquidation risk or bad debt from these comments understates the bonus by half. Fix: write 1.2e18 / 120% / 20%, or refer to CHOP_PERCENT.","line":779,"path":"src/CDPVault.sol","reproduction":"bite(owner, 100e18) at price 1e18 seizes 120e18 of collateral (line 795), not the 110e18 line 779 states. Observed at mainnet scale in test/scratch/DustRelockFreeze.t.sol: the one-wei seizure is floor(1.2e18 / 43407000000000) = 27,645 raw units, i.e. 1.2 wei of value per wei of debt, and a remainder of 27,646 was left by the first bite.","severity":"info","snippet":"    /// @dev Payout is floor(debtToRepay * 1.1e18 / price) IMD, i.e. collateral worth 110% of the imdUSD burned","title":"NatSpec still describes a 10% bonus and a 1.1e18 / 110% payout; CHOP_PERCENT is 20 and bite seizes 120%"},{"citation":"resolved","description":"Merged from a20e5af1 item 5, 4c51e063, 8b471557 and 83a29035. docs/MAINNET-RUNBOOK.md deploys ParameterizedVault with StakedIMD (24 decimals) as gem, priced through SharePriceFeed per 1e18 RAW share units. The arithmetic is decimal-agnostic and correct (ratio, seizure and the dust threshold were exercised at mainnet scale in the dust re-lock reproduction, and test/ShareCollateral.t.sol covers the rest), so this is documentation only, but line 28 is the first statement a reader meets and it contradicts the unit convention every formula depends on, the same assumption SharePriceFeed's header warns against. The constructor @param at line 279 ('Deployed, nonrebasing, fee-free MockIMD collateral (18 decimals)') has the same problem, and in the base vault the price is wei of ETH per IMD rather than USD. Fix: state that collateral is any token priced per 1e18 raw units by _priceOrZero(), USD-denominated in ParameterizedVault, and that only imdUSD is fixed at 18 decimals.","line":28,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault constructed over a 24-decimal ERC-4626 share at 7.95e12 raw IMD per 1e18 raw share, IMD at $10.92: gem.decimals() == 24 and collateralPriceFeed().latestValue() == 86,814,000,000,000, a USD price per 1e18 raw share units, not 'USD per IMD scaled by 1e18' (1.092e19). Asserted in test/scratch/DustRelockFreeze.t.sol.","severity":"info","snippet":"/// @dev Both tokens use 18 decimals; price is USD per IMD scaled by 1e18.","title":"Contract NatSpec says both tokens use 18 decimals, the price is USD per IMD and the collateral is MockIMD; the deployed collateral is 24-decimal sIMD priced per 1e18 raw units"},{"citation":"resolved","description":"Merged from audit_flow 37b397a0 and 7f6c33f4. (1) Since commit e52a025 cover(owner, amount) burns the Treasury's imdUSD against a drained position through _reduceDebt, lowering the position's debt, _recordedBadDebt and totalBadDebt. The comment on totalBadDebt (lines 270-271: 'Measurement only: no insurance or debt forgiveness. Only debt repayment reduces a recorded residual') and on badDebtOf (line 883: 'Measurement only, not insurance or forgiveness') describe the vault before cover existed. (2) Lines 889-891 say _badDebtOf is 'Shared with _recordBadDebt so the accumulator and this view can never disagree about what bad debt means'. _recordBadDebt (lines 1041-1051) never calls _badDebtOf: it records debtOf(owner) only once collateral is zero, while _badDebtOf is a mark-to-market shortfall for any position; the two legitimately disagree whenever a position is underwater but not drained, which the comment at 1034-1040 itself explains. (3) 'adding collateral cannot hide it' (line 271) is true and is the root of the medium finding: adding one raw unit of collateral makes the record permanent. Fix: say that repayment and cover() reduce a recorded residual, and replace the 'shared' sentence with a pointer to the realized-only rule.","line":270,"path":"src/CDPVault.sol","reproduction":"(1) test/Cover.t.sol test_coverRetiresRealizedBadDebtWithTheTreasurysImdUSD: after cover(owner, bad) from an unrelated account, debtOf(owner) == 0 and totalBadDebt == 0 with no repayment by the borrower. (2) Read _recordBadDebt at lines 1041-1051: no call to _badDebtOf. Position with collateral 170e18 and debt 100e18 at price 0.5e18, unliquidated: badDebtOf(owner) = 100e18 - 170e18 x 0.5 / 1.2 = 29.17e18 while totalBadDebt == 0.","severity":"info","snippet":"    /// @dev Measurement only: no insurance or debt forgiveness. Only debt repayment reduces a recorded\n    /// residual; adding collateral cannot hide it. Use badDebtOf for a current-price, accrued view.","title":"Bad-debt NatSpec claims 'no insurance or debt forgiveness' and that _badDebtOf is shared with _recordBadDebt; cover() retires bad debt with Treasury imdUSD and the accumulator never calls _badDebtOf"},{"citation":"resolved","description":"Merged from a20e5af1 item 7, a796a42b, 8b92f10f and e93659a9. Lines 159-165 describe the annual stability fee and the rule that a governed subclass MUST call drip in the same transaction as a rate change; they are followed by a second @notice for the redemption divisor (166-167) and the whole block documents redemptionDivisor() at line 168. duty() at line 172 is undocumented. A NatSpec consumer shows redemptionDivisor() with two @notice tags and the fee rule under the wrong function. In the same family: src/CDPVault.sol:192-194 says ParameterizedVault's ceiling is 'reserveValueUsd + totalDebt * earnMat / 10000' (and DeploymentConfig.sol:144 repeats it) where the implementation uses backedDebt(), totalDebt capped at the transaction start less totalBadDebt; src/Parameters.sol:217-220 puts proposeReserveAsset's documentation on proposeRedemptionDivisor (separate finding); src/Treasury.sol:437-438 ('Release reserve IMD for a redemption priced and burned by this Treasury's vault...') sits above the oracle-budget section, detached from redeemIMD at line 511. The code itself is right: Parameters._apply calls vault.drip() before writing the new set (Parameters.sol:455-456), so a governed rate change does not reprice elapsed time. Fix: move lines 159-165 above duty(), and name backedDebt in the earnLine description.","line":159,"path":"src/CDPVault.sol","reproduction":"Read src/CDPVault.sol lines 159-174: the block beginning '@notice Annual stability fee on open debt' is immediately followed by 'function redemptionDivisor()' and 'function duty()' has no NatSpec. Read ParameterizedVault.earnLine (line 233): reserveValue() + Math.mulDiv(backedDebt(), parameters.earnMat(), 10_000), not totalDebt.","severity":"info","snippet":"    /// @notice Annual stability fee on open debt, in basis points, accrued linearly from the last\n    /// index checkpoint.\n    /// @dev Virtual for the same reason line and cut are: a deployment pins\n    /// it in source, and a test can hold it at another value without rewriting the source to do it.\n    /// This contract has no setter, so the rate is permanent unless a subclass reads it from\n    /// somewhere governed — in which case that governor MUST call `drip` in the same\n    /// transaction as the change, or the new rate reaches time that has already elapsed.\n    /// @notice Each redemption raises the fee base by redeemed / supply / this. The base vault reads\n    /// the source constant; ParameterizedVault reads its governed Parameters.\n    function redemptionDivisor() public view virtual returns (uint256) {","title":"Misattached NatSpec: the stability-fee documentation (including the drip-before-change rule) sits on redemptionDivisor(), duty() has none, and earnLine's description names totalDebt where the code use"},{"citation":"resolved","description":"Merged from d332273e, 992f23e0, aa0f0274 and 2deb3e94. ImdUSD.burn is documented as 'CDPVault only burns the caller's tokens during repayment, liquidation or redemption'. CDPVault.cover (src/CDPVault.sol:486-500) calls stablecoin.burn(payer, amount) where payer is the Treasury returned by _surplus(), not msg.sender, and anyone may call cover. The behaviour is intended ('Bad debt first', docs/PARAMETERS-2026-10-05.md) and bounded: only against a position with zero collateral and a recorded residual, only up to what is owed (ExcessRepayment), never twice (the debt is reduced by the same call). But the token's own statement of what its single burner may do to a holder's balance is no longer true, and a holder reading ImdUSD alone cannot tell that the vault can burn an account other than the caller. No other path burns a third party: wipe, bite and cash all burn msg.sender. Fix: name cover and the Treasury in the comment.","line":61,"path":"src/ImdUSD.sol","reproduction":"test/Cover.t.sol test_coverRetiresRealizedBadDebtWithTheTreasurysImdUSD: STRANGER calls vault.cover(BORROWER, bad); stable.balanceOf(treasury) falls by bad and stable.balanceOf(STRANGER) stays 0. Also exercised in test/scratch/DustRelockFreeze.t.sol with the fix applied on a copy of the tree (cover called by the test contract, Treasury debited).","severity":"info","snippet":"    /// @dev CDPVault only burns the caller's tokens during repayment, liquidation or redemption.","title":"ImdUSD.burn NatSpec says the vault only burns the caller's tokens; cover() burns the Treasury's on behalf of any caller"},{"citation":"resolved","description":"Merged from a5b013b1 and e93659a9. The @notice at lines 217-220 ('Queue a listing, repricing or (with a zero price source) delisting of one of the Treasury's reserve assets ...') documents proposeReserveAsset (line 230, which has no NatSpec) but is attached to proposeRedemptionDivisor (line 221), which queues a divisor validated to MIN_/MAX_REDEMPTION_DIVISOR (1 to 8) and never touches the register. Generated ABI documentation and any reader of the governed divisor are told the wrong thing about the one governed input to the redemption fee. Fix: move the block above proposeReserveAsset and give proposeRedemptionDivisor a one-line notice ('Queue a change to the redemption fee divisor, refused outside 1..8').","line":217,"path":"src/Parameters.sol","reproduction":"Read src/Parameters.sol lines 217-223 against _validate (lines 349-353) and _apply (lines 422-425): proposeRedemptionDivisor encodes Change.RedemptionDivisor with a uint256, _validate calls _checkDivisor and _apply writes redemptionDivisor. Expected: NatSpec describes the divisor. Actual: it describes a reserve-asset listing.","severity":"info","snippet":"    /// @notice Queue a listing, repricing or (with a zero price source) delisting of one of the\n    /// Treasury's reserve assets. The Treasury's own rules apply at proposal — imdUSD is refused with\n    /// `StablecoinIsNotReserve`, a haircut must be at most 10000 — so a change the register would refuse\n    /// never occupies the slot. Applying it, like every other change, is anyone's to do after the delay.\n    function proposeRedemptionDivisor(uint256 divisor) external {","title":"Parameters.proposeRedemptionDivisor carries proposeReserveAsset's NatSpec; proposeReserveAsset has none"},{"citation":"resolved","description":"Kept from audit_math d2eb4647, with its figure corrected. _mat (src/CDPVault.sol:1081-1085) floors at 170 since the 2026-10-05 parameter change, so the cliff 'mat - 1' of docs/COMPUTE-BACKING-DESIGN.md section 3 is 0.70, i.e. 7000 bps, and at earnMat 2500 the worst-case backing with an empty reserve is 1.70 / 1.25 = 136% (the specialist wrote 'roughly 128%'; 136% is the arithmetic). The same stale derivation is at src/DeploymentConfig.sol:146 ('which is 5000 at the loosest NHI. 2500 is half that cliff, 120% worst-case backing'). The constant 2500 is a deliberate value and not a finding; the derivation written beside it is wrong in the safe direction. Note that the derivation in either form assumes the surplus collateral behind debt stays in place, which the two round-trip findings show a borrower can withdraw a block after work is minted against it.","line":72,"path":"src/Parameters.sol","reproduction":"mat() with NHI >= 0.85e18 returns 170 (src/CDPVault.sol:1082), the loosest value. Worst-case backing of debt D plus work 0.25 D with collateral at exactly mat: 1.70 D / 1.25 D = 1.36. The comment at src/Parameters.sol:72 says mat 150 and 120%.","severity":"info","snippet":"    /// touches one at the loosest NHI (mat 150); this is half of it, 120% with an empty reserve.","title":"MAX_EARN_MAT_BPS rationale cites 'mat 150', a 5000-bps cliff and 120% worst-case backing; the shipped mat floor is 170, the cliff 7000 and the figure 136%"},{"citation":"resolved","description":"Not a defect: the record of what this review covered. READ IN FULL: src/CDPVault.sol, src/ParameterizedVault.sol, src/ImdUSD.sol, src/Parameters.sol, src/Treasury.sol, src/Governed.sol, src/UsdPriceFeed.sol, src/SharePriceFeed.sol, src/interfaces/IShareVault.sol, src/interfaces/ISwarmFeed.sol, src/MockIMD.sol, src/MockWorkOracle.sol, and the economic constants of src/DeploymentConfig.sol. NOT REVIEWED: SwarmFeed, PriceFeed, NhiFeed, SpotFeed, SwarmRelay, SwarmWorkOracle, OracleAsker, Registry and the two factories (read only where the vault calls them). COULD NOT REACH: StakedIMD (sIMD) itself and the live Chainlink aggregator; no fork was run, so lockIMD's behaviour against the real staking vault, sIMD's exchange-rate behaviour under donation (SharePriceFeed reads convertToAssets(1e18) live) and its one-block hold rest on the mocks and the documents. No static analyser was run. Twelve independent specialist passes were not run; this is one reviewer re-deriving thirty specialist entries.\nQ1 positions: nothing found. lock/free/draw/wipe act only on msg.sender's position; draw and indebted free check _healthy at the gated price after _accrue; lock credits exactly the balance delta or reverts; lockIMD credits the shares that arrived by balance, so a share vault that over-reports cannot inflate the credit (test/ShareCollateral.t.sol), and every state-changing entry except drip is nonReentrant, so reentry through deposit cannot reach another entry point. Third-party effects are the designed ones: cash against a named candidate and cover against the Treasury.\nQ2 liquidation: the dust freeze (medium) and the self-mark note (info). The split itself is exact: protocolCut and markerCut are floors of the same bonus, the liquidator receives the rest, the swept dust is added after the split and is below one wei of debt in value.\nQ3 redemption: the round trip (medium), the per-call fee (low), earn dilution (low) and the falling backing ratio (low). A redemption cannot worsen a candidate's ratio: line 641 compares exact fractions. The divisor is bounded 1..8 at proposal and at application.\nQ4 bad debt: coverage cannot exceed what is owed (_reduceDebt reverts ExcessRepayment), cannot apply while collateral is non-zero, cannot be spent twice, and totalBadDebt moves with the per-position record in every writer (_reduceDebt lines 1009-1017, _recordBadDebt). The defect is the opposite direction: the record can be made permanent (medium).\nQ5 stability fee: nothing found. Parameters._apply calls vault.drip() before writing the new set; chi() is non-decreasing and chiOf is only ever assigned chi(), so chiOf cannot exceed chi.\nQ6 price gating: nothing found. draw, indebted free, earn (finite ceiling), cash, barkFor, heel and bite all require fresh feeds and price agreement; ParameterizedVault adds collateralPriceFeed staleness. lock, wipe, cover, debt-free free and drip are ungated and move value only toward safety; _clearIfRecovered refuses to clear a mark on stale or divergent feeds without blocking the deposit or repayment.\nQ7 work issuance: the adjacent-transaction round trip (medium) and the reserve mis-listing (low). Within one transaction the transient cap holds.\nQ8 arithmetic: nothing found. _collateralRatio saturates, _secured guards its product, every payout uses mulDiv and floors against the receiver, the fee rounds up to a whole basis point, _mat rounds up. Units agree at 24 decimals because every formula is per 1e18 raw units.\nDROPPED: (1) a20e5af1 item 6, 'the supply identity omits totalFeesMinted': it does not reproduce. A paid fee is burned from the payer and re-minted to the fee recipient, so supply moves only by principal and the identity at lines 124 and 442 (supply = totalDebt + totalEarned - totalNonPrincipalRedeemed) holds as written; the invariant the specialist cited runs at a zero rate where totalFeesMinted is 0. (2) 49d98342 as a separate finding: it is advisory 08f0352c of the earlier audits, an open requester decision, not","line":34,"path":"src/CDPVault.sol","reproduction":"Commands run on this tree: forge test --match-path 'test/scratch/*' for the three attached proofs (all FAIL as stated) and for test/scratch/Explore.t.sol, Mislist.t.sol and DustVariant.t.sol (the figures quoted in each finding). The full suite was run on a patched copy only (491 passed with the bite fix). Supply identity check for the dropped claim: wipe(amount) with fee f and principal p burns p + f from the payer and mints f to the Treasury (lines 1027-1030), so totalSupply falls by p, exactly as totalDebt does.","severity":"info","snippet":"contract CDPVault is ReentrancyGuard {","title":"Coverage, answers to questions 1-8 where nothing was found, and specialist claims that were dropped"}],"hash":"e510d7fe4a938d1c181aa46064f779a53ceec197bffc5555a3d055adb9c16979","nodeId":"eea19b22-df07-459c-8f05-43915e429a36","outcome":"completed","summary":"The review is written to `.imd-findings.json`: 16 entries — 3 medium, 4 low, 9 info (the last is a coverage note, not a defect). Three carry proofs that fail on this tree. No high or critical finding survived.\n\n## Kept, after reproducing each\n\n**Medium**\n- **Dust re-lock freezes bad debt** (`src/CDPVault.sol:488`, two specialists merged). After a liquidation drains a position, the owner calls `lock(1)`. `bite` then reverts `InsufficientCollateral` and `cover` reverts `NoRealizedBadDebt`, so the residual stays in `totalBadDebt` and the matching Treasury imdUSD stays behind `BadDebtFirst`. Neither specialist's proof was supplied, so I wrote one. The fix I suggest in `bite` makes it pass on a patched copy, where the rest of the suite still passes (491 tests).\n- **Redemption backing cap bypass** (`src/CDPVault.sol:608`). Borrow, redeem and unwind in three adjacent transactions pays 95.5 IMD where the pro-rata share is 38.\n- **Work ceiling bypass** (`src/ParameterizedVault.sol:221`). Borrow, earn and unwind leaves 250 work-minted imdUSD with no debt, no collateral and backing 0.\n\n**Low**\n- Splitting a redemption into chunks pays about a third less than the documented fee (5 chunks: 101.3 against 150).\n- `backingPerUnit` falls across a position-funded redemption when the mat cap binds (1.0 → 0.927 → 0.756), contradicting the \"strictly improves backing\" comment.\n- `earn` then `cash` in one transaction dilutes the fee base (450 bps instead of 500).\n- Listing sIMD as reserve against `usdPriceFeed` values it 125,786× too high; this is a governor error with a cheap guard, and it sits in `Treasury.sol`, outside the three contracts in scope.\n\n**Info**\n- One note on self-marking: a borrower who marks its own position recovers the marker's share of the bonus.\n- Seven NatSpec mismatches, merged from the specialists' duplicates.\n\n## Where I disagreed with the specialists\n\n- **A dust variant needs no owner action, and the specialist's cause for it did not reproduce.** The first bite left 27,646 raw units against a 27,645 one-wei seizure, not an exact tie. A further 10% price fall then made it unseizable: 1,850 imdUSD of debt neither liquidatable nor coverable, with `totalBadDebt` reading 0.\n- **Backing-cap proof replaced.** The original borrowed and earned in one block in its own setup, so it reverted under the fix it proposed. My version separates the honest steps by block and passes with the fix applied.\n- **Block-scoping does not close either round trip.** One block apart, the work-ceiling sequence still succeeds for 0.0000338 imdUSD of stability fee; the redemption sequence has the same structure but I did not run it a block apart. Block-scoping also broke 73 existing tests on the patched copy. I recommend an age-based exclusion instead; that narrows a documented design choice, so it is the requester's call.\n- **Dropped: \"supply identity omits `totalFeesMinted`\".** A paid fee is burned from the payer and re-minted to the Treasury, so the identity in the source holds as written.\n- **Dropped as a separate finding: fresh-principal chunking (49d98342).** It is the earlier audits' open advisory 08f0352c, not a regression. Its proof does fail as described; I note it under the fee-split finding.\n- **Corrected a figure.** Worst-case backing at mat 170 and earnMat 2500 is 136%, not \"roughly 128%\".\n\n## Not verified\n\n- sIMD itself and the live Chainlink aggregator were out of reach; everything share-related rests on mocks.\n- The feeds, relay, work oracle and factories were read only where the vault calls them.\n- One unverified lead, not in the file: if sIMD's one-block hold is inherited on transfer as the docs say, sending dust sIMD to the Treasury each block might keep `Treasury.fundOracle` reverting.\n\nAnswers to questions 1–8, including the ones where nothing was found, are in the coverage entry.","treeHash":null,"usage":{"cachedInputTokens":3542177,"inputTokens":48,"model":"claude-fable-5-1","outputTokens":57696,"runtime":"claude","turns":31,"wallClockMs":673383}}],"verification":[]}