# Audit report

> Audit the changes since 6085c8a and what they touch: src/CDPVault.sol, src/ParameterizedVault.sol, src/Treasury.sol, script/DeployMainnet.s.sol and deploy/mainnet/, at the pinned commit, for a mainnet launch. Read whatever else in src/ these depend on, but report on this scope. Ten audit rounds and their fixes are already in (docs/AUDIT-*.md). The newest is docs/AUDIT-FINAL-SWEEP-PANEL-2026-10-08.md, a panel over the whole system at 6085c8a that found nothing above low; its fixes are git diff 6085c8a 07905bb -- src script deploy, and its Resolution section says how each finding was answered. That diff is what no panel has read and what to break first. A finding of an earlier round counts only if its fix regressed or left a gap. Three items are accepted with their reasons stated where they live, and are findings only if the reason is wrong: the repay-then-redeem premium (CDPVault._backingPerUnit), a redraw after a redemption releasing a repayment's fee share early (CDPVault._lag), and a debt-side orphan overstating the backing cap's lagged figure by a bounded amount (CDPVault._cool).
>
> imdUSD is a dollar-denominated CDP stablecoin borrowed against sIMD (IdentityMD's staked IMD, an ERC-4626 share with 24 decimals, about 7.95 IMD each). Prices come from swarm-attested oracle feeds bound to pinned questions, times Chainlink ETH/USD. Everything about the deployment is in src/DeploymentConfig.sol and docs/MAINNET-RUNBOOK.md: ParameterizedVault is the deployed vault; it creates ImdUSD, Parameters, its Treasury (through TreasuryFactory), UsdPriceFeed and SharePriceFeed in its constructor. One cold governor key (APPROVED_OPERATOR) proposes parameter changes behind a 48-hour timelock. Collateral pricing is per 1e18 raw units throughout. IMD's only market is a full-range Uniswap v4 pool, about $2.3M a side with a 1% fee; docs/PARAMETERS-2026-10-05.md has the numbers every economic parameter was chosen from.
>
> Answer each numbered question, including the ones where nothing is wrong:
> 1. THE RESERVE'S SHARE (CDPVault._backingPerUnit). The lagged figure is now (reserve x warm / supply + warm secured collateral) / warm, warm = supply - fresh, supply = live + REPAID_THIS_TX_SLOT. Prove or break: no sequence, in one transaction or across several, lets capital brought in (by lock, draw, wipe, cover, a donation of collateral to the Treasury, or work minted) raise what a redemption is paid above the honest backing of the book it found, at launch (most supply new) or later; quantify how far an honest redeemer can be UNDERPAID by a large new loan or by work-minted supply (warm with no collateral), and whether either is a cheap grief on redemptions or the peg; check that REPAID_THIS_TX_SLOT and the reserve share interact correctly (the slot inflates supply, so it also shrinks the reserve's share), and that min(live, lagged) is still the figure every payout path reads (cash, the reserve route, the mixed route).
> 2. THE FEE-BASE FLOOR (_feeBaseFloor, 100,000 imdUSD; _feeBase; _redemptionRate; the stored base rate in cash). Prove or break: the cheapest way to store the cap for everyone at launch, now; whether the floor can be used to dilute fees for anyone once the warm base is past it; what the floor costs honest redeemers while the protocol is small (a weaker brake on a run when the warm supply is far below 100,000), and whether that matters for the peg given the payout is capped at backing.
> 3. THE VAULT'S DEPLOY SALT (DeployMainnet.plan, runVault, _record, PUBLIC_VAULT_SALT; docs/MAINNET-RUNBOOK.md sections 6 and 7). The salt is read from VAULT_SALT and stage two is broadcast through a private relay (MEV Blocker). Prove or break: anything committed, recorded or broadcast before stage two lands that reveals the vault's address or salt; what happens if the private relay leaks or delays the transaction, if stage two is rerun, or if a vault already sits at the address (the 'exists, skipped' path then verify); whether stage one's record, check() and plan.py still give the operator and the keeper everything they need.
> 4. EVERYTHING THE DIFF TOUCHES, for regressions: the redemption invariant's model, cover and bite after the cash changes, the Treasury's fundOracle (unchanged code, rewritten NatSpec), and contract size (ParameterizedVault initcode 46,679 of 49,152 bytes).
> 5. Every comment, NatSpec or runbook line changed in the diff that claims a property the code does not have.
>
> 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 | `07905bb419eb856975e19c77a7b4006575d3b2c2` |
| Job | `fc96f209-e004-42c6-bea0-728288548eee` |
| Judged | 2026-10-08 20:44 UTC |
| Findings | 1 medium · 2 low · 4 info |

Four agents audited the code as it is at `07905bb`, 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.draw/_backingPerUnit: a band position's debt-only draw adds cold debt but no cold secured term, so the lagged figure keeps that collateral against the warm supply and overstates backing; a on

`src/CDPVault.sol:492`

```
        _lag(position, false, position.debt, position.debt + amount);
```

Root cause. draw() records the new principal as cold debt (line 492), then _resecure re-derives the term as min(collateral, 2 x principal / price). For any position whose term is already its whole collateral (ratio <= 200% at the term's price, which is every position in the 170-200% band) the term does not change, so _lag(position, true, before, current) returns at after_ == before and no cold secured is recorded. _backingPerUnit's lagged figure (line 790) is reserve x warm / supply + lagSecured / warm with warm = supply - fresh: the new debt and its imdUSD leave the warm supply, but the collateral that now also stands behind them stays whole in lagSecured. The lagged figure therefore keeps the pre-draw backing while the honest (live) figure falls by up to fresh_band / supply (a draw from 200% to 170% adds 17.6% of the principal). While the live figure binds nothing happens; but the live figure is exactly what fresh capital raises: a newcomer's lock + draw at or above 200% in one transaction lifts the live figure above the lagged one (its own collateral and debt are cold, so the lagged figure is untouched), and a cash() in the next transaction is paid min(live, lagged) = the overstated lagged figure, capped at par, from the reserve (or a candidate). The newcomer unwinds afterwards: the D1 round trip the lag exists to close. This is not any of the three accepted items: the repay-then-redeem premium is the repay direction; the _cool item (final sweep panel #4) is the same arithmetic only after the position has gone stale for a day (a sixteenth of it); here the full fresh draw counts, decaying with the 6-hour half-life. The NatSpec at 757-759 ('An attacker's capital can raise the live figure but not the lagged one ... new debt and the imdUSD minted against it are excluded together') is the property the code lacks: the imdUSD is excluded, the collateral it was drawn against is not. Preconditions: a band position's draw within the last hours and a book below par (a price fall after the draw, or a book already below par from underwater positions/bad debt, in which case a warm band borrower can run the whole sequence itself); a reserve (Treasury sIMD) or an eligible candidate. Bounded by fresh_band / warm and by the gap to par; the fee (>= 0.5%) is the only cost besides gas and a one-block collateral lock. Loser: every holder (reserve) or the candidate. Reachable with the committed constants (mat 170 at NHI >= 0.85, LINE 1M, wage 0). The redemption invariant cannot catch it: its _laggedPerUnit (test/Redemption.invariant.t.sol:277) mirrors the code's formula. Smallest fix (write side, in draw): remember termBefore = position.secured before _resecure; if the term is unchanged and nonzero, cool a pro-rata slice of it with the new debt: `if (position.secured == termBefore && termBefore > position.coldSecured) _lag(position, true, termBefore, termBefore + Math.min(Math.mulDiv(termBefore, amount, position.debt), termBefore - position.coldSecured));` (moves the position's and the vault's cold secured, not securedCollateral). Checked on a copy: with it the proof passes (after the newcomer 0.878 against an honest 0.897; paid 8,518 raw IMD against at most 8,705). A read-side fix (scaling lagSecured by lagDebt/totalDebt) would double-exclude a newcomer whose collateral is already cold.

**Reproduction**

forge test --match-path test/scratch/BandDraw.t.sol (the proof below; specialist proof Proof_75bcc4265fd5 re-run unchanged). ParameterizedVault over MockIMD at $1 (Chainlink 2000e8 etched), NHI 0.85 (mat 170), Treasury holds 10,000 IMD. BORROWER lock(200_000e18), draw(100_000e18); +2 days. BORROWER draw(17_000e18) (term stays 200,000). Next block IMD to $0.50: backingPerUnit() == 897435897435897435 = (5,000 + 100,000)/117,000 (lagged reads 1.043). NEWCOMER lock(400_000e18), draw(100_000e18) in one tx; next block backingPerUnit() == 1e18 and NEWCOMER cash(5_000e18, 0, address(0)) is paid 9,700e18 raw IMD from the reserve. EXPECTED: at most 0.897e18 and 8,705.1e18 raw IMD. ACTUAL: 1.0e18 and 9,700e18 (995 IMD, $497, over the honest payout). Test fails on 07905bb with 'fresh capital lifted a redemption's backing: 1000000000000000000 > 897435897435897435'; passes with the fix above applied in a scratch copy.

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

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

// A band position's draw (170-200%: its term is its whole collateral) adds COLD debt and no cold secured term.
// _backingPerUnit's lagged figure takes that debt out of the supply but leaves the collateral that now stands
// behind it in the lagged secured term, so the lagged figure reads ABOVE the honest backing of the book, by up to
// fresh/(supply - fresh) (17.6% for a position drawn from 200% to 170%). The live figure catches it, but the live
// figure is what a newcomer's one-transaction-old capital raises, so lock+draw in one transaction and cash in the
// next is paid the overstated lagged figure: the D1 round trip the lag exists to close.
//
// Fails on 07905bb: honest 0.897, after the newcomer 1.000, and 5,000 imdUSD is paid 9,700 raw IMD from the
// reserve where the honest payout is at most 8,705. Passes once a debt-only draw in a collateral-bound position
// cools a pro-rata slice of the term (see the finding's fix).

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

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

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

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

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

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

contract BdMirror 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 BdAggregator {
    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 BandDrawLagTest is Test {
    address private constant BORROWER = address(0xB0B);
    address private constant NEWCOMER = address(0xC0DE);

    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    BdFeed 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 BdAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new BdFeed(DOLLAR);
        BdFeed health = new BdFeed(0.85 ether); // mat 170
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new BdMirror(primary))
        );
        stable = vault.stablecoin();
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(BORROWER, 200_000 ether);
        imd.mint(NEWCOMER, 400_000 ether);
        imd.mint(address(vault.treasury()), 10_000 ether); // the reserve
        vm.stopPrank();
    }

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

    function test_bandDrawLetsNewcomerLiftRedemptionToPar() public {
        // A warm book: one borrower at 200% (term = its whole collateral = 2 x principal).
        vm.startPrank(BORROWER);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(200_000 ether);
        vault.draw(100_000 ether);
        vm.stopPrank();
        vm.warp(block.timestamp + 2 days);
        _next();
        // The borrower draws down to ~171%: 17,000 of cold debt, and the term (its whole collateral) is unchanged.
        vm.prank(BORROWER);
        vault.draw(17_000 ether);
        _next();
        // IMD halves: the book is below par.
        primary.set(DOLLAR / 2);
        _next();
        uint256 honest = vault.backingPerUnit();
        emit log_named_uint("honest, the live figure (5,000 + 100,000) / 117,000", honest);
        assertLt(honest, 1e18, "the scenario needs a book below par");
        uint256 feeBps = vault.redemptionFeeBps(5_000 ether);
        (uint256 price,) = vault.collateralPriceFeed().latestValue();
        uint256 honestPay = Math.mulDiv(Math.mulDiv(5_000 ether, honest, 1e18) * (10_000 - feeBps) / 10_000, 1e18, price);

        // A newcomer brings capital in one transaction ...
        vm.startPrank(NEWCOMER);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(400_000 ether);
        vault.draw(100_000 ether);
        vm.stopPrank();
        _next();
        uint256 lifted = vault.backingPerUnit();
        emit log_named_uint("after the newcomer", lifted);
        // ... and in the next is paid from the reserve at that figure.
        vm.prank(NEWCOMER);
        uint256 paid = vault.cash(5_000 ether, 0, address(0));
        emit log_named_uint("paid for 5,000 imdUSD (raw IMD)", paid);
        emit log_named_uint("honest payout at most (raw IMD)", honestPay);

        assertLe(lifted, honest, "fresh capital lifted a redemption's backing");
        assertLe(paid, honestPay, "a redemption was paid above the honest backing of the book it found");
    }
}
```

### 2. Low: Runbook 7.2 sends the salt-carrying stage-two transaction to MEV Blocker's default endpoint, which shares transactions with searchers; the 'salt is not public before the vault exists' property the fin

`docs/MAINNET-RUNBOOK.md:351`

```
   (`--rpc-url https://rpc.mevblocker.io`), never a public mempool: the transaction carries the salt, and
```

Merged from audit_math (low), audit_flow (info), audit_economics (low), audit_permissions (low). runVault() calls CREATE2_FACTORY with bytes.concat(salt, _vaultInit(p)) (script/DeployMainnet.s.sol:268); the canonical deployer binds the address to (salt, initcode) only, so anyone holding the calldata can land the identical vault from any account. The runbook names https://rpc.mevblocker.io. MEV Blocker's own documentation (docs.mevblocker.io/concepts/order-flow-auction, fetched 2026-10-08): 'MEV Blocker RPC shares the transaction (without signature) with a permissioned or permissionless set of searchers'; the endpoint list (reference/api/transaction-endpoints) offers https://rpc.mevblocker.io/fullprivacy, 'Maximum privacy, no rebates', as the private option. An unsigned copy is enough: the salt is in the calldata. So the claims at script/DeployMainnet.s.sol:81-82 ('stage two is sent through a private relay (MEV Blocker), so the salt is not public before the vault exists'), src/SwarmFeed.sol:239 ('from a salt no one else knows, so no one else can deploy it first') and runbook 251-252 / 331-334 rest on a third party's no-frontrun policy, not on the transaction being private. Consequence if a recipient front-runs (after verifySeeded passed, so the feeds are honest): the copy is byte-identical and correctly wired (every child is created by the vault), the operator's transaction reverts inside the deployer (CREATE2 collision) after spending its gas, a rerun prints 'exists, skipped'; if the front-runner also locked and drew 1 wei in its bundle, verify() reverts at line 290 ('imdUSD: nonzero opening supply') or 319 and _record never writes the vault, parameters, treasury or stablecoin into deployment.json, which the keeper runs from; no entry point re-records an existing vault. No funds at risk, hence low. Also unstated: how to make the salt (the rehearsal uses cast keccak of a date and $RANDOM; a salt derived from a phrase is guessable). Smallest fix: runbook 7.2 and the script header name https://rpc.mevblocker.io/fullprivacy (or Flashbots Protect with hints off) and say builders still see it; generate VAULT_SALT as 32 random bytes (openssl rand -hex 32); give runVault a path that, when p.vault already has code, checks the codehash and records it without the opening-state requires. Or deploy the vault with plain CREATE from the deployer, which nobody can pre-empt.

**Reproduction**

State: stage one landed, first values relayed, verifySeeded passed. Operator runs, per runbook 7.2: VAULT_SALT=0x<secret> REFERENCE_IMD_ETH_WEI=... FOUNDRY_PROFILE=deploy forge script script/DeployMainnet.s.sol --sig runVault() --rpc-url https://rpc.mevblocker.io --broadcast. EXPECTED (DeployMainnet.s.sol:81-82, runbook 351-352): no one but the operator sees the salt until the vault exists. ACTUAL: the default endpoint forwards the transaction (unsigned) to its searcher set; calldata = salt ++ ParameterizedVault initcode to 0x4e59b44847b379578588920cA78FbF26c0B4956C. A recipient replaying it plus lock and draw(1) lands first; the operator's run then logs 'exists, skipped' (DeployMainnet.s.sol:263-266) and reverts in verify at 'imdUSD: nonzero opening supply' (line 290), so deployment.json gets no vault. Not a Foundry-reproducible property (off-chain relay policy); verified against docs.mevblocker.io on 2026-10-08, and the deploy-side consequences by reading _deploy, verify and _record (lines 262-272, 276-319, 458).

### 3. Low: CDPVault lag: new capital is counted by its warmed fraction in an average, so a loan k times the warm book lifts a below-par book's redemption figure to par in minutes (10 min at k = 9), contradicting

`src/CDPVault.sol:321`

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

From audit_permissions (low), reproduced. The lagged figure is (reserve x warm/supply + warm secured)/warm. A newcomer's cold capital halves every 6 hours, so after t seconds a fraction 1 - 2^(-t/6h) of both its debt and its secured term is warm. A newcomer at >= 200% contributes about 2 of secured value per warm unit of its debt, so averaged with a warm book backed at b < 1 the lagged figure reaches par once its warm fraction reaches about (1-b)/((2-b)k) for a loan k times the warm book: about 1.5% for k = 9 at b = 0.84, i.e. 10 minutes. The live figure is above par throughout (the newcomer's collateral), so min(live, lagged) = par and a reserve-funded cash() in that window pays par; the newcomer then wipes and frees. The NatSpec at 321-322 ('capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par') and at 757-758 ('An attacker's capital can raise the live figure but not the lagged one') state it as a property; 'a day' (316-320) is how long the newcomer takes to count in full, not how long it takes the payout to reach its cap. Gain bounded by (1 - b - fee) x the Treasury's sIMD (position-funded payouts stay pro rata via RedemptionWorsensRatio); cost: collateral worth ~2k times the warm book locked for the window (9,000,000 IMD, about $3.8M at $0.42, against a $2.3M-a-side market, so k = 9 is hard to source; k = 3 takes 29 minutes per the specialist), price exposure and duty. Hence low. Smallest fix: state the warm-fraction behaviour and its k-dependence at the lines above and in runbook section 7 (keep Treasury sIMD small while the book is thin); a code change (e.g. capping each fresh unit's lagged contribution at par, or counting new capital only after a half-life) is an economic-rule decision for the requester.

**Reproduction**

test/scratch/Q1Probe.t.sol::test_timeToPar (passes, logs): ParameterizedVault over MockIMD at $1, NHI 0.85, no reserve. OLD lock(200_000e18), draw(100_000e18); +2 days; IMD to $0.42: backingPerUnit() == 0.84e18. NEW lock(9_000_000e18), draw(900_000e18) in one tx; then backingPerUnit() is read every 60 s. EXPECTED per lines 321-322: capital brought in and withdrawn a few transactions later cannot authorise a redemption at par (the figure stays near 0.84 for hours). ACTUAL: backingPerUnit() == 1e18 after 10 minutes.

### 4. Info: CDPVault._backingPerUnit NatSpec: the new-borrower dilution is not 'as it does in the live figure' (the live figure rises), and 'two accepted cases' omits it; measured 0.88 -> 0.812 for an honest rede

`src/CDPVault.sol:765`

```
    /// figure and the live one stands. The lag underpays honest redemptions for hours in two accepted cases:
```

Merged from audit_flow (2 infos), audit_economics (info), audit_permissions (info). Q1's underpayment side: a fresh loan D dilutes the lagged figure's reserve term by D/(supply+D) while adding no warm collateral, so an honest reserve-funded redeemer is underpaid by about (reserve/supply_before) x D/(supply_before + D) until the loan warms. In the live figure the same dilution is outweighed by the newcomer's collateral, so the live figure RISES; 'as it does in the live figure' (line 764) is true of the reserve term only. The sentence at 765 then counts 'two accepted cases' of underpayment, leaving out this third (work-minted warm supply with no collateral would be a fourth, unreachable at launch with wage 0). Not a cheap grief: it needs a loan comparable to the whole supply with collateral at >= 170%, it only bites below par, and it reverses within a couple of hours as the same loan warms (and then lifts the figure, see the low finding on warm-up). The payout stays capped at backing, so the peg floor is not harmed. Q1 otherwise holds: REPAID_THIS_TX_SLOT is added to supply in both the reserve share and the divisor, so the reserve term stays reserve/supply exactly as in the live figure; cash() computes payoutScale once (line 716) and the reserve route and mixed route both read it. Smallest fix: reword 762-767 to count the dilution among the accepted underpayments with its bound, and drop 'as it does in the live figure'.

**Reproduction**

test/scratch/Q1Probe.t.sol::test_dilution (passes, logs): Treasury holds 20,000 IMD; OLD lock(200_000e18), draw(100_000e18); +2 days; IMD to $0.40: backingPerUnit() == 0.88e18. NEW lock(5_000_000e18), draw(900_000e18); next block backingPerUnit() == 812143724142315685 (lagged 8,000/1,000,000 + 80,000/100,000 plus one block of warming) while the live figure is par; +2 h: 1e18. EXPECTED per 764-765: the dilution is as in the live figure and not among the accepted underpayments. ACTUAL: the live figure is par, the payout figure 0.812, 7.7% under the book the redeemer found.

### 5. Info: CDPVault._feeBase NatSpec: storing the cap from the 100,000 floor does not cost 'the cap paid on all of it'; split into ninety 100 imdUSD burns the same 9,000 pays 249.75 of fee, not 450

`src/CDPVault.sol:1066`

```
    /// of it times the divisor (9,000 at 2), the cap paid on all of it. Under the floor a redemption's
```

Merged from audit_math and audit_economics (info). _redemptionRate adds each burn's increase to the decayed stored rate, and each burn pays the floor plus the rate including only its own increase, so slices pay 50, 55, ... 500 bps and store the same 0.045e18. The 9,000 threshold is right; the price is about 1.8x overstated (and the same row in docs/AUDIT-FINAL-SWEEP-PANEL-2026-10-08.md Resolution #1). With a 12-hour-seasoned own position in the 170-220% band as the candidate (accepted b952037a) the fee stays in the pinner's collateral. Q2 otherwise: the floor is a max, inert once the warm base passes 100,000, so it dilutes nobody's fee; while the warm supply W is under 100,000 a run raises the rate by W/200,000 at most, a weaker brake, but the payout is capped at backing so remaining holders lose nothing. Fix: reword to 'about 250 imdUSD of fee in small burns, 450 in one'.

**Reproduction**

test/scratch/PinSplit.t.sol (passes, logs): ParameterizedVault at $1, B lock(400_000e18) draw(100_000e18), Treasury 100,000 IMD, +12 s. test_oneBurn: cash(9_000e18) -> redemptionBaseRate 45000000000000000, fee 450.0. test_ninetyBurns: 90 x cash(100e18) in one block -> redemptionBaseRate 45000000000000000, fee 249.75. EXPECTED per line 1066: the cap paid on all of it (450). ACTUAL: 249.75.

### 6. Info: DeployMainnet.runVault: a rerun with a different VAULT_SALT deploys a second vault stack and overwrites deployment.json; the header's 'resumable ... never redeployed' no longer holds for the vault, an

`script/DeployMainnet.s.sol:160`

```
        bytes32 salt = vm.envOr("VAULT_SALT", bytes32(0));
```

From audit_permissions (info) and audit_flow. plan() derives p.vault from the environment; _deploy skips only when that address has code; nothing reads the stage-two record back. Same salt: 'exists, skipped', verify, record (fine). Any other nonzero salt (lost, retyped, regenerated as rehearse-fork.sh does per run) passes verifySeeded, deploys a second correct ParameterizedVault with its own imdUSD, Parameters, Treasury, oracle and feeds (~12.7M gas), and _record overwrites deployment.json, so the keeper follows the second vault while the first stays live. Operator-only, no funds lost. Fix: in runVault refuse when deployment.json already records a vault with code at a different address, or record keccak256(salt) in stage one and check it; add 'rerun with the same VAULT_SALT' to runbook 7.2.

**Reproduction**

On a fork after a successful stage two: VAULT_SALT=0xaa..aa forge script ... --sig runVault() --broadcast (vault A, deployment.json vault = A); then VAULT_SALT=0xbb..bb ... --sig runVault() --broadcast. EXPECTED per header lines 89-91: 'exists, skipped'. ACTUAL by the code path (lines 160-161, 224, 262-271, 458): p.vault is a new address with no code, the deployer is called, vault B is created, verify(p) passes, deployment.json now names B.

### 7. Info: Changed or adjacent comments that claim what the code does not: tail() 'all agree', runbook 'salts infer-protocol/mainnet/v1/<Contract>' for the vault, and run() 'reads the whole stack back' after the

`src/CDPVault.sol:1347`

```
    /// feeds' lifetimes (the spot feed and Chainlink are not read; at the shipped constants all agree).
```

(1) CDPVault 1347: tail() = min(price, NHI maxAge) = 1 hour; the four lifetimes do not agree (PRICE 1 h, SPOT 1 h, NHI 1 day, ETH_USD 2 h, DeploymentConfig 30, 39-41); presumably 'the minimum would be the same' was meant. (2) docs/MAINNET-RUNBOOK.md 242: 'salts infer-protocol/mainnet/v1/<Contract>' is now false for the vault, whose salt is the operator's secret (the paragraph below it says so). (3) runbook 238-240 and DeployMainnet header 89-92: run() 'reads the whole stack back off chain before writing deployment.json, which is what the keeper runs from'; since the split run() reads back only the feeds and asker (verifyFeeds, line 209) and records no vault; the keeper's record comes from runVault. Everything else changed in the diff was checked and holds: divisor NatSpec (CDPVault 183-185, Parameters 110-113, DeploymentConfig 128-130); BACKING_WARMUP 331-332 (_cool returns 0 at a day for banks too); cover 590-596 against the burn at 616; cash @notice; the premium figures 773-777; _cool 1035-1038 for the stale orphan (but see the medium finding for the unstale case); redemptionReserve (gem only); Treasury.fundOracle NatSpec against its try/catch; 'prior is never zero' (floor). Contract size: ParameterizedVault initcode 46,679 B (2,473 under EIP-3860), runtime 22,466 B (forge build --sizes). forge test (excluding test/scratch): 601 passed, 0 failed, 4 skipped. The redemption invariant's _laggedPerUnit mirrors the new formula exactly, so it cannot detect the band-draw gap. Read in full: src/CDPVault.sol (the lag, backing, fee, draw/wipe/cover/cash paths), script/DeployMainnet.s.sol (plan, run, runVault, _deploy, verify, _record), the diff 6085c8a..07905bb, runbook sections 6-7. Read in part: ParameterizedVault, Treasury.fundOracle, DeploymentConfig, Parameters, SwarmFeed (changed lines). Not reached: UsdPriceFeed, SharePriceFeed, ImdUSD, the factories, SwarmWorkOracle, OracleAsker beyond ask, deploy/mainnet/plan.py beyond the vault regex.

**Reproduction**

(1) read CDPVault.sol 1350-1352 against DeploymentConfig.sol 30, 39-41: min(1 hours, 1 days) = 1 hour while ETH_USD_MAX_AGE = 2 hours and NHI_MAX_AGE = 1 day. (2) runbook 242 against DeployMainnet.s.sol 220-224 (vault salt from VAULT_SALT). (3) DeployMainnet.s.sol 195-213 (run(): verifyFeeds(p, true); _record; no vault) against runbook 238-240.

---

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