# Audit report

> 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. Four audit rounds and their fixes are already in (docs/AUDIT-*.md; the newest are docs/AUDIT-FINAL-PANEL-VAULT/GOVERNANCE/ORACLE-2026-10-07.md, fixed in 8756817: git show 8756817). This is the last sweep before the deployment commit is frozen, so it audits the code as it will deploy; a finding of an earlier round counts only if its fix regressed or left a gap. Spend turns on breaking the newest fixes first.
>
> imdUSD 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.
>
> Answer each numbered question, including the ones where nothing is wrong:
> 1. The lag (laggedDebt, laggedSecured, _advanceLag, _approach, BACKING_WARMUP) and its per-transaction netting (_clampLag, _lagAtStart, the two transient slots): can any sequence within one transaction raise the lagged figures above where the transaction found them, or keep them from falling when capital really leaves? Across transactions a decrease still counts at once: is there a cheap way to use that to drive backingPerUnit down (a dominant borrower's wipe then draw in the next transaction), and what does it cost and block?
> 2. The earn gate (_earnOpen: wage != 0 in ParameterizedVault, the same switch as _lagApplies) and the D1 round trip (borrow / earn / unwind across adjacent transactions): is there any state in which earn mints with the lag off, or in which a rights holder can block a governed oracle replacement?
> 3. cover: the ungated sweep of collateral below the one-wei seizure at the LAST price (_priceOrZero, possibly stale), the gated sweep of dust (_coverDust) or of collateral worth less than the recorded bad debt. Can cover take collateral that could make the debt good, can a borrower lose value it should keep, or can a drained borrower still keep cover off cheaply?
> 4. _resecure with an unreadable price (the term kept, at most the collateral, zero with no debt): can a dead leg overstate securedCollateral in a way a redeemer or a work mint can use before the next priced checkpoint?
> 5. The fresh-debt record in 1e18-scaled seconds (draw, _reduceDebt, _recentlyMinted, cash's freshCancelled): is principal-time conserved through every draw/wipe/redemption/liquidation sequence, and can any sequence keep seasoned principal fresh or age fresh principal early?
> 6. Positions, liquidation (bark, barkFor, bite, heel, tail, lull, the mark's expiry), redemption (fee base, candidate eligibility mat + gap, the backingPerUnit cap), bad debt (totalBadDebt and the per-position record across wipe, cover, bite, cash), stability fee (duty, chi, drip). Anything a caller can receive beyond the formula, freeze, or desynchronise.
> 7. Price gating and arithmetic: every value-moving action refused on stale or divergent feeds, the exceptions (lock, wipe, debt-free free, the ungated cover sweep) safe; overflow at extreme collateral or price, rounding direction in every payout, units where a price, a 24-decimal amount and basis points meet.
>
> Not 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.
>
> For 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.

| | |
|---|---|
| Repository | https://github.com/fa11up/infer-protocol |
| Commit | `8756817e66e973b05ea08e2aa99ee627de225d09` |
| Job | `43b96259-a6e0-4bca-b04f-0308a7943b8b` |
| Judged | 2026-10-07 21:59 UTC |
| Findings | 1 high · 4 medium · 1 low · 3 info |

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

## Findings

### 1. High: CDPVault._clampLag nets the lag against AGGREGATE start figures, so cancelling another borrower's warm principal (cash, bite or cover) and drawing the same amount in one transaction transfers its warm

`src/CDPVault.sol:894`

```
        laggedDebt = totalDebt < startDebt ? totalDebt : startDebt;
        laggedSecured = securedCollateral < startSecured ? securedCollateral : startSecured;
```

Q1, breaking the newest fix (8756817, 'the lag netted per transaction'). _clampLag now sets laggedDebt = min(totalDebt, startDebt) and laggedSecured = min(securedCollateral, startSecured), where start* is the lagged figure the transaction FIRST found (_lagAtStart, transient). The record is of the aggregate, not of the position whose capital moved, so any transaction in which totalDebt and securedCollateral end where they began leaves the lag untouched whoever's capital left and whoever's arrived. Before 8756817 the step-by-step clamp dropped laggedDebt to 0 at the cancellation and the attacker's redraw warmed from zero over BACKING_WARMUP, which is exactly what closed D1 (launch audit 2026-10-05, vault panel, medium). The fix reopens it. Call sequence (a contract, one transaction): cash(D, 0, HONEST) against any position inside the redeemable band (CR < mat + gap = 220%), paying only the redemption fee (0.5% to 5%) and receiving the candidate's collateral at backing less the fee; then lock(C); then draw(D). Inside the cash, _reduceDebt -> _resecure -> _clampLag records startDebt = D (warm) and startSecured; the cancellation lowers both to about 0; the draw raises totalDebt back to D and _clampLag sets laggedDebt = min(D, startDebt) = D and laggedSecured = min(C, startSecured). The same swap works through bite (paid the 20% bonus to do it) and through cover (the Treasury's imdUSD pays, nothing for the caller). Next transaction: backedDebt() = min(totalDebt, debtAtTxStart, laggedNow) - bad = D, earnLine() = reserve + 25% of D, earn mints; a third transaction wipes and frees. The whole round trip also fits in ONE transaction: ParameterizedVault._debtChanged records the total BEFORE the first change, i.e. the honest D the cash cancels, so _debtAtTransactionStart() = D and the finding-4d30331c cap passes too (cash, lock, draw, earn, wipe, free in one call). The redemption half is lifted the same way: _backingPerUnit's lagged figure reads min(held, lagSecured) and min(prior, lagDebt), both left where they were, so the attacker's fresh collateral reads as warm backing for a reserve redemption at par in the next transaction. Who loses: every imdUSD holder (work-minted supply with nothing behind it once the attacker unwinds: in the proof totalEarned 250e18 against totalDebt 0.33e18 and backing 0.0013e18), and for the redemption half the remaining holders. Cost: the 5% redemption fee on D when done through cash (less in smaller tranches as the base decays), the bonus is EARNED through bite, nothing through cover. Reachable with the constants as committed once governance has applied a wage (48-hour proposal; the compute channel is the lag's stated purpose); at WAGE_WAD 0 earn is refused (WorkMintingOff) and only the redemption half is reachable. NatSpec the code does not have: lines 884-890 ('capital that leaves and comes back inside one transaction ... leaves the lag where it was' is true, but so does DIFFERENT capital), lines 302-304 ('capital brought in one transaction and withdrawn a few later cannot authorise work minting'), ParameterizedVault 241-244 ('the ratio term is only ever backed by positions that existed before the caller arrived'). Smallest fix: net per POSITION, not per transaction. In _reduceDebt / _resecure record, transiently keyed by the position (a transient mapping slot derived from the owner), the amount by which THAT position's own decrease lowered laggedDebt and laggedSecured in this transaction; on the SAME position's later increase within the transaction restore min(increase, its own recorded decrease) to the lagged figure (bounded by the live figure); clamp every other change step by step as before (laggedDebt = min(laggedDebt, totalDebt) after each change). Then the attacker's draw restores nothing (its own decrease was zero) and a borrower's own wipe-and-redraw still nets. Merged from audit_permissions 351ba7cc.

**Reproduction**

test/scratch/NettingTransfersWarmth.t.sol (attached as proof; both tests fail on this code). ParameterizedVault over an 18-decimal MockIMD at $1 (IMD/ETH 1/2000 x Chainlink ETH/USD 2000e8 etched at CHAINLINK_ETH_USD), NHI 0.85 (mat 170, gap 50), wage 0.01 applied through Parameters.proposeWage + TIMELOCK + applyPending; TreasuryFactory etched at TREASURY_FACTORY. HONEST locks 2,000 IMD, draws 1,000 imdUSD (200%, inside the redeemable band) and transfers the 1,000 imdUSD to the attacker contract, which holds 1,800 IMD and 1,000 rights from MockWorkOracle.grantRights; three days pass: laggedNow() debt == 1,000e18, earnLine() == 250e18. Test 1: attacker.swap(1000e18, HONEST, 1800e18, 1000e18) = cash + lock + draw in ONE transaction. Afterwards HONEST's debt is under 1 imdUSD (fee residue) and the attacker's principal is 1,000e18, zero seconds old. Next transaction: EXPECTED laggedNow() debt about 0.33e18 (the residue), earnLine() under 1e18, attacker.earn(250e18) reverts WorkCeilingReached, totalEarned 0. ACTUAL (logged): laggedDebt 1000000000000000000000, earnLine 250000000000000000000, earn(250e18) succeeds ('next call did not revert as expected'). Test 2: attacker.roundTrip(...) = cash, lock, draw, earn(250e18), wipe(debtOf), free(1800e18) in ONE transaction. EXPECTED: the earn reverts WorkCeilingReached inside the call. ACTUAL: the call succeeds; totalEarned() == 250e18 with the attacker's debt 0 and collateral withdrawn, totalDebt about 0.33e18, supply about 250.33e18.

### 2. Medium: cover's recorded-bad-debt sweep (8756817) takes a re-collateralised borrower's WHOLE collateral for any caller-chosen `amount`, as little as one wei of debt, crediting nothing and skipping mark, grace

`src/CDPVault.sol:558`

```
                    || (recorded != 0 && Math.mulDiv(position.collateral, price, 1e18) < recorded);
```

Q3. The new second clause of `sweepable` sweeps, on a position with _recordedBadDebt != 0, any collateral worth less than that record at the fresh price: position.collateral is zeroed, all of it goes to the Treasury (gem.safeTransfer(payer, dust)), and the position's debt then falls only by `amount`, which is required to be nonzero and at most the debt, burned from the Treasury's imdUSD. Nothing credits the swept value against the debt it stood behind, and the branch does not require `amount` to retire anything like it. _recordedBadDebt is written to debtOf at drain and to min(previous, debtOf) at repayment; adding collateral never lowers it, and the totalBadDebt NatSpec (286-291) says a drained borrower who re-collateralises and keeps a healthy loan open is an accepted state. So every once-drained borrower carries the exposure for the life of the loan: whenever the market puts its collateral below the old record (about a 45% fall from a 200% re-lock, or simply re-locking in two tranches), anyone's cover(owner, 1) strips it. Every other underwater position gets bark, the NHI grace (six hours at NHI >= 0.85) and a bite that seizes exactly 1.2x the debt it repays and retires that debt; this path seizes everything, retires one wei, and the borrower still owes the whole record. The caller gains nothing directly (the collateral lands in the surplus account), so this is griefing or a mis-sized honest cover, but the operator's own keeper calls cover to retire realized bad debt in the ordinary course and will trigger it. Reachable with the constants as committed, no governance. It also contradicts the function NatSpec at 531-532 ('Reverts on a position holding collateral a bite could still reach (that is not realized bad debt)'): $262 of collateral against a $291.7 record is reachable by a bite of 218 imdUSD. Smallest fix: in the sweep branch credit the swept value before burning `amount`: cancel min(debt, mulDiv(dust, price_, 1e18)) of the position's debt through _reduceDebt (fees first; record, totalBadDebt and totalDebt move together) so the sweep is a repayment in kind at `price`, and only then burn `amount` from the Treasury; or require `amount >= min(mulDiv(dust, price_, 1e18), debtOf(owner))` so a sweep is a one-for-one liquidation the surplus funds; or drop the recorded-bad-debt clause and keep the NatSpec's rule. Reword 531-532 either way. Merged from audit_economics 05796c11, audit_math 9282f397 and audit_flow 96f4d5b6 (the same clause, one as a two-tranche re-lock losing the first tranche).

**Reproduction**

test/scratch/Proof_05796c11d426.t.sol (attached; fails on this code). ParameterizedVault over an 18-decimal IMD at $1, NHI 0.85 (mat 170, lull 6 h). BORROWER locks 1,700 and draws 1,000 (exactly mat); KEEPER locks 20,000 and draws 5,000. Price to $0.50; bark(BORROWER); +6 h; KEEPER bites floor(1,700 x 0.5 / 1.2) = 708.33 imdUSD: collateral 0, recorded bad debt R = 291.7e18. Price back to $1; the Treasury holds 10 imdUSD. BORROWER re-locks 2R = 583.4 IMD (CR about 200%, healthy); cover(BORROWER, 1) reverts NoRealizedBadDebt as intended. Price to $0.45: collateral worth 262.5 < R. STRANGER calls cover(BORROWER, 1). EXPECTED: refused (a bite could reach it at 1.2x), or collateral leaves only against debt retired at no worse than the bite formula. ACTUAL: positions(BORROWER).collateral == 0, 583.4 IMD ($262.5) in the Treasury, debtOf(BORROWER) fell by exactly 1 wei: assertion '262527369863013698100 > 2'. Second variant (audit_math, same clause, read and consistent with the proof): price back to $1, BORROWER lock(160e18) as the first tranche of a rebuild worth $160 < the $166.7 record; cover(BORROWER, 1) moves all 160 IMD to the Treasury for 1 wei of debt.

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

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

import {Test} from "forge-std/Test.sol";
import {ParameterizedVault} from "src/ParameterizedVault.sol";
import {CDPVault} from "src/CDPVault.sol";
import {ImdUSD} from "src/ImdUSD.sol";
import {MockIMD} from "src/MockIMD.sol";
import {TreasuryFactory} from "src/TreasuryFactory.sol";
import {ISwarmFeed} from "src/interfaces/ISwarmFeed.sol";
import {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from "src/DeploymentConfig.sol";

contract SweepFeed is ISwarmFeed {
    uint256 public constant maxAge = 1 days;
    uint256 private value;
    uint64 private updatedAt;
    bool private stale;

    constructor(uint256 v) {
        value = v;
        updatedAt = uint64(block.timestamp);
    }

    function set(uint256 v) external {
        value = v;
        updatedAt = uint64(block.timestamp);
    }

    function setStale(bool s) external {
        stale = s;
    }

    function latestValue() external view returns (uint256, uint64) {
        return (value, updatedAt);
    }

    function isStale() external view returns (bool) {
        return stale;
    }
}

contract SweepMirror is ISwarmFeed {
    ISwarmFeed private immutable primary;

    constructor(ISwarmFeed p) {
        primary = p;
    }

    function latestValue() external view returns (uint256, uint64) {
        return primary.latestValue();
    }

    function isStale() external view returns (bool) {
        return primary.isStale();
    }

    function maxAge() external view returns (uint256) {
        return primary.maxAge();
    }
}

contract SweepAggregator {
    function decimals() external pure returns (uint8) {
        return 8;
    }

    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {
        return (1, 2000e8, block.timestamp, block.timestamp, 1);
    }
}

/// @notice `cover`'s bad-debt sweep (CDPVault.cover, the `recorded != 0 && value < recorded` branch) takes
/// a re-collateralised borrower's WHOLE collateral for whatever `amount` the caller names, as little as
/// one wei of debt, skipping the mark, the grace and the 120% seizure formula every other underwater
/// position gets.
contract CoverSweepStripsTest is Test {
    address private constant BORROWER = address(0xB0);
    address private constant KEEPER = address(0xCA);
    address private constant STRANGER = address(0x57);

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    SweepFeed private primary;

    /// 1 IMD = 1/2000 ETH and 1 ETH = $2000: the vault prices IMD at exactly $1 per 1e18 raw units.
    uint256 private constant ONE_DOLLAR = uint256(1 ether) * 1e18 / 2000 ether;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new SweepAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new SweepFeed(ONE_DOLLAR);
        SweepFeed health = new SweepFeed(0.85 ether);
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new SweepMirror(primary))
        );
        stable = vault.stablecoin();
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(BORROWER, 10_000 ether);
        imd.mint(KEEPER, 100_000 ether);
        vm.stopPrank();
        vm.prank(BORROWER);
        imd.approve(address(vault), type(uint256).max);
        vm.prank(KEEPER);
        imd.approve(address(vault), type(uint256).max);
    }

    function _setDollars(uint256 usdPerImd) private {
        primary.set(ONE_DOLLAR * usdPerImd / 1 ether);
    }

    /// @dev BORROWER at exactly mat (1700 / 1000) is crashed to $0.50, marked, and bitten for everything its
    /// collateral covers; the rest of its debt is realized bad debt.
    function _drain() private returns (uint256 bad) {
        vm.startPrank(BORROWER);
        vault.lock(1_700 ether);
        vault.draw(1_000 ether);
        vm.stopPrank();
        vm.startPrank(KEEPER);
        vault.lock(20_000 ether);
        vault.draw(5_000 ether);
        vm.stopPrank();
        _setDollars(0.5 ether);
        vault.bark(BORROWER);
        vm.warp(block.timestamp + 6 hours);
        _setDollars(0.5 ether);
        uint256 repayable = uint256(1_700 ether) * 0.5 ether / 1.2e18;
        vm.prank(KEEPER);
        vault.bite(BORROWER, repayable);
        (uint256 held,) = vault.positions(BORROWER);
        assertEq(held, 0, "drained");
        bad = vault.totalBadDebt();
        assertGt(bad, 0, "realized");
        _setDollars(1 ether);
    }

    function test_coverSweepStripsAReCollateralisedBorrowerForOneWei() public {
        uint256 bad = _drain();
        // The Treasury holds imdUSD (the fees the bite reminted to it, topped up by the keeper).
        address treasury = address(vault.treasury());
        vm.prank(KEEPER);
        stable.transfer(treasury, 10 ether);

        // The borrower re-collateralises to a healthy 200%+ loan, as the totalBadDebt NatSpec says it may.
        uint256 relock = bad * 2;
        vm.prank(BORROWER);
        vault.lock(relock);
        assertGe(vault.collateralRatio(BORROWER), 170, "healthy again");
        vm.expectRevert(CDPVault.NoRealizedBadDebt.selector);
        vault.cover(BORROWER, 1);

        // The market falls 55%: collateral worth 0.9 x the recorded bad debt. Any other underwater position
        // would now need a mark, up to six hours of grace, and a bite that seizes 1.2 x the debt it repays.
        _setDollars(0.45 ether);
        uint256 debtBefore = vault.debtOf(BORROWER);
        uint256 valueBefore = relock * 0.45 ether / 1e18;
        assertLt(valueBefore, bad, "worth less than the recorded bad debt");

        vm.prank(STRANGER);
        (bool ok,) = address(vault).call(abi.encodeCall(CDPVault.cover, (BORROWER, 1)));
        if (!ok) return; // a cover that refuses leaves the borrower on the liquidation path: fine

        (uint256 heldAfter,) = vault.positions(BORROWER);
        uint256 debtAfter = vault.debtOf(BORROWER);
        uint256 cancelled = debtBefore - debtAfter;
        uint256 taken = relock - heldAfter;
        // EXPECTED: collateral leaves the borrower only against debt it retires, at no worse than the bite
        // formula (1.2 x the debt repaid, at the same price). ACTUAL: all 583 IMD (worth 262 imdUSD) go to
        // the Treasury and the borrower's debt falls by one wei.
        assertLe(
            taken * 0.45 ether / 1e18,
            cancelled * 12 / 10 + 1,
            "collateral swept by cover must be credited against the debt it stood behind"
        );
    }
}
```

### 3. Medium: CDPVault._backingPerUnit: imdUSD burned earlier in the same transaction is not added back to the supply (or to the prior debt), so a same-call wipe / cash / draw by a borrower in the 170-200% band is

`src/CDPVault.sol:708`

```
        uint256 supply = stablecoin.totalSupply();
```

Q1/Q6/Q7. The transient tallies net out capital that ARRIVES inside a transaction (SECURED_THIS_TX_SLOT, MINTED_THIS_TX_SLOT), and since 8756817 _clampLag leaves the lag where it was when debt leaves and returns inside one transaction. Nothing nets out imdUSD BURNED inside the transaction: _backingPerUnit divides by the live stablecoin.totalSupply(), and _securedCollateralValue caps at mat x the live totalDebt less this transaction's mints. A borrower whose collateral ratio is in [170%, 200%) has a secured term equal to its whole collateral (min(collateral, 2 x principal / price) binds on the collateral), so it can repay up to principal - collateral x price / 2 (15% of its principal at 170%) WITHOUT its term moving: the numerator holds while the denominator falls by the repayment. In the same call it redeems at that inflated figure, then draws the repayment back. Debt, supply and every position end where they began; the redeemer was paid above the honest pro-rata figure; and because _clampLag restores laggedDebt to the transaction's starting level on the redraw, the churn costs nothing afterwards (before 8756817 it left the lag depressed for a day). The boost is supply / (supply - repaid), up to about 7% in the regime where it applies (backing below par, which needs underwater debt of at least about 1.4x the churner's), a transfer from every other imdUSD holder to the redeemer, repeatable every transaction while the regime lasts, for gas plus briefly holding imdUSD equal to the repayment. Reachable with the constants as committed at any wage, no work supply needed. NatSpec the code does not have: lines 229-234 ('capital which exists only for the length of the call can neither inflate the backing the guard measures nor dilute the supply the fee is measured against') and the cash() comment at 651 ('Paying pro-rata instead is exactly neutral on backing by construction'). Smallest fix: tally principal repaid in the transaction in a transient slot (BURNED_THIS_TX_SLOT, added in _payDebt, cover's burn and cash's burn, or in _reduceDebt for the principal part) and add it back to `supply` in _backingPerUnit and to `prior` in _securedCollateralValue (live and lagged), so a repayment counts only once it has outlived the transaction, symmetric with how a draw is excluded. The same slot fixes the _redemptionRate finding below. From audit_flow cbe35dcb.

**Reproduction**

test/scratch/Proof_cbe35dcb7f1b.t.sol (attached; fails on this code). ParameterizedVault over an 18-decimal IMD at $1, NHI 0.85 (mat 170, candidates below 220%). A contract borrower locks 5,780 IMD and draws 1,000; OTHER locks 5,100, draws 3,000 and hands the borrower its 3,000 imdUSD. Price falls to $0.294: borrower at 170% (secured term = its whole 5,780), OTHER at 50%. Both touched at the new price and warmed three days: backingPerUnit() = 0.79968e18. EXPECTED: cash(500e18, 0, borrower) pays the same whether or not the borrower repays and redraws inside the same call, since debt and supply are identical before and after: 1,292.0 IMD. ACTUAL: cash alone pays 1292000000000000000000 raw; wipe(150e18) + cash(500e18, 0, borrower) + draw(150e18) in ONE transaction pays 1342083237164646386054 raw (+3.9%): inside the call supply fell 4,000 -> 3,850 while the collateral term held at 3,198, so backing read 3,198 / 3,850 = 0.8306 instead of 0.7997. Assertion: '1342083237164646386054 > 1292000000000000000000'. Afterwards backingPerUnit() for everyone else is 0.7144e18 instead of the pro-rata-neutral 0.7997e18.

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

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

import {Test} from "forge-std/Test.sol";
import {ParameterizedVault} from "src/ParameterizedVault.sol";
import {ImdUSD} from "src/ImdUSD.sol";
import {MockIMD} from "src/MockIMD.sol";
import {TreasuryFactory} from "src/TreasuryFactory.sol";
import {ISwarmFeed} from "src/interfaces/ISwarmFeed.sol";
import {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from "src/DeploymentConfig.sol";

contract ProofFeed is ISwarmFeed {
    uint256 public constant maxAge = 1 days;
    uint256 private value;
    uint64 private updatedAt;

    constructor(uint256 v) {
        set(v);
    }

    function set(uint256 v) public {
        value = v;
        updatedAt = uint64(block.timestamp);
    }

    function latestValue() external view returns (uint256, uint64) {
        return (value, updatedAt);
    }

    function isStale() external pure returns (bool) {
        return false;
    }
}

contract ProofMirror is ISwarmFeed {
    ISwarmFeed private immutable p;

    constructor(ISwarmFeed p_) {
        p = p_;
    }

    function latestValue() external view returns (uint256, uint64) {
        return p.latestValue();
    }

    function isStale() external view returns (bool) {
        return p.isStale();
    }

    function maxAge() external view returns (uint256) {
        return p.maxAge();
    }
}

/// @dev ETH/USD = $2000, always fresh.
contract ProofAggregator {
    function decimals() external pure returns (uint8) {
        return 8;
    }

    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {
        return (1, 2000e8, block.timestamp, block.timestamp, 1);
    }
}

/// @dev A borrower that is a contract, so a wipe, a redemption and a redraw can share one transaction.
contract Borrower {
    ParameterizedVault private immutable vault;

    constructor(ParameterizedVault vault_, MockIMD imd) {
        vault = vault_;
        imd.approve(address(vault_), type(uint256).max);
    }

    function lock(uint256 amount) external {
        vault.lock(amount);
    }

    function draw(uint256 amount) external {
        vault.draw(amount);
    }

    function cash(uint256 amount, address candidate) external returns (uint256) {
        return vault.cash(amount, 0, candidate);
    }

    /// Repay for the length of the call, redeem against the shrunken supply, borrow it back.
    function wipeCashRedraw(uint256 repaid, uint256 burned, address candidate) external returns (uint256) {
        vault.wipe(repaid);
        uint256 out = vault.cash(burned, 0, candidate);
        vault.draw(repaid);
        return out;
    }
}

/// @notice A repayment that exists only for the length of the call inflates the backing a redemption
/// in the same call is paid against. CDPVault nets out same-transaction DEPOSITS and MINTS
/// (SECURED_THIS_TX_SLOT, MINTED_THIS_TX_SLOT) and, since 8756817, leaves the lag where it was when
/// debt leaves and returns inside one transaction (_clampLag); but imdUSD BURNED in the transaction
/// still leaves `stablecoin.totalSupply()`, the denominator of _backingPerUnit. A borrower in the
/// 170-200% band repays up to (debt - collateral*price/2) without moving its secured term, so the
/// numerator holds while the denominator falls, and the redemption it then takes is paid above the
/// honest pro-rata figure. The redraw restores debt and supply, and the lag netting means it costs
/// nothing afterwards either. Fails on the committed code; passes once same-transaction repayment is
/// added back to the supply (and prior debt) the backing is measured against.
contract Proof_SameTxWipeInflatesRedemption is Test {
    address private constant OTHER = address(0xB1);
    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    ProofFeed private primary;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new ProofAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new ProofFeed(uint256(1 ether) * 1e18 / 2000 ether); // $1 per IMD
        ProofFeed health = new ProofFeed(0.85 ether); // mat 170, gap 50: candidates below 220%
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new ProofMirror(primary))
        );
        stable = vault.stablecoin();
        vm.prank(APPROVED_OPERATOR);
        imd.mint(OTHER, 10_000 ether);
        vm.prank(OTHER);
        imd.approve(address(vault), type(uint256).max);
    }

    function _priceUsd(uint256 usd) private {
        primary.set(usd * 1e18 / 2000 ether);
    }

    function test_aSameTransactionRepaymentMustNotInflateTheRedemptionPayout() public {
        Borrower borrower = new Borrower(vault, imd);
        vm.prank(APPROVED_OPERATOR);
        imd.mint(address(borrower), 10_000 ether);
        // The borrower at 578%, another position at 170%; the other hands the borrower its imdUSD.
        borrower.lock(5_780 ether);
        borrower.draw(1_000 ether);
        vm.startPrank(OTHER);
        vault.lock(5_100 ether);
        vault.draw(3_000 ether);
        stable.transfer(address(borrower), 3_000 ether);
        vm.stopPrank();
        vm.warp(block.timestamp + 3 days);
        // A crash to $0.294: the borrower sits at 170% (redeemable, healthy), the other at 50%.
        _priceUsd(0.294 ether);
        borrower.lock(1);
        vm.prank(OTHER);
        vault.lock(1);
        vm.warp(block.timestamp + 3 days); // everything warm
        borrower.lock(1);
        uint256 backing = vault.backingPerUnit();
        assertLt(backing, 1e18, "the regime: backing below par");

        uint256 snapshot = vm.snapshotState();
        uint256 honest = borrower.cash(500 ether, address(borrower));
        vm.revertToState(snapshot);

        // Same end state for debt and supply, but the redemption inside the call is paid against a
        // supply shrunk by the 150 imdUSD repaid for the length of the call.
        uint256 boosted = borrower.wipeCashRedraw(150 ether, 500 ether, address(borrower));
        emit log_named_uint("honest payout (IMD)", honest);
        emit log_named_uint("payout with a same-call wipe and redraw (IMD)", boosted);
        assertLe(boosted, honest, "a repayment that lasts only for the call must not inflate the payout");
    }
}
```

### 4. Medium: _redemptionRate nets supply MINTED this transaction out of the fee base but not supply BURNED, so a dominant borrower's wipe / cash / draw pins the redemption fee at the 5% cap for a tenth of the hone

`src/CDPVault.sol:831`

```
        uint256 prior = supply > minted ? supply - minted : 0;
```

Q6, the fee base. The increase a burn adds to redemptionBaseRate is amount / prior / divisor with prior = totalSupply() - (principal and work minted this transaction). A burn earlier in the same transaction lowers totalSupply() and is not added back, so `prior` is the post-burn supply, not 'the supply that existed before this transaction' as the NatSpec at 819 states. A borrower holding share s of the supply as its own debt wipes it (burning its imdUSD), redeems a small amount against the shrunken supply, and draws the principal back, in one transaction: the base rate is set as if the burn were 1/(1-s) times larger. Reaching the 4.5% cap honestly costs a burn of 9% of supply at the 5% fee (0.45% of supply lost to the fee); with s = 90% it costs 0.9% of supply at the same fee, ten times less, and the pinned base decays with a twelve-hour half-life, so the pump is repeated twice a day. The redemption can be reserve-funded (freshCancelled == 0, so the base stands whole) or against any seasoned third-party position; the borrower's own position is unchanged afterwards. Victims: every later redeemer pays up to 500 bps instead of 50 for the next half-life or two, and the peg floor min(1 - fee, backing) the cash() comment promises sits at 0.95 on demand, which blunts the arbitrage that defends the peg; a candidate borrower can use it to deter redemptions against itself. Reachable with the constants as committed (divisor 2, wage 0), no governance; needs a borrower whose debt is a large share of supply (LINE is $1M at launch, so one large position suffices). Smallest fix: track burns in a transient slot (BURNED_THIS_TX_SLOT, added in _payDebt, cover's burn and cash's burn) and measure prior = supply + burned - minted, mirroring the existing minted netting; the same slot serves the _backingPerUnit finding above. From audit_math 8dd9e22f.

**Reproduction**

test/scratch/Proof_8dd9e22f56eb.t.sol (attached; fails on this code). ParameterizedVault at $1, launch constants (divisor 2, wage 0). A contract borrower locks 2,000 and draws 900; OTHER locks 200, draws 100 and gives the borrower 9 imdUSD; the Treasury holds 100 IMD so the redemption is reserve-funded; supply 1,000, redemptionBaseRate 0, redemptionFeeBps(9e18) quotes 95 (floor 50 + 9/1000/2 = 45 bps). The borrower calls wipe(900e18), cash(9e18, 0, address(0)), draw(900e18) in one transaction. EXPECTED: redemptionBaseRate == 0.0045e18 (45 bps, the increase for a 9-of-1,000 burn). ACTUAL: 0.045e18, the cap: assertion '45000000000000000 > 4500000000000001'; redemptionFeeBps(0) reads 500 for everyone; the borrower's position is 2,000 / 900 again and the pump cost 0.45 imdUSD of fee.

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

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

import {Test} from "forge-std/Test.sol";
import {ParameterizedVault} from "src/ParameterizedVault.sol";
import {CDPVault} from "src/CDPVault.sol";
import {ImdUSD} from "src/ImdUSD.sol";
import {MockIMD} from "src/MockIMD.sol";
import {TreasuryFactory} from "src/TreasuryFactory.sol";
import {ISwarmFeed} from "src/interfaces/ISwarmFeed.sol";
import {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from "src/DeploymentConfig.sol";

contract FeeFeed is ISwarmFeed {
    uint256 public constant maxAge = 1 days;
    uint256 private value;
    uint64 private updatedAt;

    constructor(uint256 v) {
        value = v;
        updatedAt = uint64(block.timestamp);
    }

    function latestValue() external view returns (uint256, uint64) {
        return (value, updatedAt);
    }

    function isStale() external pure returns (bool) {
        return false;
    }
}

contract FeeMirror is ISwarmFeed {
    ISwarmFeed private immutable primary;

    constructor(ISwarmFeed p) {
        primary = p;
    }

    function latestValue() external view returns (uint256, uint64) {
        return primary.latestValue();
    }

    function isStale() external view returns (bool) {
        return primary.isStale();
    }

    function maxAge() external view returns (uint256) {
        return primary.maxAge();
    }
}

contract FeeAggregator {
    function decimals() external pure returns (uint8) {
        return 8;
    }

    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {
        return (1, 2000e8, block.timestamp, block.timestamp, 1);
    }
}

/// @dev A dominant borrower that burns its own principal, redeems against the shrunken supply and
/// draws the principal back, in one transaction.
contract Pumper {
    ParameterizedVault private immutable vault;

    constructor(ParameterizedVault vault_, MockIMD imd) {
        vault = vault_;
        imd.approve(address(vault_), type(uint256).max);
    }

    function open(uint256 collateral, uint256 debt) external {
        vault.lock(collateral);
        vault.draw(debt);
    }

    function pump(uint256 principal, uint256 redeemed) external {
        vault.wipe(principal);
        vault.cash(redeemed, 0, address(0));
        vault.draw(principal);
    }
}

/// @notice Q6: `_redemptionRate` nets supply MINTED this transaction out of the fee base (finding
/// fcd5b261) but not supply BURNED this transaction. A borrower holding most of the supply as debt
/// wipes it, redeems a small amount against the shrunken supply, and draws the debt back: the base
/// rate everyone pays afterwards is pinned at the cap for a tenth of the honest cost.
contract RedemptionFeeBaseBurnTest is Test {
    address private constant OTHER = address(0x07);

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    Pumper private pumper;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new FeeAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        FeeFeed primary = new FeeFeed(uint256(1 ether) * 1e18 / 2000 ether);
        FeeFeed health = new FeeFeed(0.85 ether);
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new FeeMirror(primary))
        );
        stable = vault.stablecoin();
        pumper = new Pumper(vault, imd);
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(address(pumper), 2_000 ether);
        imd.mint(OTHER, 200 ether);
        imd.mint(address(vault.treasury()), 100 ether); // a reserve, so the redemption is reserve-funded
        vm.stopPrank();
        vm.prank(OTHER);
        imd.approve(address(vault), type(uint256).max);
    }

    function test_burningSupplyInTheSameTransactionShrinksTheFeeBase() public {
        pumper.open(2_000 ether, 900 ether);
        vm.startPrank(OTHER);
        vault.lock(200 ether);
        vault.draw(100 ether);
        stable.transfer(address(pumper), 9 ether);
        vm.stopPrank();
        assertEq(stable.totalSupply(), 1_000 ether);
        assertEq(vault.redemptionBaseRate(), 0);

        // The honest increase for burning 9 imdUSD of a 1,000 supply at divisor 2: 9 / 1000 / 2 = 0.45%.
        uint256 honest = vault.redemptionFeeBps(9 ether);
        assertEq(honest, 50 + 45, "quoted: floor 50 bps plus 45");

        // Wipe 900, cash 9 against the 100 that remain, draw 900 back. The 9 is charged
        // 9 / 100 / 2 = 4.5%: the cap, for everyone, until it decays (half-life twelve hours).
        pumper.pump(900 ether, 9 ether);
        (, uint256 debt) = vault.positions(address(pumper));
        assertEq(debt, 900 ether, "the pumper's position is unchanged");
        assertEq(stable.totalSupply(), 991 ether);

        // Expected (NatSpec of _redemptionRate: "the burned fraction of the supply that existed before
        // this transaction"): 0.45% -> base rate 0.0045e18. Actual: 0.045e18, the cap.
        assertLe(vault.redemptionBaseRate(), 0.0045e18 + 1, "the base rate must be measured against the pre-transaction supply");
        // Cost comparison on the committed code: without the trick, reaching the cap takes a burn of 9% of
        // supply (90 imdUSD at the 5% fee: 4.5 imdUSD lost); with it, 9 imdUSD at the same fee: 0.45.
        // Afterwards redemptionFeeBps(0) reads 500 for every later redeemer until the base decays.
    }
}
```

### 5. Medium: The per-transaction netting of _clampLag lives in transient storage, so a borrower's wipe in one transaction and draw in the next (same block) still clamps laggedDebt and laggedSecured at once: with w

`src/CDPVault.sol:905`

```
            tstore(slot, add(current, 1))
```

Q1, second half: the gap the 8756817 fix for the final panel's medium leaves open, reported because the question asks what it costs and blocks. _lagAtStart records the transaction's starting lagged figures in transient storage, which the EVM clears at the end of each transaction, so 'a decrease that lasted' (NatSpec 889-890) includes a decrease that lasted zero seconds: the same sender's wipe(debtOf) as transaction N and draw(same) as transaction N+1 of one block (consecutive nonces from one key, or a bundle) clamp laggedDebt and laggedSecured to the post-wipe level, and transaction N+1 records that clamped level as ITS start, so the redraw warms from there over about a day (exponentially longer under activity, 297-302). Nothing elapses between the two: no stability fee, no price exposure, no attestation purchase, and the borrower already holds the imdUSD it drew. Effect with work-minted supply E outstanding (wage nonzero, the state the lag exists for): a borrower holding share s of the debt D leaves the lagged backing at (reserve + 1.7(1-s)D) / ((1-s)D + E); for the sole borrower with no reserve that is 0, so `cash` reverts ZeroAmount for every redeemer, a redeemer with minGemOut set is refused, and earnLine() falls with it (262.5 -> 12.5 in audit_economics' run); recovery is 0.24 after one quiet hour, par after a quiet day, and the churn is repeatable every block. At launch (wage 0, E = 0) the debt side cancels (supply <= fresh skips the lagged figure, or the lagged denominator is the other borrowers' own debt), but the collateral side still binds after a price fall that puts the collateral term in charge: a healthy surplus holder's free(x) then lock(x) in two transactions leaves laggedSecured at the lower level for a day, 1.00 -> 0.90 in the reproduction, and every redeemer is paid against it; a churner that is itself a candidate has its debt cancelled for less collateral per imdUSD. Cost: two transactions of gas, fresh agreeing feeds for the draw / free, and health at the redraw, which the position already had. Who loses: redeemers (closed or underpaid) and rights holders refused by the ceiling; the peg floor the cash() comment at 659-660 presents as min(1 - fee, backing) does not hold against the clamped figure actually paid. Reachable with the constants as committed for the collateral-side variant (stressed state); with a governed wage for the zero-backing variant. Smallest fix that keeps 'a decrease by someone else counts at once': the per-position record from the high finding above, kept in STORAGE for BACKING_WARMUP rather than in transient storage: record per position the lagged amounts its own decrease clamped (coolingDebt, coolingSecured, cooledAt); when the same position's principal or term rises again within BACKING_WARMUP of cooledAt, add min(increase, cooling) directly to laggedDebt / laggedSecured (bounded by the live figures) instead of routing it through _approach. Netting per block alone is not enough: the same two calls one block apart cost twelve seconds. Alternatively accept it and say so where the peg floor is claimed, and have the keeper (docs/MAINNET-RUNBOOK.md) watch laggedSecured. Merged from audit_math 51b9c8ee, audit_permissions f01a46e2, audit_flow 115c2e9d and audit_economics 655cdf78.

**Reproduction**

.imd/reads/proofs/Proof_51b9c8eeb598.t.sol, run here from test/scratch/ (not attached: the four proof slots go to the findings above; both of its tests fail on this code, each top-level call being its own transaction under foundry.toml isolate = true, with no warp between the two halves). ParameterizedVault over an 18-decimal IMD at $1, NHI 0.85, wage 0.01 applied through Parameters after the 48 h timelock. BORROWER locks 2,000 IMD and draws 1,000 imdUSD; a day later WORKER earns 250 imdUSD (the ceiling) and hands 10 to the borrower for fees; a day later backingPerUnit() == 1e18, laggedNow() == (1,000e18, 2,000e18). BORROWER calls wipe(debtOf) [tx 1] then draw(1,000e18) [tx 2, same block]. Position afterwards: 2,000 collateral, 1,000 principal; the churn cost under 0.3 imdUSD of fees. EXPECTED: backingPerUnit() == 1e18 and REDEEMER's cash(10e18, 0, BORROWER) pays about 9.91 IMD. ACTUAL: laggedDebt == 0, laggedSecured == 0, backingPerUnit() == 0 ('an adjacent-transaction round trip must not move backing: 0 != 1000000000000000000'), and cash reverts ZeroAmount(). Launch-constant variant (audit_permissions, wage 0, read and consistent with the code): WORKER 2,000 / 1,000, OTHER 38,000 / 1,000, IMD to $0.05, both terms re-priced by lock(1), a day warm: backingPerUnit 1e18; OTHER sends free(3,990e18) then lock(3,990e18) as two transactions in one block: laggedSecured 36,010e18 + 2 against securedCollateral 40,000e18 + 2 and backingPerUnit 0.90025e18 for the next day.

### 6. Low: _resecure keeps a position's whole previous term through an ungated wipe while the price is unreadable, so a dead ETH/USD or share leg plus a repayment overstates securedCollateral (a term sized for t

`src/CDPVault.sol:865`

```
            ? (position.debt == 0 ? 0 : Math.min(before, position.collateral))
```

Q4, breaking the 8756817 fix (final panel, oracle, low). The fix keeps min(before, collateral) when _priceOrZero() reads 0 (ParameterizedVault: a reverting, non-positive or malformed Chainlink ETH/USD answer, or a share vault that stops answering convertToAssets; a merely stale answer still prices). `lock` keeping `before` is conservative (collateral only rose), but `wipe` is ungated too and lowers the principal while the term stays: the term is min(collateral, 2 x principal / price), bounded by PRINCIPAL as well as collateral, and a position with 2,000 collateral and 1,000 principal at $0.40 (term 2,000) that repays 999 during the outage keeps a term of 2,000 where a priced checkpoint gives min(2,000, 2 x 1.5 / 0.40) = about 7.4. The securedCollateral NatSpec at 250-251 no longer holds. The overstatement persists after the leg recovers: only that position's own lock/free/draw/wipe, or a redemption or bite against it, re-prices the term, and a healthy position with one wei of principal is refused by bite, cash-as-candidate and cover, so nobody else can force a checkpoint and the owner (who may also be the redeemer) has every reason not to. It feeds both the live and the lagged side of _backingPerUnit (securedCollateral never fell, so _clampLag had nothing to clamp). It is hidden while the aggregate cap mat x prior binds and matters exactly when honest backing is below par (a price fall after the outage): the reserve-funded part of cash then pays at par instead of at backing (the position-funded part is stopped by RedemptionWorsensRatio), so the Treasury's reserve is overpaid by (par - honest backing) on every unit redeemed, at the remaining holders' expense. The work ceiling is unaffected (earnLine reads debt, not securedCollateral). Preconditions are exogenous (a revert-dead leg, not reachable by an unprivileged actor; then a fall; then a reserve), hence low; but it is the mirror image of the underpayment the fix removed, and it lasts indefinitely where the underpayment lasted a day. The _secured NatSpec at 844 ('an unpriced feed counts the position for nothing') describes the behaviour the fix removed, and 863 ('the next priced checkpoint of the position corrects it') omits that only the owner can produce one. Smallest fix: when price == 0 and the principal fell, scale the kept term by the principal ratio: pass the previous principal from _reduceDebt and use min(before, collateral, mulDiv(before, position.debt, oldDebt)); the bound 2 x principal / price is linear in principal, so this is exact when the bound was binding and only tightens when the collateral was; lock / lockIMD may keep `before` as they do. Then reword 844 and 863. audit_economics validated that with this patch the Cover, LaggedBacking, BadDebtSweep, MarkerBadDebt, Redemption, RedemptionEconomics and Liquidation suites stay green except test/LaggedBacking.t.sol test_aDeadLegNeitherZeroesTheSecuredTermNorTheLag, which pins the term as kept to the wei and would assert the scaled figure instead. Merged from audit_math 9d2f901c, audit_economics 8537f6f6 and e5e186db, audit_flow 829e9180, audit_permissions 03d8e168.

**Reproduction**

test/scratch/DeadLegWipe.t.sol, written for this review (fails on this code; no proof slot left). ParameterizedVault over an 18-decimal IMD at $1 (Chainlink ETH/USD etched at 2000e8), NHI 0.85, the Treasury holding 50 IMD. A locks 2,000 and draws 1,000; B locks 2,000, draws 1,000 and hands the imdUSD to REDEEMER. Price to $0.40; A and B each lock(1) to re-price their terms; four quiet days: securedCollateral == 4,000e18 + 2, backingPerUnit() == 0.81e18. vm.mockCallRevert on CHAINLINK_ETH_USD latestRoundData: REDEEMER's cash(20e18, 0, B) reverts StaleFeed as designed; A calls wipe(999e18), ungated. Mock cleared. EXPECTED: the unpriced figure never exceeds the next priced one: A's term min(2,000, 2 x 1.5 / 0.40) = about 7.4, securedCollateral about 2,007e18, backingPerUnit about 0.82e18. ACTUAL (logged): securedCollateral kept 4000000000000000000002, backingPerUnit kept 1000000000000000000; after A's lock(1) re-prices it: securedCollateral 2007432876712328765001, backingPerUnit 821751555085508501 ('the unpriced figure must not exceed the next priced one: 1000000000000000000 > 821751555085508501'). A reserve-funded cash(20e18, 0, B) before the touch pays 49250000000000000000 IMD (par less the fee) against 40471264087961293650 once re-priced: 8.78 IMD of the Treasury's reserve overpaid per 20 imdUSD.

### 7. Info: NatSpec and comments that claim properties the committed code does not have after 8756817: _clampLag 'a decrease that lasted', backedDebt 'positions that existed before the caller arrived', _redemptio

`src/CDPVault.sol:890`

```
    /// one transaction and returns in another still re-warms: a decrease that lasted is a decrease.
```

Each is the documentation half of a finding above; reword to the behaviour the code has, or fix the code and keep the text. (1) 884-890, _clampLag: 'capital that leaves and comes back inside one transaction ... leaves the lag where it was' is also true of DIFFERENT capital (high finding), and 'a decrease that lasted is a decrease' treats a wipe and a redraw in adjacent transactions of one block, lasting zero seconds, as lasting (medium finding). (2) 302-304 ('capital brought in one transaction and withdrawn a few later cannot authorise work minting') and ParameterizedVault.sol 241-244 ('the ratio term is only ever backed by positions that existed before the caller arrived'): the swap mints work against zero-second debt in the same transaction. (3) 819, _redemptionRate: 'the burned fraction of the supply that existed before this transaction': supply burned in the transaction is not added back. (4) 229-234, the transient tallies: 'capital which exists only for the length of the call can neither inflate the backing the guard measures nor dilute the supply the fee is measured against': a same-call repayment does both. (5) 531-532, cover: 'Reverts on a position holding collateral a bite could still reach (that is not realized bad debt)': the recorded-bad-debt clause at 558 sweeps bite-reachable collateral. (6) 250-251, securedCollateral: 'Sum over positions of min(collateral, SECURED_COLLATERAL_MULTIPLE x principal / price) ... each term at the price in force when that position last changed', and 844, _secured: 'an unpriced feed counts the position for nothing': after an unpriced change _resecure keeps min(before, collateral) with no principal bound; 863 'the next priced checkpoint of the position corrects it' omits that only the owner can produce one. (7) 703-705, _backingPerUnit: 'honest redemptions are not underpaid, because new debt and the imdUSD minted against it are excluded together', and 651 / 659-660, cash: 'Paying pro-rata instead is exactly neutral on backing by construction' and 'the peg floor is min(1 - fee, backing)': after a cross-transaction churn honest redemptions are paid against a figure below the honest backing, to zero, and a same-call burn pays above pro-rata. (8) test/helpers/OpenWorkVault.sol:13 cites test/EarnGate.t.sol, which does not exist in the tree (`ls test/EarnGate.t.sol`: no such file; the gate tests live in test/LaggedBacking.t.sol). Merged from audit_math 2a72ba2f and audit_economics e5e186db.

**Reproduction**

Each claim is refuted by the reproduction of the finding it documents: test/scratch/NettingTransfersWarmth.t.sol (claims 1, 2, 7), test/scratch/Proof_51b9c8eeb598.t.sol (claims 1, 7), test/scratch/Proof_8dd9e22f56eb.t.sol (claim 3), test/scratch/Proof_cbe35dcb7f1b.t.sol (claims 4, 7), test/scratch/Proof_05796c11d426.t.sol (claim 5), test/scratch/DeadLegWipe.t.sol (claim 6). `ls test/EarnGate.t.sol` reports 'No such file or directory' (claim 8).

### 8. Info: Position struct NatSpec is garbled after 8756817: the inserted 1e18-scaled-seconds clause left the old tail in place, so the sentence states the date twice and reads as whole seconds at its end

`src/CDPVault.sol:41`

```
        /// @dev Principal minted within FRESH_DEBT_WINDOW of `mintedAt` and still outstanding, and
        /// `mintedAt` is its amount-weighted mint time in 1e18-SCALED seconds (see `draw`),
        /// its amount-weighted mint time. Only redemption reads them: see `_redeemPosition`.
```

Lines 41-43 read as one sentence: 'Principal minted within FRESH_DEBT_WINDOW of `mintedAt` and still outstanding, and `mintedAt` is its amount-weighted mint time in 1e18-SCALED seconds (see `draw`), its amount-weighted mint time. Only redemption reads them'. The inserted clause was meant to replace the trailing one. A reader of the ABI docs or the struct will take `mintedAt` for a Unix timestamp unless they reach the inner clause; everything that reads it (draw 472-477, _reduceDebt 1230-1241, _recentlyMinted 838-840) uses WAD-scaled seconds correctly, and Q5's arithmetic is otherwise sound: principal-time is conserved through every draw/wipe pair to within rounding (draw rounds the weighted date toward the present by at most one wad-second, _reduceDebt rounds the age up), a tranche can only re-date a record by its share, a repayment retires the youngest first, and an aged-out record restarts at the present. Fix: '`recentlyMinted` is the principal minted within FRESH_DEBT_WINDOW of `mintedAt` and still outstanding; `mintedAt` is its amount-weighted mint time in 1e18-scaled seconds (block.timestamp * 1e18, see `draw` and `_reduceDebt`). Only redemption reads them: see `_redeemPosition`.' Merged from audit_flow f45983ab and audit_permissions 8bd7982a.

**Reproduction**

Read src/CDPVault.sol:41-43 as one sentence against _recentlyMinted (838-840: block.timestamp * 1e18 - mintedAt < 12 hours * 1e18) and draw (472-477). EXPECTED: one grammatical statement of the two fields and their unit. ACTUAL: the clause 'its amount-weighted mint time' appears twice, the second in whole-second wording after the corrected one. test/LaggedBacking.t.sol test_drawAndWipePairsDoNotKeepSeasonedDebtFresh pins the code behaviour per the inner clause.

### 9. Info: _redemptionReserveBacking's second return value (the rounded-up value leaving the reserve) is computed and never read: the only caller passes amount 0 and discards it

`src/CDPVault.sol:710`

```
        (uint256 reserve,) = _redemptionReserveBacking(0, price);
```

The virtual _redemptionReserveBacking(amount, price) at 225 returns (reserve backing, ceil(amount x price / 1e18)); ParameterizedVault overrides it at 172-183 with the same shape. The only call is _backingPerUnit at 710, with a literal 0 for `amount` and `(uint256 reserve,)` destructuring. The second computation is a remnant of the refused-redemption guard that the pro-rata payout replaced (the cash comment at 636-648 describes the replacement). No behaviour depends on it; it costs a mulDiv per redemption and makes the ParameterizedVault NatSpec at 164-171 ('One valuation for the IMD held and the IMD leaving is what makes the comparison mean something') describe a comparison that no longer happens. Fix: drop the parameter and the second return (and the ParameterizedVault override's), or document it as unused. From audit_math d32ebe3b.

**Reproduction**

grep -n '_redemptionReserveBacking' src/*.sol shows exactly three lines: the definition (src/CDPVault.sol:225), the override (src/ParameterizedVault.sol:172) and one call site (src/CDPVault.sol:710) with a literal 0 as the first argument and the second return value discarded; no other reader exists in src/.

---

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