# 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. Six audit rounds and their fixes are already in (docs/AUDIT-*.md; the newest is docs/AUDIT-RETRY-PANEL-VAULT-2026-10-07.md, whose fixes are this commit: git show 24337a2, and whose Resolution section says how each finding was answered). That panel found that ONE aggregate lag could not keep warmth with the position that earned it, so the lag was redesigned: what is new is now cold PER POSITION. The redesign is what to break first. A finding of an earlier round counts only if its fix regressed or left a gap. One medium of that round is accepted with its cost stated (CDPVault._backingPerUnit NatSpec): check its bound, do not re-report it.
>
> 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. COLD CAPITAL PER POSITION (CDPVault._lag, _cool, _pow, _coldNow, laggedNow; Position.coldDebt/coldSecured/coldAt; _coldDebt/_coldSecured/_coldAt; BACKING_HALF_LIFE six hours; nothing cold after a BACKING_WARMUP with no touch). Every position's cold halves at the same rate, so the vault's total is kept as one figure. Prove or break: (a) the vault total never reads below the sum of the positions' cold (it rounds up, a position rounds down; a position's own quiet day zeroes its cold while the vault total keeps cooling), so laggedNow never exceeds honest warm-up; (b) no sequence, by one position or several, in one transaction or across many, in either order (draw then cancel another's debt through cash, bite or cover; cancel then draw), lets one position's new capital count as another's warm capital; (c) whether activity or quiet can be arranged to credit capital faster than intended (a touch restarts only the vault total's quiet day; a position's own quiet day is its own); (d) the 128-bit saturation, _pow's precision and gas over long gaps, and the unchecked block in _lag.
> 2. THE BANK (Position.bankDebt/bankSecured with one date each, set only when that bank fills; expiry after BACKING_WARMUP; credit only to the position that lost the warmth, only as it grows back). Can a bank be inflated, moved to another position, kept past its day (a trickle, the other side), or credited with capital that never left warm?
> 3. THE LAGGED FEE BASE (_laggedSupply, _checkpointSupply at a transaction's first mint or burn, _supplyStart, _laggedSupplyFrom, _coldRepaidCountsAtOnce with COLD_REPAID_SLOT, cash's adjustments to both). A repayment of warm principal stays in the base, fading by half every six hours; increases, redemptions and repayments of cold principal count at once. Prove or break: no sequence moves the base below the honest supply (pinning the fee at the cap cheaply) or above it (lowering every fee) without seasoned capital held for hours; the transient bookkeeping across several mints and burns in one transaction (wipe then cash, cash then wipe, bite, cover, earn, fee remints) and across transactions.
> 4. BACKING PER IMDUSD'S DENOMINATOR (_backingPerUnit: the larger of the live supply and the supply the transaction began with). The cross-transaction premium is accepted: verify its stated bound, and find any OTHER path, same transaction or not, that raises backingPerUnit above honest for a redemption, or underpays honest redeemers.
> 5. DRAINED POSITIONS (_relockBelowBadDebt: collateral worth less than the recorded bad debt). cover takes such a re-lock at its value, burning at least that much of the Treasury's imdUSD (CoverBelowCollateralValue); bite skips mark and grace only in that case. Can a rebuilding borrower be harmed (taken at par mid-rebuild, a price move between their deposits), can cover or bite be griefed, and do the bad-debt record and totalBadDebt stay consistent through a value-sweep?
> 6. The fresh-debt record, earn's wage gate, positions, liquidation, redemption, the stability fee, price gating, arithmetic and contract size: as the previous panel's question 5, for regressions.
>
> 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 | `24337a23ce2f6349d0ed6350e2bfb09e0360f29f` |
| Job | `a2640621-925d-479e-b71d-9629899ed4c6` |
| Judged | 2026-10-08 07:04 UTC |
| Findings | 3 medium · 5 low · 2 info |

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

## Findings

### 1. Medium: CDPVault lagged fee base: a cold draw by any position absorbs a warm repayment's fading share, and the cold repayment is then subtracted again, so the base collapses to the live supply and the fee is

`src/CDPVault.sol:1437`

```
        _laggedSupply = lagged > cold ? lagged - cold : 0;
```

Q3; a gap left by the fix for the retry panel's medium #5 (merged from audit_flow 2f12b4a4 and audit_math a73695b7; audit_economics cba55ee9 is the documentation half of the same mechanism). `_laggedSupply` is stored as an ABSOLUTE figure and `_laggedSupplyFrom(start)` reads max(start, start + cool(_laggedSupply - start)). Two rules contradict each other. (1) A mint never touches `_laggedSupply` (`_mintOrBurn` only checkpoints), so at the next checkpoint the fading excess is measured against a live supply the mint has raised: a cold draw (or an `earn`) of M absorbs M of the warm repayment's share. (2) `_coldRepaidCountsAtOnce` (line 1437) then subtracts a cold repayment from `_laggedSupply` in full, although the mint it repays was never added to it. Net effect of a cold draw-and-repay of M by ANY position: the base falls by min(M, excess). Call sequence (launch constants, wage 0, divisor 2; BORROWER holds 900 of a 1,000 supply as seasoned debt, OTHER 100; the Treasury holds IMD so the redemption is reserve-funded): tx1 BORROWER wipe(900e18): warm, banked, live 100, `_laggedSupply` 1,000 (redemptionFeeBps(9e18) still 95, as designed). tx2 any position (a contract in one call, or an EOA in three transactions) lock(2000e18), draw(900e18), wipe(900e18): the draw's checkpoint sets `_laggedSupply` = 100 + cool(900) = 999.6, the mint lifts live to 1,000; the wipe's `_lag` retires 899.6 of cold principal into COLD_REPAID_SLOT and `_coldRepaidCountsAtOnce` sets `_laggedSupply` = 1,000 - 899.6 = 100.4, live 100. tx3 anyone cash(9e18, 0, address(0)): `_redemptionRate` reads prior 100.4 and increase 9 / 100.4 / 2 = 4.48%: `redemptionBaseRate` is set to the 4.5% cap where the honest base of 1,000 gives 0.0045e18. tx4 BORROWER draw(900e18), credited warm from its bank: the position is exactly where it started. The same erasure works through `cash` against the cold helper instead of its wipe. Cost: gas, one block, no seasoned capital (the helper can even use the collateral BORROWER freed) and 0.45 imdUSD of fee against the honest 4.5 (a burn of 9% of supply). Who loses: every later redeemer pays 500 bps instead of 50 for a half-life or two; the peg floor min(1 - fee, backing) sits at 0.95 on demand; a candidate can deter redemptions against itself; repeatable every half-life. Reachable with the constants as committed, no governance; needs one large position (LINE is $1M at launch). The regression test test/retry-panel/AdjacentTxBurn.t.sol test_adjacentWipeCashDrawPinsTheFeeBase passes only because its redraw is credited from the same position's bank (warm), so no cold principal is ever retired. NatSpec the code does not have: lines 334-337 ('an increase counts at once ... a repayment of warm principal only as it ages, its excess halving every BACKING_HALF_LIFE'; an increase is in fact absorbed by the fading excess, so after a warm repayment of W and a new draw of E the base is max(live, fading high-water mark), not live + fading W), 893-895 ('a repayment in it or in the last few hours does not shrink the base') and 1427-1429 ('Only warm repayments lag'). Smallest fix: keep the lag as an EXCESS over the live supply rather than an absolute figure. At each checkpoint cool the excess; at every repayment burn (`_payDebt`, `cover`) add principalPaid - coldRepaid to it; on a draw subtract the part credited from the position's bank (the same supply returning; floor at zero) and nothing else; leave it untouched by other mints, by redemptions and by cold repayments; prior = start + cooled excess. A mint can then not absorb it and a cold repayment cannot subtract from it, while a wipe-and-redraw by the same position still nets to the pre-churn supply. (audit_flow verified an equivalent variant locally: tally increases into the lagged figure at once, less the bank-credited part, so a cold repayment's subtraction is matched by its mint's addition; the attached proof and the committed redemption, retry-panel, lag, cover, liquidation and invariant suites s

**Reproduction**

test/scratch/Proof_2f12b4a45902.t.sol (attached; both tests fail on this code, run by me). ParameterizedVault over an 18-decimal MockIMD at $1 (IMD/ETH 1/2000 times Chainlink ETH/USD 2000e8 etched at CHAINLINK_ETH_USD), NHI 0.85, TreasuryFactory etched at TREASURY_FACTORY, launch constants (wage 0, divisor 2). BORROWER locks 2,000 and draws 900; OTHER locks 200, draws 100 and gives BORROWER 9 imdUSD; the Treasury holds 100 IMD; two quiet days; supply 1,000, redemptionFeeBps(9e18) == 95. Test 1: BORROWER wipe(900e18) [tx 1]; a helper contract calls lock(2000e18), draw(900e18), wipe(900e18) in one transaction [tx 2]; supply back at ~100. EXPECTED: redemptionFeeBps(9e18) <= 96 and, after cash(9e18, 0, address(0)), redemptionBaseRate <= 0.0046e18. ACTUAL: redemptionFeeBps(9e18) == 500 ('the lagged base must still hold the warm 900 repaid moments ago: 500 > 96'). Test 2: the same three steps from two EOAs as separate transactions (OTHER locks 2,000, draws 900, wipes 900), then cash(9e18, 0, address(0)). EXPECTED: redemptionBaseRate 0.0045e18. ACTUAL: 44890786251530523 (the cap: 'a 9-of-1,000 burn must not pin the fee at the cap: 44890786251530523 > 4600000000000000'). audit_math's Proof_a73695b7acf2 (one call: draw, wipe, cash) fails the same way with base 44901683765300744 against 4500000000000000, and its baseline (the committed regression's sequence) passes.

**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;

// A cold draw by any position absorbs a warm repayment's fading share of the lagged fee base at the next
// checkpoint, and the cold repayment is then subtracted from the base a second time: a dominant borrower's
// wipe, a helper's draw-and-wipe, and a small cash pin the redemption fee at the cap for a tenth of the
// honest cost, with no seasoned capital and no wait.

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 FbFeed 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 FbMirror 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 FbAggregator {
    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 helper position that draws cold principal and repays it in the same call.
contract FbHelper {
    ParameterizedVault private immutable vault;

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

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

contract FeeBaseColdMintTest is Test {
    address private constant BORROWER = address(0xB0B);
    address private constant OTHER = address(0x07E);

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    FbHelper private helper;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new FbAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        FbFeed primary = new FbFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1
        FbFeed health = new FbFeed(0.85 ether); // mat 170, gap 50
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new FbMirror(primary))
        );
        stable = vault.stablecoin();
        helper = new FbHelper(vault, imd);
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(BORROWER, 10_000 ether);
        imd.mint(OTHER, 10_000 ether);
        imd.mint(address(helper), 10_000 ether);
        imd.mint(address(vault.treasury()), 100 ether); // the redemption is reserve-funded
        vm.stopPrank();
        vm.prank(BORROWER);
        imd.approve(address(vault), type(uint256).max);
        vm.prank(OTHER);
        imd.approve(address(vault), type(uint256).max);

        // A seasoned dominant position: 900 of a 1,000 supply, warm for two days.
        vm.startPrank(BORROWER);
        vault.lock(2_000 ether);
        vault.draw(900 ether);
        vm.stopPrank();
        vm.startPrank(OTHER);
        vault.lock(200 ether);
        vault.draw(100 ether);
        stable.transfer(BORROWER, 9 ether);
        vm.stopPrank();
        vm.warp(block.timestamp + 2 days);
        assertEq(stable.totalSupply(), 1_000 ether);
        assertEq(vault.redemptionFeeBps(9 ether), 95, "floor 50 + 9 / 1,000 / 2 = 45 bps");
    }

    /// Transaction 1: the dominant borrower repays its warm 900 (the base keeps it, fading over hours).
    /// Transaction 2: a helper position draws 900 cold and repays it in the same call.
    /// Transaction 3: a 9 imdUSD redemption. EXPECTED: an increase of 45 bps against a base of about 1,000.
    /// ACTUAL: the base read about 100, and the rate is pinned at the 450 bps cap for a tenth of the cost.
    function test_aColdDrawAndRepayByAnotherPositionEmptiesTheLaggedFeeBase() public {
        vm.prank(BORROWER);
        vault.wipe(900 ether);
        helper.lockDrawWipe(2_000 ether, 900 ether);
        assertApproxEqAbs(stable.totalSupply(), 100 ether, 0.5 ether, "supply is back where the wipe left it");
        // The quote already shows it: the increase for 9 imdUSD reads the cap instead of 45 bps.
        assertLe(vault.redemptionFeeBps(9 ether), 95 + 1, "the lagged base must still hold the warm 900 repaid moments ago");
        vm.prank(BORROWER);
        vault.cash(9 ether, 0, address(0));
        assertLe(vault.redemptionBaseRate(), 0.0046e18, "a 9-of-1,000 burn must not pin the fee at the cap");
        // The dominant borrower redraws from its bank, warm, and the position is where it was.
        vm.prank(BORROWER);
        vault.draw(900 ether);
    }

    /// The same, as three separate transactions from two keys (no contract needed).
    function test_theHelperNeedsNoContract() public {
        vm.prank(BORROWER);
        vault.wipe(900 ether);
        vm.startPrank(OTHER);
        vault.lock(2_000 ether);
        vault.draw(900 ether);
        vault.wipe(900 ether);
        vm.stopPrank();
        vm.prank(BORROWER);
        vault.cash(9 ether, 0, address(0));
        assertLe(vault.redemptionBaseRate(), 0.0046e18, "a 9-of-1,000 burn must not pin the fee at the cap");
    }
}
```

### 2. Medium: CDPVault._lag: a bank credited in full by a one-block visit is re-dated when the capital leaves again, so a once-seasoned position keeps its warmth indefinitely while its capital is in the vault one b

`src/CDPVault.sol:992`

```
                    if (bank == 0) bankAt = block.timestamp;
```

Q2 and Q1(b) (from audit_math 73d81e30). The retry panel's medium #3 was answered with one date per bank, 'set only when that bank goes from empty to full', so that a bank expires one warm-up after the capital first left. But a return credits the bank down to zero (lines 996-997), and the next departure finds bank == 0 and dates the refilled bank at that moment (line 992). A position that seasoned its capital once can therefore leave, come back for a single transaction every 23 hours (lock + draw, credited in full on both sides), leave in the next transaction (banked again, re-dated) and never warm up again: the capital is in the vault one block out of every 23 hours, forever. That is exactly the D1 round trip the lag exists to close, for anyone who has held a position for one day at any time in the past: bring the capital in (tx N), redeem reserve IMD at the lifted backing or `earn` against the lifted ceiling (tx N+1), withdraw (tx N+2), with the capital exposed to price and liquidation for one block per cycle. Reachable with the constants as committed: the redemption half at wage 0 (`_backingPerUnit`'s lagged figure reads min(held, lagSecured) and supply - fresh with the returning capital warm), the ceiling half once a wage is set. Bounded by what the position once held warm, hence medium, as the panel rated the same 'permanent option' (its #3). NatSpec the code does not have: lines 318-319 ('capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par'), 759-760 ('An attacker's capital can raise the live figure but not the lagged one, whichever position it sits in'), 963-964 ('a bank only ever returns warmth to the position that lost it, within BACKING_WARMUP of the moment it first filled, whatever is added to it later'). Smallest fix that keeps the design: let a bank COOL while it waits, at the lag's own half-life, and credit only what is left: credit = min(increase, _cool(bank, block.timestamp - bankAt, false)), the expiry becoming the natural zero at BACKING_WARMUP. A borrower's ordinary wipe-and-redraw minutes apart is still credited almost whole; capital away for 23 hours comes back about 7% warm; a one-block visit cannot re-arm it, because the refilled bank holds only what was credited and is dated at the departure.

**Reproduction**

test/scratch/Proof_73d81e305a41.t.sol (attached; fails on this code, run by me). ParameterizedVault at $1, NHI 0.85, wage 0. OTHER locks 1,000, draws 200 and hands BORROWER 200 imdUSD of fee money. BORROWER locks 2,000, draws 1,000; two quiet days: laggedNow() == (1,200e18, 2,400e18). t0: BORROWER wipe(debtOf) and free(all): laggedNow().debt == 200e18, bankDebt 1,000 and bankSecured 2,000 dated t0. Every 23 hours: lock(2000e18) + draw(1000e18) as one transaction, then wipe + free as the next. EXPECTED at t0 + 46 h and after (the capital first left more than a BACKING_WARMUP ago and has been present for two blocks since): laggedNow().debt <= 270e18 and .secured <= 540e18 (OTHER's terms plus at most 7% of the returning capital). ACTUAL: laggedNow() == (1200e18, 2400e18) on every visit, ten cycles over ten days ('capital away for a day comes back cold: 1200000000000000000000 > 270000000000000000000').

**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 BrfFeed is ISwarmFeed {
    uint256 public constant maxAge = 1 days;
    uint256 private value;
    uint64 private updatedAt;

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

    function set(uint256 v) external {
        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 BrfMirror 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 BrfAggregator {
    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 Q2: a bank's date is set when it goes from empty to full. A one-block visit (lock + draw, credited
/// in full, then wipe + free, banked again) empties and refills it, so the day restarts. A once-seasoned
/// position keeps its warmth forever while its capital is away all but one block a day.
contract BankRefreshTest is Test {
    address private constant BORROWER = address(0xB0B);
    address private constant OTHER = address(0x07E);

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    BrfFeed 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 BrfAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new BrfFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1
        BrfFeed health = new BrfFeed(0.85 ether); // mat 170, gap 50
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new BrfMirror(primary))
        );
        stable = vault.stablecoin();
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(BORROWER, 100_000 ether);
        imd.mint(OTHER, 100_000 ether);
        vm.stopPrank();
        vm.prank(BORROWER);
        imd.approve(address(vault), type(uint256).max);
        vm.prank(OTHER);
        imd.approve(address(vault), type(uint256).max);
        // OTHER gives the borrower fee money so it can always repay in full.
        vm.startPrank(OTHER);
        vault.lock(1_000 ether);
        vault.draw(200 ether);
        stable.transfer(BORROWER, 200 ether);
        vm.stopPrank();
    }

    /// @dev Season 2,000 / 1,000 for two days, leave, and come back for one block every 23 hours. After the
    /// capital has been away for more than BACKING_WARMUP in total (two visits of one block in 46 hours), the
    /// bank that was created when it FIRST left has had its day, and the returning capital should be cold.
    function test_oneBlockVisitsKeepTheBankAliveForever() public {
        vm.startPrank(BORROWER);
        vault.lock(2_000 ether);
        vault.draw(1_000 ether);
        vm.stopPrank();
        vm.warp(block.timestamp + 2 days);
        (uint256 warmBefore, uint256 warmSecBefore) = vault.laggedNow();
        assertEq(warmBefore, 1_200 ether, "everything warm after a quiet warm-up");
        assertEq(warmSecBefore, 2_000 ether + 400 ether, "both terms warm");

        // Leave: the capital first leaves at t0.
        _leave();
        uint256 t0 = block.timestamp;
        (uint256 warmAway,) = vault.laggedNow();
        assertEq(warmAway, 200 ether, "only OTHER's debt remains");

        // Ten one-block visits, 23 hours apart: in each, come back (credited), and leave the next transaction.
        for (uint256 i; i < 10; ++i) {
            vm.warp(block.timestamp + 23 hours);
            _comeBack();
            (uint256 warm, uint256 warmSec) = vault.laggedNow();
            if (block.timestamp - t0 > vault.BACKING_WARMUP()) {
                // EXPECTED: the returning 1,000 of debt and 2,000 of collateral are cold: the bank was created
                // when the capital first left, more than a warm-up ago, and the capital has been in the vault
                // for one block since. ACTUAL: credited in full, every time.
                assertLe(warm, 200 ether + 70 ether, "capital away for a day comes back cold");
                assertLe(warmSec, 400 ether + 140 ether, "collateral away for a day comes back cold");
            }
            _leave();
        }
    }

    function _leave() private {
        vm.startPrank(BORROWER);
        vault.wipe(vault.debtOf(BORROWER));
        (uint256 collateral,) = vault.positions(BORROWER);
        vault.free(collateral);
        vm.stopPrank();
    }

    function _comeBack() private {
        vm.startPrank(BORROWER);
        vault.lock(2_000 ether);
        vault.draw(1_000 ether);
        vm.stopPrank();
    }
}
```

### 3. Medium: CDPVault._redemptionRate: the fee base counts cold principal at once, so a draw held for one block (or a cold repayment inside the redeeming call) dilutes the fee to the floor and resets the base rate

`src/CDPVault.sol:906`

```
        uint256 prior = _laggedSupplyFrom(_supplyStart());
```

Q3, the other direction (merged from audit_permissions f1f7755a and audit_economics ddd10333). The lagged fee base lags DECREASES of warm principal only. `_laggedSupplyFrom` floors the base at `start`, the live supply when the transaction began, and the live supply includes principal that is zero blocks old; `_coldRepaidCountsAtOnce` removes a cold repayment across transactions but not inside the transaction that redeems, because `_redemptionRate` reads `_laggedSupplyFrom(_supplyStart())` and `_supplyStart()` is the pre-wipe supply. (1) Across transactions, one block: tx N lock + draw(D); tx N+1 anyone's cash(A) is quoted A / (S + D) / divisor instead of A / S / divisor, and `redemptionBaseRate` is stored from it for everyone after; tx N+2 wipe(D) (cold, `_coldRepaidCountsAtOnce`, the position's bank is empty, nothing lags). (2) In one call through a contract: wipe(D) of cold principal then cash(A): `_laggedSupply` has already lost D but the floor at `start` still holds it, which contradicts lines 334-336 and 1427-1429 ('a repayment of cold principal counts at once') for the same transaction and lets the churn finish inside one call; as two transactions the same calls charge A / S / divisor. Cost: gas, a stability fee on D for one block (0.012% a day at DUTY_BPS) and 1.7 x D of sIMD exposed for one block; D is bounded only by the line's room ($1M at launch against a small supply). Who loses: the redeemer's fee (up to the 4.5% between cap and floor on A) is value that would have stayed in the reserve or the candidate position, and the stored base rate, the throttle that slows a redemption run and makes the b952037a pump expensive, is reset low on demand (95 bps for everyone instead of 500 in the proof), repeatable every half-life. Reachable with the constants as committed, wage 0, no governance. It is the mirror image of the retry panel's medium #5 and of the finding above. NatSpec: 334 ('an increase counts at once') states the rule, but the requester's own Q3 property ('no sequence moves the base ... above [honest] without seasoned capital held for hours') does not hold, and 893-895 / 1427-1429 contradict each other for the same-call case. Smallest fix: measure the fee base against WARM supply the way backing does, prior = _laggedSupplyFrom(start) - (totalDebt - laggedNow().debt) - coldRepaidThisTransaction (a tally `_payDebt` does not clear), saturating at zero (which quotes the cap, the lag's accepted direction). Trade-off to decide: while every unit of supply is cold (the first hours after launch, or right after a large honest draw) `prior` is small and early redemptions pay the cap; audit_economics measured ten committed redemption tests failing under that rule. If the cost is refused, state at 334-343 and 893-895 that a draw held across one block dilutes the base and that a cold repayment counts at once only from the next transaction, and drop the claim at 1427-1429 that the cold rule prevents the inflation.

**Reproduction**

test/scratch/FeeDilution.t.sol (attached as proof; mine, a tolerant rewrite of audit_permissions' Proof_f1f7755a6131, which fails identically but asserts an exact 0.045e18 that a fix cooling the cold principal for its 12 seconds would miss by a hair). ParameterizedVault over MockIMD at $1 (Chainlink 2000e8 etched), NHI 0.85, divisor 2, Treasury holding 10,000 IMD so redemptions are reserve-funded. OTHER locks 3,000, draws 1,000 and gives HOLDER the 1,000; two quiet days. Control (passes): redemptionFeeBps(90e18) == 500 and cash(90e18) leaves redemptionBaseRate == 0.045e18. Attack 1: an actor contract lock(17,000e18) + draw(9,000e18) in one transaction; next block HOLDER cash(90e18, 0, 0); next block actor wipe(9,000e18); supply 910 after. EXPECTED: quoted >= 495 bps, base >= 0.0445e18. ACTUAL: quoted 95 bps, base 0.0045e18 ('cold principal must not dilute the fee: 95 < 495'). Attack 2: the actor holds the 90 and calls wipe(9,000e18) then cash(90e18) in ONE call. EXPECTED base >= 0.0445e18 (cold repaid counts at once). ACTUAL 0.0045e18 ('cold principal repaid in the same call must count at once: 4500000000000000 < 44500000000000000').

**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;

// The lagged fee base lags decreases of warm principal only: an increase counts at once, so a cold draw held
// for one block (or repaid in the same call as the redemption) dilutes the fee and the stored base rate.

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 FdlFeed 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 FdlMirror 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 FdlAggregator {
    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);
    }
}

contract FdlActor {
    ParameterizedVault private immutable vault;

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

    function lockDraw(uint256 c, uint256 d) external {
        vault.lock(c);
        vault.draw(d);
    }

    function wipeThenCash(uint256 w, uint256 a) external returns (uint256) {
        vault.wipe(w);
        return vault.cash(a, 0, address(0));
    }

    function wipe(uint256 w) external {
        vault.wipe(w);
    }

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

contract FeeDilutionTest is Test {
    address private constant HOLDER = address(0x401D);
    address private constant OTHER = address(0x07E);

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    FdlActor private actor;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new FdlAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        FdlFeed primary = new FdlFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1
        FdlFeed health = new FdlFeed(0.85 ether); // mat 170, gap 50
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new FdlMirror(primary))
        );
        stable = vault.stablecoin();
        actor = new FdlActor(vault, imd);
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(address(actor), 100_000 ether);
        imd.mint(OTHER, 10_000 ether);
        imd.mint(address(vault.treasury()), 10_000 ether); // reserve-funded redemptions
        vm.stopPrank();
        vm.prank(OTHER);
        imd.approve(address(vault), type(uint256).max);
        // A warm supply of 1,000, held by HOLDER.
        vm.startPrank(OTHER);
        vault.lock(3_000 ether);
        vault.draw(1_000 ether);
        stable.transfer(HOLDER, 1_000 ether);
        vm.stopPrank();
        vm.warp(block.timestamp + 2 days);
        assertEq(stable.totalSupply(), 1_000 ether);
    }

    function _next() private {
        vm.roll(block.number + 1);
        vm.warp(block.timestamp + 12);
    }

    /// Honest: redeeming 90 of a 1,000 warm supply is a 4.5% increase (the cap).
    function test_honestFeeForNinetyOfAThousand() public {
        assertEq(vault.redemptionFeeBps(90 ether), 500);
        vm.prank(HOLDER);
        vault.cash(90 ether, 0, address(0));
        assertEq(vault.redemptionBaseRate(), 0.045e18);
    }

    /// A draw one block before the redemption, repaid one block after: the fee is quoted against 10,000.
    /// EXPECTED: the cold 9,000 does not dilute the fee (quoted 500 bps, base about 0.045e18; a fix that cools
    /// the cold principal for the 12 seconds it lived may read a hair under). ACTUAL: 95 bps, base 0.0045e18.
    function test_coldDrawOneBlockEarlierDilutesTheFee() public {
        actor.lockDraw(17_000 ether, 9_000 ether); // 90% of the resulting supply, zero seconds old
        _next();
        uint256 quoted = vault.redemptionFeeBps(90 ether);
        vm.prank(HOLDER);
        vault.cash(90 ether, 0, address(0));
        uint256 base = vault.redemptionBaseRate();
        _next();
        actor.wipe(9_000 ether);
        emit log_named_uint("quoted fee bps (honest 500)", quoted);
        emit log_named_uint("base rate after (honest 0.045e18)", base);
        assertApproxEqAbs(stable.totalSupply(), 910 ether, 1e16, "supply is 910 after the churn (fee dust aside)");
        assertGe(quoted, 495, "cold principal must not dilute the fee");
        assertGe(base, 0.0445e18, "cold principal must not depress the base rate");
    }

    /// Same transaction: repay the cold principal and redeem in one call. The NatSpec says a repayment of cold
    /// principal counts at once; `_laggedSupplyFrom` floors at the pre-wipe supply, so it does not.
    function test_wipeOfColdPrincipalThenCashInOneTransactionReadsThePreWipeSupply() public {
        actor.lockDraw(17_000 ether, 9_000 ether);
        _next();
        vm.prank(HOLDER);
        stable.transfer(address(actor), 90 ether);
        actor.wipeThenCash(9_000 ether, 90 ether);
        uint256 base = vault.redemptionBaseRate();
        emit log_named_uint("base rate after (honest 0.045e18)", base);
        assertApproxEqAbs(stable.totalSupply(), 910 ether, 1e15, "supply is 910 after the churn");
        assertGe(base, 0.0445e18, "cold principal repaid in the same call must count at once");
    }
}
```

### 4. Low: CDPVault.cash: the start-of-transaction supply slot is decremented with an unchecked assembly sub, so a redemption larger than the supply the transaction began with wraps it; backingPerUnit reads 0, t

`src/CDPVault.sol:734`

```
            tstore(SUPPLY_START_SLOT, sub(tload(SUPPLY_START_SLOT), amount))
```

Q3/Q4, the transient bookkeeping across several mints and burns in one transaction (all four specialists: audit_flow 40b7b462, audit_math 24d7b4ae, audit_permissions a1132c6c, audit_economics 5076eedf; merged). SUPPLY_START_SLOT holds start + 1, the supply at the transaction's first mint or burn. `cash` subtracts its whole burn with assembly `sub`, which has no underflow check. `amount` is bounded only by the LIVE supply (`_redemptionRate` line 902), and a `draw` or `earn` earlier in the transaction raises the live supply above `start`, so a redemption of more than start + 1 wraps the slot to about 2^256 - (amount - start); exactly start + 1 wraps it to 0, which `_checkpointSupply` reads as 'not recorded' and re-checkpoints `_laggedSupply` and the start supply from the mid-transaction live supply. Until the transaction ends `_supplyStart()` returns the wrapped figure: `_backingPerUnit` takes supply = max(live, start) ~ 2^256 and rounds perUnit to 0 (backingPerUnit() reads 0 in a fully backed vault), `_redemptionRate` reads prior ~ 2^256 and quotes the floor with no increase, and a second `cash` computes gemOut = 0 and reverts ZeroAmount. Transient storage is cleared when the call ends and `_laggedSupply` only ever falls, so nothing persists and no profit path was found (a cash at the wrapped figure pays nothing): the harm is a legitimate composed flow (a router or multicall that borrows or earns and then redeems more than the pre-existing supply, ordinary at launch with a small supply and a $1M line) refused with an error indistinguishable from an empty reserve, and wrong quotes mid-call. The comment at 729-730 ('counts at once, across transactions and inside this one') does not hold for such a burn. Reachable with the constants as committed. Smallest fix: saturate at the transaction's floor, keeping the +1 sentinel so the slot never reads as unrecorded, e.g. in a small private helper (an extra local in `cash` itself hits stack-too-deep): `let s := tload(SUPPLY_START_SLOT) switch gt(s, amount) case 1 { tstore(SUPPLY_START_SLOT, sub(s, amount)) } default { tstore(SUPPLY_START_SLOT, 1) }`, matching the saturating update of `_laggedSupply` on the line before.

**Reproduction**

test/scratch/SupplyStartWrap.t.sol (mine; fails on this code with ZeroAmount(), and its companion test pins the selector). ParameterizedVault at $1, NHI 0.85; OTHER locks 200 and draws 100 (the supply the next transaction begins with is 100); the Treasury holds 5,000 IMD so redemptions are reserve-funded; a router contract holds 10,000 IMD. Control, as separate transactions: lock(2000e18) + draw(900e18); cash(500e18, 0, 0) pays a1 > 0; cash(100e18, 0, 0) pays b1 > 0 (passes, snapshot reverted). Then in ONE transaction: lock(2000e18), draw(900e18) [live 1,000, start 100], cash(500e18, 0, 0) [burns more than start: the slot becomes 101e18 - 500e18 mod 2^256], then backingPerUnit(), redemptionFeeBps(100e18), cash(100e18, 0, 0). EXPECTED: both redemptions paid as in the control, backingPerUnit() == 1e18 between them. ACTUAL: the call reverts ZeroAmount() at the second cash; the specialists' logged readings with the asserts removed are backingPerUnit() == 0 and the quote at the 50 bps floor.

### 5. Low: CDPVault._backingPerUnit: the accepted cross-transaction premium is stated as at most supply / (supply - repaid), but the lagged figure's denominator is supply - fresh, so with cold debt outstanding t

`src/CDPVault.sol:769`

```
    /// most supply / (supply - repaid), the repayment bounded by the churner's principal above half its
```

Q4, the bound check the task asks for on the accepted medium (retry panel #4); the premium itself is not re-reported (from audit_flow c05c911a; audit_permissions d451c417 item 3 adds the parenthetical note below). `_backingPerUnit` returns min(live, lagged) with lagged = (reserve + warm secured) * 1e18 / (supply - fresh), where fresh = totalDebt - laggedNow().debt is the vault's cold principal. Below par the lagged figure binds, so a warm borrower's repayment R one transaction earlier (its term unchanged, 170-200% band) scales that figure by (supply - fresh) / (supply - fresh - R), not by supply / (supply - R) as the NatSpec (768-770), the retry panel's resolution table and test/retry-panel/AdjacentTxBurn.t.sol (which pins the bound with fresh == 0) state. The two coincide only when no debt is cold. The churner must itself be warm (a cold churner's repayment lowers fresh by the same R, and the denominator does not move), so R is bounded by 15% of the WARM principal it holds and the true bound is 1 / (1 - 0.15 x s_w) with s_w the churner's share of the warm supply: 17.6% for a churner that is the whole warm supply, where supply / (supply - repaid) suggests 15% of its share of the whole supply. Whenever a large part of the supply is fresh (launch, or after a large draw by anyone) the stated bound understates what a redeemer takes out of the Treasury's reserve and the other holders' backing. Also, the parenthetical '15% of it at a 170% minimum ratio, 25% at 150%' describes a churner AT mat: `wipe` has no health check, so a position below mat that is unmarked or inside its grace can repay up to half its principal without moving its term; it cannot redraw past the health check, so that variant costs capital, not gas, and the gas-only bound stands as the general clause. Not a bypass of the acceptance: the cost (gas, below par only, bounded by the churner's warm principal) stands; the bound stated for it does not. Smallest fix: state the bound as (supply - fresh) / (supply - fresh - repaid), `fresh` being the cold principal `laggedNow` excludes, in the NatSpec, in docs/AUDIT-RETRY-PANEL-VAULT-2026-10-07.md's resolution and in the retry-panel test's assertion (which should pin the bound with fresh debt present); or make the lagged denominator read the supply the churn left in it by adding the position's unexpired `bankDebt` back to supply - fresh for the lagged figure.

**Reproduction**

test/scratch/BackingBound.t.sol (mine; fails on this code at the stated bound). ParameterizedVault over an 18-decimal IMD at $1 (ETH/USD 2000e8 etched), NHI 0.85, wage 0. BORROWER locks 5,790 and draws 1,000; OTHER locks 5,100, draws 3,000 and hands BORROWER the 3,000 imdUSD; IMD to $0.294 (BORROWER 170.2%, OTHER 50%), both re-priced by lock(1); three quiet days; then NEW locks 14,000 and draws 2,000 in its own transaction: supply 6,000, fresh (totalDebt - laggedNow().debt) 2,000, backingPerUnit() 0.800415e18 (the lagged figure binds). Snapshot: BORROWER's cash(500e18, 0, BORROWER) pays 1297679625000000000000 raw (honest). Revert. Two transactions: BORROWER wipe(140e18); cash(500e18, 0, BORROWER). EXPECTED by the NatSpec: churned <= honest x 6000 / 5860 = 1328682209897610921501. ACTUAL: 1344618602670256454081 (+3.62% against the stated +2.39%), which is honest x 4000 / 3860 = 1344745725388601036269 less rounding, i.e. (supply - fresh) / (supply - fresh - repaid).

### 6. Low: _resecureBounded / _lag: a price fall re-prices a debt-bound secured term upward and the whole rise is cold, so collateral held for days is excluded from the lagged backing for a day after a fall and

`src/CDPVault.sol:950`

```
        _lag(position, true, before, current);
```

Q4 ('or underpays honest redeemers') and Q1(c) (from audit_math 2e2c4d95). `_lag` is driven by the secured TERM, min(collateral, 2 x principal / price), not by the collateral itself. For a position above 200% the term is debt-bound; when the price falls and the position is next touched (its own lock, wipe or free, or ANY third party's `cash` against it once it is an eligible candidate, even for one wei), the term rises toward its collateral and `_lag` treats the whole rise as cold capital although nothing arrived: the collateral had been in the vault for days. `_backingPerUnit` then pays redeemers the lagged figure, which excludes that collateral, for a day after the fall (half of it for six hours), which is exactly when redemptions matter. It is the safe direction and not a theft, but it is not 'new capital' either, and a third party can impose it on every eligible candidate with a one-wei cash against each. Reachable with the constants as committed. NatSpec the code does not have: lines 312 and 956 ('what a position adds is cold' / 'An increase is cold, less what the position's bank gives back') describe additions; a re-pricing adds nothing. Smallest fix: cold only the part of a term increase that corresponds to capital added in the same call: pass the collateral and principal this call added into `_resecure` (from lock, lockIMD and draw) and cap the cold increment at added collateral + 2 x added principal / price; a pure re-pricing is then warm. (The mirror, a price rise shrinking a debt-bound term and banking the decrease as warm, only ever credits the same position's own term back within a day and was checked as within the design.)

**Reproduction**

test/scratch/PriceFallColdTerm.t.sol (mine; fails on this code). ParameterizedVault at $1, NHI 0.85. P locks 4,000 and draws 1,000 (term 2,000, debt-bound); Q locks 1,700 and draws 1,000 (term 1,700); three quiet days: laggedNow().secured == 3,700e18, backingPerUnit() == 1e18. IMD to $0.50. P lock(1). EXPECTED: laggedNow().secured >= 5,700e18 (P's term is re-priced to 4,000 with no capital added) and backingPerUnit() == 1e18 (min(5,700 x 0.5, 1.7 x 2,000) / 2,000, capped). ACTUAL: securedCollateral 5,700e18 but laggedNow().secured == 3,700e18 (2,000 IMD of three-day-old collateral is cold: '3700000000000000000000 < 5700000000000000000000') and backingPerUnit() == 0.925e18 for the next day: a redeemer of 100 imdUSD is paid 7.5% less than the honest pro-rata figure.

### 7. Low: CDPVault._lag / _cool: a position untouched for a day reads its own cold as zero while the vault total, kept alive by others' touches, still holds 1/16 of it, so the position's repayment leaves that c

`src/CDPVault.sol:1021`

```
        if (elapsed >= BACKING_WARMUP) return 0;
```

Q1(a)/(c) (from audit_economics 4693a119). `_cool` returns 0 for any elapsed >= BACKING_WARMUP. For the vault total that is the designed quiet-day rule, and a touch of any position restarts it. For a position the same rule runs on the position's OWN elapsed (block.timestamp - position.coldAt, line 971), so a position left untouched for a day reads its cold as zero even when the vault total, touched by others every few hours, still carries that position's residual (1/16 after exactly a day, rounded up at every touch). The total is then above the sum of the positions, which the NatSpec calls the safe direction, but the decrease path takes coldOut = min(cold, out) with cold == 0 (lines 987-989): nothing leaves the total, the whole repayment is banked as warm, and the residual stays in `_coldDebt` / `_coldSecured` as cold for debt and collateral that no longer exist, halving every six hours and zeroed only by a quiet vault day. Effect: `laggedNow` reads the OTHER positions' warm capital short by that residual (62.5 of the helper's 100 in the reproduction), so `ParameterizedVault.backedDebt` / `earnLine` and the lagged `_backingPerUnit` figure are below honest for hours. Direction: conservative for the protocol (I checked that an orphan on both sides lowers the lagged backing below par whenever honest backing is under the position's term-to-debt ratio, i.e. always below par; on the debt side alone it would raise `fresh` and shrink the denominator, but a term always moves with the principal it bounds, so a debt-only orphan needs an unreadable price and was not reached). So this is a griefing and accuracy defect rather than an extraction: a borrower who holds D for a day while the vault stays active, repays, and redraws (credited warm from its bank, so its own position is unaffected) leaves D/16 of phantom cold behind each time; with D equal to the honest debt that is about 6% off the lagged backing and the work ceiling for about a day, for one day of stability fee on D and gas. The claim at 1016-1018 ('A touch only ever restarts that day, so activity can slow warming but never speed it') holds; the per-position premise in Q1(a) that 'a position's own quiet day is its own' holds for the position's figures and not for the total its decrease is applied to. Smallest fix: cool a position's figures continuously (apply the half-life for any elapsed, without the >= BACKING_WARMUP short-circuit; `_pow` stays at about 27 squarings for any realistic gap), so a position's cold is never below its share of the total and a decrease always removes what the position actually contributed; keep the quiet-day zeroing for the total only.

**Reproduction**

test/scratch/OrphanCold.t.sol (mine; fails on this code). ParameterizedVault at $1, NHI 0.85. HELPER locks 190 and draws 100 (190%: its term is its collateral, so lock(1) is a touch); three quiet days (warm). BORROWER locks 2,000 and draws 1,000 (cold). Every six hours for a day HELPER lock(1) touches the cold total while BORROWER stays untouched; one second past the day laggedNow().debt == 1037502005602022804874 == totalDebt - 62.5e18: 1/16 of the day-old 1,000 is still cold in the total. HELPER hands BORROWER 1 imdUSD for the day's fee; BORROWER wipe(debtOf(BORROWER)). EXPECTED: laggedNow().debt == totalDebt == 100e18 (BORROWER's debt and its cold both left; HELPER's 100 is warm). ACTUAL: 37502005602022804874 ('no cold should remain for debt that no longer exists: 37502005602022804874 != 100000000000000000000'): the 62.5 orphan stays and HELPER's warm debt reads 62.5 short, halving every six hours.

### 8. Low: CDPVault.bite: the no-mark, no-grace path keys on the re-lock's value against the bad-debt record, so a rebuilding borrower whose tranche or price move leaves the collateral below the record is bitten

`src/CDPVault.sol:1129`

```
        if (!_relockBelowBadDebt(owner, price)) {
```

Q5 ('a price move between their deposits') (from audit_permissions 1ba6e564). The retry panel's low #7 narrowed the skip to a re-lock worth less than the recorded bad debt, and its low #6 made `cover` take such a re-lock at its value with no liquidator needed. After those two fixes the bite shortcut is no longer needed to clear a griefing re-lock while the Treasury holds imdUSD (cover does it at par, 0% penalty, strictly better for protocol and borrower), but it still removes the mark and the grace from every drained borrower whose collateral is worth less than the record at the moment of the bite, whatever their intent: a borrower rebuilding in tranches is exposed between tranches, and one who re-locked collateral worth 110% of the record (above it, so marked and given grace like anyone) and is moved below it by a 10% price fall is liquidated by anyone in the same block, for any `debtToRepay` the collateral covers, at the 20% penalty, with both bonus shares to the liquidator (line 1156). An identical position with no record gets `bark` and up to six hours (NHI >= 0.85) to top up; the comment's premise at 1123-1124 ('cannot recover by waiting') is not a property of the position, because the same price moving back up returns it to the marked path. Harm bounded by the re-lock (20% of what is bitten); the bad-debt record and totalBadDebt stay consistent through the bite and through cover's value sweep (`_reduceDebt` lowers the record with the debt; `_recordBadDebt` re-records at zero collateral), which I checked. Reachable with the constants as committed. Smallest fix: require the ordinary mark and grace in `bite` for every position above dust, leaving `cover`'s at-value sweep as the permissionless remedy for a re-lock below the record (and keep the shortcut, if at all, only for the case cover cannot serve: a Treasury with no imdUSD); or, if the shortcut is kept, say in docs/MAINNET-RUNBOOK.md and the borrower docs that a drained borrower must re-lock at least the recorded bad debt's worth in one transaction and keep it above the record.

**Reproduction**

test/scratch/BiteRebuilder.t.sol (mine; fails on this code because the bite succeeds). ParameterizedVault at $1, NHI 0.60 (mat 200, lull 0, so grace is only the mark). B locks 2,000 and draws 1,000; K locks 40,000 and draws 10,000. Price to $0.50; bark(B); K bite(B, 833.333e18) drains B (collateral 0, totalBadDebt == debtOf(B) == 166666666666666666667). Price back to $1; two days (the old mark expires); B lock(1.1 x debtOf(B)) (worth 110% of the record; totalBadDebt unchanged). K bite(B, 1e18): reverts MarkExpired (the ordinary path: a fresh bark is needed). Price to $0.90: the re-lock is worth 99% of the record. K bite(B, 10e18) with no bark. EXPECTED: MarkExpired / PositionNotMarked like any other borrower at that ratio. ACTUAL: succeeds at once; K receives 12888888888888888889 raw IMD for 10 imdUSD (the 20% bonus less the protocol's cut, both shares to K).

### 9. Info: CDPVault._cool NatSpec: 'rounded up for the vault's totals, so they never read below the sum of the positions' is not exact, because _pow truncates at every squaring and a total cooled touch by touch

`src/CDPVault.sol:1016`

```
    /// totals (`up`, so they never read below the sum of the positions) and down for a position; and
```

Q1(a) and (d) (merged from audit_flow 08475e01, audit_math f30223db, audit_economics c023b9c7). The vault's cold totals are cooled at every touch of any position (`_lag` line 969) with `_cool(..., up = true)`, while a position's own cold is cooled once, at its next touch, over the whole gap, with `up = false`. The ceiling only guarantees total >= sum when the decay factor composes exactly, and `_pow` does not: each `result * factor / one` and `factor * factor / one` truncates, so `_pow(f, a) * _pow(f, b) / RAY` can read below `_pow(f, a + b)` by a few units in 1e27, and the one-unit ceiling per touch cannot cover that on an amount above about 1e27 raw units (a thousand sIMD at 24 decimals). Consequence: when such a position subtracts its cold the vault total saturates to zero (line 989) while another position still holds cold of that dust size, which `laggedNow` then reads as warm; bounded by about 1e-21 of the cold amount relatively (under 1e-18 sIMD on a million-sIMD term), no economic effect. Also checked and holding for Q1(d): the unchecked block in `_lag` (every subtraction guarded by the comparison or min before it; `bank + (out - coldOut)` cannot wrap for any representable position); the uint128 saturation keeps the excess in the vault total, the safe direction (see the NatSpec finding for the comment that says the opposite); `_pow`'s bound (factor and result at most `one`, products below 1e54 for RAY and 1e36 for 1e18); its gas over long gaps (at most 17 squarings for the cold and the lagged supply, elapsed being short-circuited at BACKING_WARMUP, about 27 for the base rate over a year of quiet). Smallest fix: reword the claim ('so the position's share is never read above the total, to within the truncation of `_pow`'), or compute the vault totals' decay with a rounding-up `_pow` (round each product up) so the totals dominate by construction.

**Reproduction**

Integer arithmetic over the committed constants (COLD_SECOND_DECAY = 999967910367635122012970996, RAY = 1e27, the `_pow` loop and `_cool` as written), run by me: pow(f, 86400) = 62499999999999999999995426 (ideal 0.0625e27); amount 1e30 cooled 2,932 s then 18,215 s rounded up = 507321505183320375430890732842, cooled once over 21,147 s rounded down = 507321505183320375430890735000: the two-step total is 2,158 raw units BELOW the one-step position figure; amount 601691055351260499632438899944742 cooled 1 s then 36,314 s rounded up = 187614705151815296225965817975788, once over 36,315 s rounded down = 187614705151815296225965818292158: 316,370 raw units below (1.7e-27 relatively). audit_math's test/scratch/PowMultiplicativity.t.sol and audit_economics' testFuzz_totalRoundsAboveThePosition report the same counterexamples against a copy of the two functions.

### 10. Info: NatSpec and comments that claim properties the committed code does not have after 24337a2 (the lagged fee base, the bank, the cold term, the premium bound, the saturation direction, the no-grace ratio

`src/CDPVault.sol:318`

```
    /// position's own cold first and counts at once. So capital brought in one transaction and withdrawn a
```

Merged from audit_math 70dddc45, audit_permissions d451c417, audit_economics cba55ee9 and audit_flow's list; each is the documentation half of a finding above, to reword to the behaviour the code has or to keep once the code is fixed. (1) Lines 318-319 ('capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par'), 759-760 ('An attacker's capital can raise the live figure but not the lagged one, whichever position it sits in') and 963-964 ('a bank only ever returns warmth ... within BACKING_WARMUP of the moment it first filled'): false for a position that was once seasoned, because a one-block return and departure empties and re-dates its bank (medium, `_lag` line 992). (2) Lines 334-337 ('an increase counts at once ... a repayment of warm principal only as it ages, its excess halving every BACKING_HALF_LIFE'), 893-895 ('a repayment in it or in the last few hours does not shrink the base') and 1427-1429 ('Only warm repayments lag'): a cold draw and repayment after a warm repayment erases the excess at once (medium, `_coldRepaidCountsAtOnce` line 1437); an increase that follows a warm repayment is absorbed by the fading excess rather than counted on top of it (the base is max(live, decaying high-water mark), so a redeemer after a dominant repayment and a new draw pays 95 bps where the documented model gives 74, the higher-fee direction, no attacker gain); and a repayment of cold principal does NOT count at once inside the transaction that redeems, because `_laggedSupplyFrom` floors at the pre-wipe `start` (medium, `_redemptionRate` line 906), so 334-336 and 893-895 contradict each other for the same call. (3) Lines 312 and 956 ('what a position adds is cold' / 'An increase is cold'): a price fall's re-pricing of a debt-bound term is cold although nothing was added (low, line 950). (4) Line 1016 ('so they never read below the sum of the positions'): not exact, by units of 1e-27 (info). (5) Lines 768-770, the accepted premium: 'at most supply / (supply - repaid)' holds only with no cold debt; the true bound is (supply - fresh) / (supply - fresh - repaid), and the parenthetical describes a churner at mat (low, line 769). (6) Lines 1002-1003 ('past 128 bits of raw units a position's excess over that counts as warm'): `total` receives the whole increase (line 999) while `cold` is capped (line 1004), so the excess is COLD in `laggedNow` until it cools, and on a later decrease the position's capped coldOut leaves it orphaned in the total; the opposite direction from the comment, the safe one, and unreachable at sIMD's supply (3.4e14 sIMD). (7) Lines 1123-1124 ('cannot recover by waiting'): a price rise that lifts the re-lock above the record returns it to the mark-and-grace path (low, line 1129). (8) Lines 729-730 ('counts at once, across transactions and inside this one'): a burn larger than the start supply wraps the slot instead (low, line 734). (9) Line 1016-1018 implicitly and Q1(a)'s premise: a position's quiet day zeroes its own figures but not its share of the total, which its decrease then cannot remove (low, line 1021). Checked and consistent: the Position struct comments (41-60), 983 ('Every subtraction below is guarded'), 316-317 (94% / 99.6% under activity), 242-256 the transient slots for the same call, cover's 557-569 and 591-595, `_coverDust` 651-658, `_relockBelowBadDebt`, `_recordBadDebt` 1446-1463, `totalBadDebt` 298-305, the rest of `_backingPerUnit` 750-767 and 771-772, ParameterizedVault.backedDebt 230-258 (its 'in either order' clause holds for the new per-position lag; 'real capital at risk ... not gas' holds for the ceiling's slow round trip only until the bank finding above is fixed), and ImdUSD throughout (single minter/burner bound at construction or once by APPROVED_OPERATOR, no admin, no pause, no upgrade). ANSWERS WHERE NOTHING IS WRONG. Q1(b): no sequence moves warmth between positions: a draw adds cold to its own position and the total, a cancellat

**Reproduction**

Each claim is refuted by the reproduction of the finding it documents: test/scratch/Proof_73d81e305a41.t.sol (claim 1), test/scratch/Proof_2f12b4a45902.t.sol and test/scratch/FeeDilution.t.sol (claim 2), test/scratch/PriceFallColdTerm.t.sol (claim 3), the integer-arithmetic counterexamples in the `_cool` info (claim 4), test/scratch/BackingBound.t.sol (claim 5), test/scratch/BiteRebuilder.t.sol (claim 7), test/scratch/SupplyStartWrap.t.sol (claim 8), test/scratch/OrphanCold.t.sol (claim 9). Claim 6: read `_lag` 996-1012: `total += after_ - before - credit` at 999 is uncapped while `cold` is capped at 1004. Baseline: `forge test` --no-match-path 'test/scratch/*' reports 588 passed, 0 failed, 4 skipped on this commit.

---

Judge's submission `70fb56bba8fd577545fc1af3cce479075cbbfbd363741c8f06987df1cc3fbf71`, 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.
