# Audit report

> Audit the vault: src/CDPVault.sol, src/ParameterizedVault.sol and src/ImdUSD.sol, as fixed through this commit, at the pinned commit, for a mainnet launch. Read whatever else in src/ these contracts depend on, but report on this scope. Three audit rounds and their fixes are already in (docs/AUDIT-*.md, newest docs/AUDIT-FINAL-2-2026-10-07.md and the fix commit after it); this panel audits the code as it will deploy, so 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. Positions: lock, lockIMD (credits shares by balance delta), free, draw, wipe. Below mat, double counting, funds moved for anyone but the caller, reentrancy through the share vault?
> 2. Liquidation (bark, barkFor, bite, heel, tail, lull, the mark's expiry): with CHOP_PERCENT 20 and the chip/cut split, can anyone receive more than the formula, can a position be frozen unliquidatable, and can the grace (lull, six hours at NHI >= 0.85) be gamed?
> 3. cover and its dust floor (_coverDust: the seizure for the larger of a millionth of the debt and one imdUSD, a hundredth under 100 imdUSD): can cover sweep collateral that is not dust, can a drained borrower still block it cheaply, can coverage exceed what is owed or desynchronise totalBadDebt from the per-position record? The second-half review's residual (docs/AUDIT-FINAL-2-2026-10-07.md finding 4) is fixed here; break the fix.
> 4. Redemption (cash): the fee base (redemptionDivisor), the fresh-debt record, candidate eligibility (mat + gap), the backingPerUnit cap and the LAGGED capital (laggedNow, BACKING_WARMUP). Can a redeemer pay less than the fee for its size, worsen a candidate's ratio, or drain the reserve below what backs remaining supply?
> 5. Bad debt and the Treasury's imdUSD (BadDebtFirst): any sequence of cover, withdraw, payStream that spends what outstanding bad debt needs?
> 6. Stability fee (duty, chi, drip, checkpoints): can a governed rate change reprice elapsed time, or chiOf exceed chi?
> 7. Price gating (_pricingStale, _requirePriceAgreement, skew): every value-moving action refused on stale or divergent feeds, the exceptions (lock, wipe, debt-free free) safe, and what a feed that cannot follow a gap for hours (SwarmFeed's allowance schedule) does to each action, liquidations included.
> 8. Work issuance (earn, earnLine, earnMat, backedDebt and its transient slot) with WAGE_WAD 0 at launch: can anything mint before governance turns the wage on, and can same-transaction debt or a reserve listing authorise unbacked minting once it is on?
> 9. Arithmetic: overflow at extreme collateral or price, rounding direction in every division that pays someone, units wherever a price, a 24-decimal amount and basis points meet.
>
> Not findings: addresses in DeploymentConfig that are placeholders until deployment (INTAKE, ORACLE_ASKER, TREASURY_FACTORY, WORK_ORACLE_FACTORY); the mocks (MockIMD, MockWorkOracle, LaunchToken); script/checks/ (a separate, partly stale tree); web/ and points/; anything docs/COMPUTE-BACKING-DESIGN.md describes as future work; and findings of the earlier audits in docs/AUDIT-*.md and docs/INTERNAL-AUDIT-2026-10-04.md, unless the fix regressed. A constant set to a deliberate economic value is not a finding; an arithmetic or ordering error in how it is used is.
>
> For every finding: severity; file and function; the call sequence from an external caller; a concrete failing input or state with expected against actual; whether it is reachable with the constants as committed; and the smallest fix. Also report every place a comment or NatSpec claims a property the code does not have, and say which contracts you read in full and which you could not reach.

| | |
|---|---|
| Repository | https://github.com/fa11up/infer-protocol |
| Commit | `b73a05f0f9185bae139f46c56f044ed9c7391c4c` |
| Job | `6a5f4140-95a4-4011-aa75-cbf064127f7f` |
| Judged | 2026-10-07 18:29 UTC |
| Findings | 2 medium · 3 low · 3 info |

Four agents audited the code as it is at `b73a05f`, 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._clampLag: a borrower's own atomic repay-and-redraw (or free-and-relock) converts warm capital into fresh capital, so backingPerUnit falls although nothing left the system; with work-minted s

`src/CDPVault.sol:849`

```
        if (totalDebt < laggedDebt) laggedDebt = totalDebt;
```

Q4 (the LAGGED capital) and Q8. `_clampLag` (called from `_resecure` and `_reduceDebt`) lowers `laggedDebt` and `laggedSecured` to the live figure the moment either falls, while `_approach` only credits an increase at elapsed/BACKING_WARMUP per checkpoint and credits nothing within a block. The clamp never asks whether the capital comes straight back. A borrower who calls wipe(debtOf) then draw(the same figure) in ONE transaction (or free(x) then lock(x)) leaves live totalDebt / securedCollateral unchanged but the lagged copies clamped at the lower level for about a day (exponentially longer under activity, per the NatSpec at lines 288-296). `_backingPerUnit` (line 677-689) reads min(live, lagged) at every wage, so the figure every redeemer is paid against (cash, line 632) falls. Two regimes. (A) Wage nonzero with work-minted supply E outstanding (supply = D + E): after a borrower holding D_a of the debt churns, the lagged ratio is 1.7(D - D_a)/(D - D_a + E), below par whenever 0.7(D - D_a) < E, i.e. for a borrower above 1 - E/(0.7 D) of the debt (64% at the maximum earnMat), and ZERO when one position holds all the debt: `cash` reverts ZeroAmount for everyone; recovery is 0.24 after one quiet hour and the churner can repeat every block. (B) Launch configuration, wage 0, E = 0: the debt side cancels but the collateral side does not; when the collateral term binds (after a price fall), a healthy borrower who frees down to 170% and re-locks removes its surplus from laggedSecured: 1.00 -> 0.90 in the reproduction, for the next day. Who profits: the churning borrower when it is the candidate being redeemed against (its debt is cancelled for backing x (1 - fee) of collateral per imdUSD instead of par) and, for the reserve-funded part, the Treasury; who loses: every redeemer paid against the depressed figure (a redeemer with minGemOut set is refused instead), and the peg floor the cash() comment at lines 629-631 presents as min(1 - fee, backing). Reachable with the constants as committed in regime (B); regime (A) needs the wage governance intends to raise (DeploymentConfig.sol:168-172, a 48-hour proposal). NatSpec claims the code does not have: lines 294-296 ('capital brought in one transaction and withdrawn a few later cannot authorise ... a redemption at par') is silent on this direction; line 621 ('Paying pro-rata instead is exactly neutral on backing by construction') and lines 629-631 (peg floor min(1 - fee, backing)) do not hold while a clamp is in force, since the figure paid against is below the honest backing. Smallest fix that keeps the design (decreases count at once for everyone else): record per position the lagged amounts its own decrease clamped (coolingDebt, coolingSecured, cooledAt); when the same position's term or principal rises again within BACKING_WARMUP of cooledAt, add min(increase, cooling) directly to laggedSecured / laggedDebt (bounded by the live figure) instead of routing it through _approach. Alternatively defer the clamp to the end of the external call (compare live against lagged after the position change completes), which closes the atomic variant only. Merged from audit_flow (ad254d80); the launch variant was re-derived and reproduced independently.

**Reproduction**

Proof (fails on this code): test/scratch/Proof_ad254d800307.t.sol. ParameterizedVault over an 18-decimal IMD at $1 (IMD/ETH 1/2000 x ETH/USD 2000), NHI 0.85 (mat 170), wage 0.01 applied through Parameters after the 48-hour timelock. A contract borrower locks 2,000 IMD and draws 1,000 imdUSD; a day later a rights holder earns 250 imdUSD (the ceiling); a day later everything is warm: backingPerUnit() == 1e18. The borrower calls wipe(debtOf(self)) then draw(the same figure) in ONE transaction. Expected: same 2,000 collateral and same principal afterwards, so backingPerUnit() stays 1e18 and cash(10e18, 0, borrower) pays about 9.95 IMD. Actual: 'an atomic round trip must not move backing below par: 0 < 999000000000000000' (laggedDebt 0, laggedSecured 0); cash(10e18, 0, borrower) reverts ZeroAmount(). Launch variant, reproduced in test/scratch/Judge.t.sol test_launchCollateralChurnLowersLaggedBacking (passes as a demonstration): wage 0, borrower A 2,000 IMD / 1,000 debt, borrower B 38,000 IMD / 1,000 debt, price falls to $0.05 (A 10%, B 190%), both touched at the new price and warmed: backingPerUnit() == 1e18. B calls free(3,990) then lock(3,990) in one transaction. Expected 1e18. Actual backingPerUnit() == 900250000000000000, laggedSecured 36,010e18 against securedCollateral 40,000e18.

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

    constructor(uint256 v) {
        value = v;
    }

    function set(uint256 v) external {
        value = v;
    }

    function latestValue() external view returns (uint256, uint64) {
        return (value, uint64(block.timestamp));
    }

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

contract ChurnMirror is ISwarmFeed {
    ISwarmFeed private immutable p;

    constructor(ISwarmFeed p_) {
        p = p_;
    }

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

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

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

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

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

/// @dev A borrower that repays and re-borrows inside ONE transaction, so the round trip is atomic
/// and nothing leaves the system: the same collateral and the same debt afterwards.
contract Churner {
    ParameterizedVault private immutable vault;

    constructor(ParameterizedVault v) {
        vault = v;
    }

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

    /// @dev Wipe everything (principal plus the accrued fee) and draw the same figure back.
    function churnDebt() external {
        uint256 debt = vault.debtOf(address(this));
        vault.wipe(debt);
        vault.draw(debt);
    }
}

/// @notice FINDING: the lagged backing (`laggedNow`) is clamped DOWN at once on any decrease and only
/// warms back up over a day, so a borrower who repays and re-borrows in one transaction converts warm
/// capital into fresh capital at will. Nothing left the system, yet `backingPerUnit()` — the figure
/// every redeemer is paid against — falls, and with work-minted supply outstanding it falls to ZERO,
/// which makes `cash` revert `ZeroAmount` for everyone for up to a day. Repeatable for gas.
/// This test fails on the committed code and passes once a same-position re-add within
/// BACKING_WARMUP restores the lagged figure the decrease clamped (or the clamp is otherwise closed).
contract LagChurnTest is Test {
    address private constant WORKER = address(0xCA);
    address private constant REDEEMER = address(0x4E1);
    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    MockWorkOracle private oracle;
    Churner private churner;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new ChurnAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        // 1 IMD = 1/2000 ETH and 1 ETH = $2000, so the vault prices IMD at exactly $1 per 1e18 raw.
        ChurnFeed primary = new ChurnFeed(uint256(1 ether) * 1e18 / 2000 ether);
        ChurnFeed health = new ChurnFeed(0.85 ether);
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new ChurnMirror(primary))
        );
        stable = vault.stablecoin();
        oracle = MockWorkOracle(address(vault.oracle()));
        churner = new Churner(vault);
        vm.startPrank(APPROVED_OPERATOR);
        oracle.grantRights(WORKER, 1_000 ether);
        imd.mint(address(churner), 10_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();
    }

    /// @dev With work-minted imdUSD outstanding, the dominant borrower's atomic wipe-and-redraw drives
    /// the lagged figure to zero: every redemption reverts for a day although the system is fully backed.
    function test_atomicRepayAndRedrawDoesNotChangeBackingOrBlockRedemption() public {
        churner.open(imd, 2_000 ether, 1_000 ether); // 200%, well above mat 170
        vm.warp(block.timestamp + vault.BACKING_WARMUP() + 1); // the debt is warm
        vm.prank(WORKER);
        vault.earn(250 ether); // the ceiling: 1000 x 2500 / 10000
        vm.warp(block.timestamp + vault.BACKING_WARMUP() + 1); // everything warm
        vm.startPrank(WORKER);
        stable.transfer(REDEEMER, 100 ether);
        stable.transfer(address(churner), 10 ether); // for the fee the churner accrued meanwhile
        vm.stopPrank();

        assertEq(vault.backingPerUnit(), 1e18, "fully backed: 2000 of collateral behind 1250 of supply");

        churner.churnDebt(); // one transaction: wipe principal + fee, draw the same figure back

        (uint256 collateral, uint256 debt) = vault.positions(address(churner));
        assertEq(collateral, 2_000 ether, "same collateral");
        assertGe(debt, 1_000 ether, "same principal (plus the fee it just converted)");
        assertGe(vault.backingPerUnit() , 1e18 - 1e15, "an atomic round trip must not move backing below par");

        vm.prank(REDEEMER);
        uint256 out = vault.cash(10 ether, 0, address(churner));
        assertGt(out, 9 ether, "redemption must stay open and pay about par less the fee");
    }
}
```

### 2. Medium: ParameterizedVault._lagApplies keys the D1 work-ceiling lag to wage != 0, but earn mints at wage 0 from rights the shipped SwarmWorkOracle already credited (after an ordinary wage shutdown) or from a

`src/ParameterizedVault.sol:112`

```
        return parameters.wage() != 0;
```

Q8. `backedDebt()` (lines 250-259) applies `laggedNow()` only while `_lagApplies()` is true, and `_lagApplies` answers `parameters.wage() != 0`. But whether anything can mint from work is decided by `oracle().mintingRights(msg.sender)` (CDPVault.sol:480-481), which never reads the wage. Two shipped ways to be minting with the lag off. (1) Wage shutdown: SwarmWorkOracle.claim refuses at wage 0 (line 157) but `mintingRights` / `consumeRights` (lines 171-186) keep honouring rights credited earlier, priced at claim. After governance proposes wage > 0, a worker claims, and governance later returns the wage to 0 (documented normal operation), that worker can lock and draw in one transaction, earn against 25% of that zero-second debt in the next transaction of the same block (backedDebt counts it in full: `_debtAtTransactionStart` only excludes the current transaction), then wipe and free in a third. (2) Replacement oracle: Parameters.proposeWorkOracle is allowed ONLY while the wage is zero (Parameters.sol:385) and installs any contract answering vault(), mintingRights and (after a first mint) predecessor; a successor whose rights do not derive from wage() (the shipped MockWorkOracle qualifies; docs/PARAMETERS-2026-10-05.md plans a governed tariff per skill) turns minting on with `_lagApplies()` false. Either way `earnLine()` is the pre-D1 figure and the launch audit's vault-panel medium (D1) is open again: work-minted imdUSD outlives the debt that authorised it with no collateral and no reserve behind it (backingPerUnit 0). Reachability: not at first deployment (WAGE_WAD = 0, no rights); reachable with the committed code after governance has enabled wages and a worker has claimed (then disabled them), or after governance applies a replacement oracle (48-hour timelock). Inside the governance trust the design states, but the two governed paths are not equivalent as the NatSpec claims: raising the wage switches the lag ON, switching it off or installing an oracle switches it OFF while rights stay spendable. NatSpec claims the code does not have: ParameterizedVault.sol:109-110 ('applies exactly while minting from work is on'), CDPVault.sol:864-865 ('turns it on exactly when minting from work is on (a nonzero wage)'), DeploymentConfig.sol:171-172 ('Raising it also switches on the lagged backing'), Parameters.sol:239-244 ('so no rights are ever claimable in two oracles at once' and 'Adds no trust: a governor who could mint through a hostile oracle can already raise the wage'). Smallest fix: make the lag unconditional (`return true;`): the redemption half already reads it at every wage, the figures are tracked from deployment and warm, and with the shipped oracle at wage 0 nothing can earn, so an always-on lag changes nothing at launch. If a zero wage is meant to suspend spending saved rights as well, additionally refuse `earn` while `parameters.wage() == 0` without erasing the rights. Then correct the four comments. Merged from audit_economics (56252d7d, medium), audit_flow (30531255, medium) and audit_math (8f97703e, low): one root cause, one fix.

**Reproduction**

Proof (fails on this code): test/scratch/Proof_56252d7de5fa.t.sol, production ParameterizedVault, Treasury and SwarmWorkOracle accounting over a 24-decimal share at $79.50 per share, NHI 0.85, LINE 1,000,000. Governor proposes wage = 1e18 and applies after 48 h. Worker proves an accepted root for 250 cumulative tasks and claims 250e18 of rights without minting. Governor proposes wage = 0 and applies after 48 h; parameters.wage() == 0. With no existing debt or reserve, the worker locks $2,000 of shares and draws 1000e18 (laggedNow() debt == 0); the next transaction in the same block calls earn(250e18); the next calls wipe(1000e18) and free(all). Expected: earn is refused, or the zero-second debt contributes nothing to the work ceiling. Actual: every call succeeds, totalDebt == 0, vault collateral == 0, reserveValue() == 0 and totalSupply() == 250e18 held by the worker: 'saved rights minted against zero-second debt after wage was switched off: 250000000000000000000 != 0'. Variant (2), run independently from the specialist's proof Proof_30531255a349.t.sol (also fails on this code): wage 0 throughout, a TariffOracle successor applied through proposeWorkOracle, attacker credited 1,000 of rights; lock(2,000) + draw(1,000); one block later earnLine() == 250e18 where the D1 design gives about 0.14e18; earn(250e18), wipe, free: totalSupply() == 250e18 with nothing behind it. With `_lagApplies` returning true both tests pass (earnLine is 0 for a zero-second debt).

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

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

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

// Only environmental collateral/price/identity stand-ins; accounting is the production vault.
contract ERToken {
    string public name = "Shares";
    string public symbol = "sIMD";
    uint8 public constant decimals = 24;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    function mint(address to, uint256 amount) external { balanceOf[to] += amount; totalSupply += amount; }
    function approve(address spender, uint256 amount) external returns (bool) { allowance[msg.sender][spender] = amount; return true; }
    function transfer(address to, uint256 amount) external returns (bool) { balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true; }
    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount; balanceOf[to] += amount; return true;
    }
    function asset() external pure returns (address) { return address(0x1AD); }
    function convertToAssets(uint256 shares) external pure returns (uint256) { return shares * 795 / 100_000_000; }
}
contract ERFeed is ISwarmFeed {
    uint256 public immutable value;
    uint256 public constant maxAge = 1 days;
    constructor(uint256 v) { value = v; }
    function latestValue() external view returns (uint256, uint64) { return (value, uint64(block.timestamp)); }
    function isStale() external pure returns (bool) { return false; }
}
contract ERAggregator {
    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 ERAdapter {
    function isController(uint256, address) external pure returns (bool) { return true; }
}
contract ERWork is SwarmWorkOracle {
    constructor(address consumer) SwarmWorkOracle(consumer, 1 days) {}
    function seedRoot(bytes32 root) external { _accept(uint256(root), uint64(block.timestamp)); }
}
contract WageOffResidualTest is Test {
    ParameterizedVault vault;
    ERToken shares;
    ERWork work;
    ImdUSD stable;
    address constant BORROWER = address(0xB0B);
    address constant HOLDER = address(0xCAFE);
    uint256 constant PRICE = 79_500_000_000_000; // $10/IMD * 7.95 IMD/sIMD, per 1e18 raw shares

    function setUp() public {
        vm.warp(1_000_000);
        vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new ERAggregator()).code);
        vm.etch(ERC8004_ADAPTER, address(new ERAdapter()).code);
        shares = new ERToken();
        ERFeed primary = new ERFeed(5e15); // IMD = $10
        ERFeed nhi = new ERFeed(0.85e18);
        ERFeed spot = new ERFeed(5e15);
        address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);
        work = new ERWork(predicted);
        vault = new ParameterizedVault(address(shares), address(0), address(work), address(primary), address(nhi), address(spot));
        assertEq(address(vault), predicted);
        stable = vault.stablecoin();
        shares.mint(BORROWER, 30_000e24);
        vm.prank(BORROWER);
        shares.approve(address(vault), type(uint256).max);
    }
    function _wage(uint256 amount) private {
        Parameters p = vault.parameters();
        vm.prank(APPROVED_OPERATOR);
        p.proposeWage(amount);
        vm.warp(block.timestamp + 48 hours);
        p.applyPending();
    }
    function _lockDollars(uint256 dollars) private {
        vm.prank(BORROWER);
        vault.lock((dollars * 1e18 + PRICE - 1) / PRICE);
    }
    function test_zeroWageMustNotDisableLagForPreviouslyClaimedRights() public {
        _wage(1e18);
        bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(uint256(1), uint32(250), uint64(250)))));
        work.seedRoot(root);
        work.recordRoot();
        vm.prank(BORROWER);
        work.claim(1, 250, 250, new bytes32[](0), root);
        assertEq(work.mintingRights(BORROWER), 250e18);
        _wage(0);
        assertEq(vault.parameters().wage(), 0);
        _lockDollars(2000e18);
        vm.prank(BORROWER);
        vault.draw(1000e18);
        (uint256 lag,) = vault.laggedNow();
        assertEq(lag, 0);
        // Separate top-level calls are separate transactions (the repository's isolate=true).
        // No time elapses between borrowing, earning, repayment and withdrawal.
        vm.prank(BORROWER);
        (bool earned,) = address(vault).call(abi.encodeCall(vault.earn, (250e18)));
        if (!earned) assertEq(vault.totalEarned(), 0);
        vm.prank(BORROWER);
        vault.wipe(1000e18);
        (uint256 collateral,) = vault.positions(BORROWER);
        vm.prank(BORROWER);
        vault.free(collateral);
        assertEq(vault.totalDebt(), 0);
        assertEq(shares.balanceOf(address(vault)), 0);
        assertEq(vault.reserveValue(), 0);
        emit log_named_uint("unbacked work supply", stable.totalSupply());
        // Either earn must refuse while off, or its ceiling must retain the lag.
        assertEq(stable.totalSupply(), 0, "saved rights minted against zero-second debt after wage was switched off");
    }
}
```

### 3. Low: CDPVault.draw: the fresh-debt record's integer-second weighted date rounds to the present when a tranche dwarfs the record, and _reduceDebt then multiplies a zero age, so a large draw/wipe pair every

`src/CDPVault.sol:463`

```
                + Math.mulDiv(block.timestamp - position.mintedAt, amount, fresh + amount, Math.Rounding.Ceil);
```

Q4 (the fresh-debt record). `draw` moves `mintedAt` toward the present by ceil((now - mintedAt) x amount / (fresh + amount)). With fresh = 10e18 aged 39,600 s and amount = 400,000e18 the increment is ceil(39,599.01) = 39,600, so mintedAt becomes block.timestamp and the record's principal-time (39,600 s x 10e18) is discarded by the one-second resolution of the weighted date. `_reduceDebt` (lines 1166-1176) then tries to recover the old age as ceil((now - mintedAt) x fresh / remaining) = ceil(0 x ...) = 0, writes recentlyMinted = 10e18 and mintedAt = now: the 10e18 that has been outstanding for eleven hours is dated as minted this second. Repeating the pair every 11 hours keeps any amount of principal inside FRESH_DEBT_WINDOW indefinitely, which is the residual of findings 883fa030 and 5ee3f2bc (the revision notes at lines 454-458 and 1153-1165 state that principal-time is conserved; it is not when a tranche exceeds about (now - mintedAt) x the record). Consequence: `cash` against such a candidate reports freshCancelled == principalCancelled and stores `_redemptionRate(amount - freshCancelled)`, so the base rate everyone after pays is not raised by that burn; a sequence of tranches against a churned candidate each pays the floor plus its own increase instead of a ramping base. The current redeemer still pays the quoted fee. Reachable with the committed launch constants, no governance, no work issuance, no price manipulation; it needs temporary imdUSD within LINE (400,000 against about $2M of posted collateral in the reproduction, or more frequent smaller pairs). Smallest sound fix: keep the fresh record's principal-time in a finer unit (principal x seconds, or an 1e18-scaled weighted timestamp) so a tranche cannot round it to zero, and use that unit in both `draw` and `_reduceDebt`; reversing one rounding direction alone over-ages the residual instead. From audit_economics (221e5297), reproduced.

**Reproduction**

Proof (fails on this code): test/scratch/Proof_221e5297df38.t.sol. ParameterizedVault at NHI 0.85, DUTY 444, LINE 1,000,000e18, 24-decimal collateral at $79.50 per share. t0: lock collateral worth $2M, draw 10e18, send 1e18 to HOLDER. At t0+11h: draw(400,000e18) then wipe(400,000e18) with no time elapsed (fresh 10e18, age 39,600: ceil(39,600 x 400,000/400,010) = 39,600, mintedAt = now; the wipe computes age ceil(0 x 400,010/10) = 0). Repeat at +22h and +33h. Free the temporary collateral down to a 200% ratio (eligible below mat + gap = 220). HOLDER calls cash(1e18, 0, BORROWER). Expected: principal outstanding for 33 hours is not fresh, so redemptionBaseRate > 0 afterwards. Actual: freshCancelled == 1e18 and redemptionBaseRate == 0: 'round-trip rounding reset seasoned debt to fresh: 0 <= 0'.

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

// Only environmental collateral/price/identity stand-ins; accounting is the production vault.
contract ERToken {
    string public name = "Shares";
    string public symbol = "sIMD";
    uint8 public constant decimals = 24;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    function mint(address to, uint256 amount) external { balanceOf[to] += amount; totalSupply += amount; }
    function approve(address spender, uint256 amount) external returns (bool) { allowance[msg.sender][spender] = amount; return true; }
    function transfer(address to, uint256 amount) external returns (bool) { balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true; }
    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount; balanceOf[to] += amount; return true;
    }
    function asset() external pure returns (address) { return address(0x1AD); }
    function convertToAssets(uint256 shares) external pure returns (uint256) { return shares * 795 / 100_000_000; }
}
contract ERFeed is ISwarmFeed {
    uint256 public immutable value;
    uint256 public constant maxAge = 1 days;
    constructor(uint256 v) { value = v; }
    function latestValue() external view returns (uint256, uint64) { return (value, uint64(block.timestamp)); }
    function isStale() external pure returns (bool) { return false; }
}
contract ERAggregator {
    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 ERAdapter {
    function isController(uint256, address) external pure returns (bool) { return true; }
}
contract ERWork is SwarmWorkOracle {
    constructor(address consumer) SwarmWorkOracle(consumer, 1 days) {}
    function seedRoot(bytes32 root) external { _accept(uint256(root), uint64(block.timestamp)); }
}
contract FreshAgeResidualTest is Test {
    ParameterizedVault vault;
    ERToken shares;
    ERWork work;
    ImdUSD stable;
    address constant BORROWER = address(0xB0B);
    address constant HOLDER = address(0xCAFE);
    uint256 constant PRICE = 79_500_000_000_000; // $10/IMD * 7.95 IMD/sIMD, per 1e18 raw shares

    function setUp() public {
        vm.warp(1_000_000);
        vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new ERAggregator()).code);
        vm.etch(ERC8004_ADAPTER, address(new ERAdapter()).code);
        shares = new ERToken();
        ERFeed primary = new ERFeed(5e15); // IMD = $10
        ERFeed nhi = new ERFeed(0.85e18);
        ERFeed spot = new ERFeed(5e15);
        address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);
        work = new ERWork(predicted);
        vault = new ParameterizedVault(address(shares), address(0), address(work), address(primary), address(nhi), address(spot));
        assertEq(address(vault), predicted);
        stable = vault.stablecoin();
        shares.mint(BORROWER, 30_000e24);
        vm.prank(BORROWER);
        shares.approve(address(vault), type(uint256).max);
    }
    function _wage(uint256 amount) private {
        Parameters p = vault.parameters();
        vm.prank(APPROVED_OPERATOR);
        p.proposeWage(amount);
        vm.warp(block.timestamp + 48 hours);
        p.applyPending();
    }
    function _lockDollars(uint256 dollars) private {
        vm.prank(BORROWER);
        vault.lock((dollars * 1e18 + PRICE - 1) / PRICE);
    }
    function test_largeDrawAndWipeMustNotErasePrincipalAge() public {
        _lockDollars(2_000_000e18);
        vm.prank(BORROWER);
        vault.draw(10e18);
        vm.prank(BORROWER);
        stable.transfer(HOLDER, 1e18);
        uint256 started = block.timestamp;
        // Renew the age at 11 hours. 400,000 is over 39,599 * the original 10.
        for (uint256 i; i < 3; ++i) {
            vm.warp(block.timestamp + 11 hours);
            vm.startPrank(BORROWER);
            vault.draw(400_000e18);
            vault.wipe(400_000e18);
            vm.stopPrank();
        }
        assertGt(block.timestamp - started, 12 hours);
        // Withdraw the temporary collateral, keeping the candidate at 200% (eligible below 220%).
        (uint256 all, uint256 debt) = vault.positions(BORROWER);
        uint256 keep = (debt * 2 * 1e18 + PRICE - 1) / PRICE;
        vm.prank(BORROWER);
        vault.free(all - keep);
        assertLt(vault.collateralRatio(BORROWER), vault.redemptionCeilingCR());
        vm.prank(HOLDER);
        vault.cash(1e18, 0, BORROWER);
        emit log_named_uint("redemption base", vault.redemptionBaseRate());
        // The principal has remained outstanding for 33 hours; draw/wipe pairs should conserve its age.
        assertGt(vault.redemptionBaseRate(), 0, "round-trip rounding reset seasoned debt to fresh");
    }
}
```

### 4. Low: cover's dust path is feed-gated, so a drained borrower's lock(1) (one raw unit, gas only) makes every later cover require fresh, agreeing primary, spot, NHI and Chainlink legs; between purchased attes

`src/CDPVault.sol:530`

```
            _requireFreshFeeds();
```

Q3, break the fix. `cover` takes two paths: with position.collateral == 0 it burns the Treasury's imdUSD with no feed read; with any nonzero collateral it enters the dust path, whose first two statements (lines 530-531) are `_requireFreshFeeds()` and `_requirePriceAgreement()`. One raw unit of sIMD (1e-24 sIMD) re-locked by the drained borrower is far below `_coverDust` and is swept by the next cover, so it no longer blocks cover in capital, but it moves cover onto the gated path. The shipped price feeds are stale between bought attestations by design (PRICE_MAX_AGE = 1 hour, no keep-alive, DeploymentConfig.sol:32-38; the Treasury buys a refresh only for a fall), so whoever covers must first buy or wait for a primary and a spot attestation. The borrower repeats lock(1) after each cover for one lock's gas; `lock` reads no freshness. Bounded: one cover can retire the whole record once feeds are fresh, and feeds go fresh whenever anyone borrows or liquidates; no funds move to the borrower. Harm while blocked: the Treasury's imdUSD equal to the record stays behind BadDebtFirst (Treasury.withdraw, payStream). Reachable with the constants as committed. Smallest fix: decide the sweep without a price when the collateral cannot be reachable at any price, e.g. if position.collateral is below a small absolute raw threshold (or below `_oneWeiSeizure(_priceOrZero())` with a nonzero last price), sweep it to the surplus account and fall through to the no-feed path, keeping the two guards for anything larger. From audit_permissions (7e0bc475), reproduced independently.

**Reproduction**

test/scratch/Judge.t.sol test_coverDustPathIsFeedGatedAfterOneUnitRelock (passes as a demonstration). ParameterizedVault over an 18-decimal IMD at $1, NHI 0.85. Borrower A locks 2,000 and draws 1,000; KEEPER locks 20,000 and draws 5,000. Price to $0.50; bark(A); +6 h; bite(A, floor(2,000 x 0.5 / 1.2)) drains A (collateral 0, totalBadDebt > 0). KEEPER funds the Treasury with 500 imdUSD. Primary feed set stale. cover(A, 1e18) succeeds with no fresh feed (expected). A calls lock(1). cover(A, 1e18): expected to succeed (one raw unit backs nothing a bite could reach); actual reverts CDPVault.StaleFeed. With the feed fresh cover sweeps the unit and lands; A calls lock(1) again, the feed goes stale again, and the next cover reverts StaleFeed again.

### 5. Low: The dust-floor fix holds in capital, but a drained borrower's single $1.20 re-lock keeps cover blocked until someone pays bark, six hours of grace and an exactly sized bite for about $0.18 of collater

`src/CDPVault.sol:533`

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

Q3, break the fix (docs/AUDIT-FINAL-2-2026-10-07.md finding 4). `_coverDust` (lines 581-587) now sweeps only collateral below the seizure for max(debt / 1e6, min(debt / 100, 1 imdUSD)), so blocking cover costs collateral worth about 1.2 imdUSD (1.38e22 raw sIMD, about 0.0138 sIMD, at the launch collateral price of 86,814,000,000,000 per 1e18 raw) for any record from 100 imdUSD to 1,000,000 imdUSD. No way was found to keep a record uncoverable for free: the fix holds as stated. Two residuals. (1) The cost is paid per BITE, not per cycle, and nobody but the protocol's keeper will bite: the collateral at or above the floor can only be reached through a fresh mark (the drain's mark has expired by the time the griefer re-locks), the full lull (six hours at NHI >= 0.85) and a bite sized to exactly floor(collateral x price / 1.2e18) (a one-wei bite leaves a remainder above `_oneWeiSeizure` that is not swept, line 980; an oversized bite reverts InsufficientCollateral, line 955-956). That bite burns about 1.0 imdUSD and pays the liquidator about 1.18 imdUSD of sIMD (1.16 when it did not mark): two mainnet transactions for about $0.18, so the cycle length is set by the operator's keeper, and until it acts the record stays uncoverable, the Treasury's imdUSD equal to it stays behind BadDebtFirst, and fees accrue on the record. (2) The NatSpec at line 580 ('blocking cover now costs collateral worth about 1.2 imdUSD every cycle, which goes to the surplus account') is wrong on the destination: a bite pays collateralSeized less the protocol cut and the marker cut to the liquidator (lines 990-996); only the protocol cut (10% of the 20% bonus, about $0.02) reaches the Treasury. Only collateral BELOW the floor is swept to the surplus account. Griefing only, no funds move to the borrower, reachable with the constants as committed. Smallest fix: let `bite` skip the mark and grace for a position whose `_recordedBadDebt` is nonzero (it has been drained once; the grace exists for a borrower who could recover), so the keeper's cost per cycle is one transaction; or let `cover` sweep any collateral on such a position whose value at `price` is below its recorded bad debt, which turns the re-lock into a donation to the surplus account as the comment intends. Correct line 580 either way. Merged from audit_flow (87cf642a), audit_math (608ba566) and audit_economics (f0a34598, the line-580 half); reproduced independently.

**Reproduction**

test/scratch/Judge.t.sol test_coverFloorCollateralGoesToTheLiquidator (passes as a demonstration). 18-decimal IMD at $1, NHI 0.85 (lull 6 h, tail 1 h). Borrower A (2,000 / 1,000) is drained by a crash to $0.50, mark and bite; price back to $1; the mark expires; the Treasury holds 500 imdUSD; A's record is above 100 imdUSD so _coverDust == 1.2e18 raw ($1.20). A locks exactly 1.2e18 raw. cover(A, 1e18) reverts NoRealizedBadDebt (expected per the fix). bite(A, 1e18) reverts MarkExpired; after bark(A) it reverts GracePeriodNotElapsed; after 6 h bite(A, 1e18) (the exact size: the seizure 1.2e18 fits) succeeds. Expected per line 580: the $1.20 goes to the surplus account. Actual: the liquidator receives 1,180,000,000,000,000,000 raw ($1.18, it was also the marker) and the Treasury 20,000,000,000,000,000 raw ($0.02); positions(A).collateral == 0 and cover(A, 1e18) then succeeds. At the sIMD scale the same cycle costs the griefer 13,822,655,332,089,294,353,446 raw (0.0138 sIMD, about $1.20) per bite that the operator's keeper pays for.

### 6. Info: cover's function NatSpec still describes the superseded dust rule ('under a millionth of its debt, at least the seizure for one wei'); the code sweeps up to the seizure for one imdUSD (a hundredth of

`src/CDPVault.sol:512`

```
    /// holding dust worth under a millionth of its debt (at least the seizure for one wei), which is
```

NatSpec claims the code does not have. (1) Lines 510-513 say cover only cancels debt behind 'a drained position, or one holding dust worth under a millionth of its debt (at least the seizure for one wei)'. Since b73a05f `_coverDust` (lines 581-587) sweeps anything below the seizure for max(debt / 1e6, min(debt / 100, 1e18)): for a 100 imdUSD debt that is collateral worth up to 1.2 imdUSD (1.2% of the debt), for 50 imdUSD up to 0.6 imdUSD, for 1,000,000 imdUSD still 1.2 imdUSD. The inline comment at 526-527 and `_coverDust`'s own NatSpec are correct; the function-level @dev that readers and ABI docs quote is not. (2) Lines 278-279 say totalBadDebt is 'reduced only by repaying the position's debt (`wipe`, or `cover` with the protocol's surplus imdUSD)'. Any path through `_reduceDebt` reduces it (lines 1181-1189): `cash` against a drained-then-relocked candidate and a `bite` of re-locked collateral do as well. Fix: at 512 'worth under about 1.2 imdUSD (the seizure for one imdUSD of debt, or a hundredth of a debt under 100 imdUSD, or a millionth of a debt over a million)'; at 278 'reduced only when the position's debt is repaid or cancelled (wipe, cover, bite, cash)'. Merged from audit_math (608ba566, the 512 and 278 halves), audit_economics (f0a34598), audit_flow (bda21ff5) and audit_permissions (3e3f7663).

**Reproduction**

Position with debt 100e18 and collateral worth 1.13 imdUSD (1.3e22 raw at price 86,814,000,000,000; 1.13% of the debt). Expected per line 512: cover reverts NoRealizedBadDebt, the collateral being far above a millionth of the debt. Actual: _coverDust == 13,824,884,792,626,887,383,465 raw > 1.3e22, so cover sweeps it to the Treasury and retires the debt. For 278: in test/scratch/Judge.t.sol the bite of A's re-locked 1.2e18 raw runs _reduceDebt with _recordedBadDebt[A] != 0 and lowers totalBadDebt by the 1e18 repaid before cover is called.

### 7. Info: earnLine's NatSpec ('Parameters caps the ratio at half that cliff') and DeploymentConfig's EARN_MAT_BPS comment ('5000 at the loosest NHI ... 120% worst-case backing') use the pre-170 mat: with mat 17

`src/ParameterizedVault.sol:266`

```
    /// Parameters caps the ratio at half that cliff.
```

NatSpec claim the code does not have, the documentation half of the 2026-10-05 vault panel's finding 15, which corrected Parameters.sol:82-83 ('7000 is the cliff ... at 2500 it is 136%') and left the same derivation in ParameterizedVault.earnLine (lines 262-266) and DeploymentConfig.sol:146-147 ('which is 5000 at the loosest NHI. 2500 is half that cliff, 120% worst-case backing with an empty reserve'). CDPVault._mat (line 1254) returns 170 at NHI >= 0.85, so mat - 1 = 0.70 = 7000 bps; MAX_EARN_MAT_BPS = 2500 is 2500/7000 = 0.357 of it; with an empty reserve, debt D fully drawn at mat 170 and E = 0.25 D minted, backing is 1.70 D / 1.25 D = 1.36. No behaviour depends on it, but the two source files now state different safety margins for the same constant, and a reader sizing a proposeEarnMat from them believes the cap sits at half the cliff with 20% headroom. Fix: '7000 bps at mat 170; 2500 is about a third of it, 136% worst-case backing' in both files, or point both at Parameters.MAX_EARN_MAT_BPS. Merged from audit_flow (95a91a28) and audit_permissions (bfd16097).

**Reproduction**

Compute with the committed constants: mat() at NHI 0.85 == 170 (src/CDPVault.sol:1254); mat - 1 == 70% == 7000 bps; Parameters.MAX_EARN_MAT_BPS() == 2500; 2500 / 7000 == 0.357, not 0.5; worst case (reserve 0, collateral 1.7 D, supply 1.25 D) is 1.36, not 1.20. Expected per the two comments: 5000 bps, half, 120%. Actual: 7000 bps, five-fourteenths, 136%, as src/Parameters.sol:82-83 already states.

### 8. Info: ParameterizedVault's header says the governor can change 'never where the price comes from'; the governor chooses every non-collateral reserve asset's price source (Parameters.proposeReserveAsset) and

`src/ParameterizedVault.sol:27`

```
/// are, never where the price comes from, where the revenue goes, or which contract governs.
```

NatSpec claim the code does not have, the documentation half of the 2026-10-05 vault panel's finding 7 (whose code half, Treasury.validateReserveAsset lines 155-161 pinning the collateral token to collateralPriceFeed, is in). The statement is true of collateral pricing: the three feeds, usdPriceFeed and collateralPriceFeed are immutables. It is not true of the two other price-bearing inputs the vault reads: Parameters.proposeReserveAsset(asset, priceFeed, haircutBps) lets the governor list any non-collateral token the Treasury holds against any ISwarmFeed-shaped source after the 48-hour delay, and that source's value is the first term of earnLine (line 268, through reserveValue) and the `others` term of _redemptionReserveBacking (line 165), which sets every redemption payout through _backingPerUnit; and Parameters.proposeWorkOracle replaces the oracle earn mints against. Both are governed behind the timelock, visible for two days and bounded (MAX_RESERVE_VALUE per asset, wage 0 for the oracle): the intended power of the role, to be stated as a trust assumption rather than denied. Fix: 'never where the COLLATERAL price comes from (reserve assets other than the collateral are priced by the source governance lists for them, and the work oracle is governed behind the same delay), where the revenue goes, or which contract governs.' Merged from audit_flow (198401c4) and audit_permissions (7856a406).

**Reproduction**

APPROVED_OPERATOR calls parameters.proposeReserveAsset(token, anyFeed, 10000) for a non-collateral token the Treasury holds; after TIMELOCK anyone calls applyPending(). Expected per the header: no governance action changes where a price the vault reads comes from. Actual: Treasury.reserveAsset(token).priceFeed == anyFeed, and vault.reserveValue(), vault.earnLine() and vault.backingPerUnit() move with anyFeed.latestValue() (test/ReserveValuation.t.sol exercises the same path).

---

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