# Audit report

> Audit the whole system: src/, script/DeployMainnet.s.sol, script/DeployPreflight.sol and deploy/mainnet/, at the pinned commit, for a mainnet launch. Fourteen audit rounds and their fixes are in (docs/AUDIT-*.md; the chain is web/content/docs/reference/audit-history.md). This is the LAST review before mainnet: a panel over the whole system at the commit that will deploy. Since the previous whole-system sweep (6085c8a, docs/AUDIT-FINAL-SWEEP-PANEL-2026-10-08.md) the vault's backing design was replaced by three paced figures (CDPVault._pace; docs/AUDIT-LAUNCH-VAULT-PANEL-2026-10-08.md and docs/AUDIT-PACED-VAULT-PANEL-2026-10-08.md, whose Resolution sections say how each finding was answered), the deploy script reads the vault's CREATE2 salt from the environment and refuses a second vault (docs/AUDIT-DELTA-PANEL-2026-10-08.md), and nothing else in src/ changed but comments: git diff 6085c8a a3aa9e4 -- src script deploy. Read the newest vault lines first (git diff d3861ac a3aa9e4 -- src: resecure, _clampPacedDebt after every cancellation, _backingPacedAt, the seeded paced supply), then everything that crosses subsystem lines. A finding of an earlier round counts only if its fix regressed or left a gap. Items ACCEPTED with their reasons stated where they live are findings only if the reason is wrong or the stated bound does not hold: the dip and the stale-term read (the paced figures' NatSpec), the redemption-fee floor, the work ceiling as an aggregate once the wage is on, the oracle's walk cost (docs/PARAMETERS-2026-10-05.md). Rank severity by what a finding lets someone take or block with the constants as committed (LINE $1M, wage 0, mat 170 at NHI >= 0.85, BACKING_RISE_PER_HOUR 2 points of par, FOLLOW_BPS_PER_HOUR 10%, PACE_INTERVAL 1 hour, fee floor 100,000 imdUSD).
>
> 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 VAULT'S NEWEST LINES (git diff d3861ac a3aa9e4 -- src). resecure(owner): anyone may re-price any position's term at a fresh, agreed price. Can it be used to lift the live figure or the paced backing past the honest one, to lower another borrower's term below the honest one, to change a position's eligibility, grace, health or mark, or to grief (gas, ordering, a flood of calls)? _clampPacedDebt after cash, bite and cover: any ordering of draw and cancellation, in one transaction or across a block boundary, that leaves zero-second debt counting? The backing's own clock and the seeded paced supply: any way to bank a rise or to reset the fee base.
> 2. THE PACED FIGURES ACROSS THE SYSTEM. With the Treasury (fundOracle unwrapping sIMD, redeemIMD, cover burning Treasury imdUSD, withdraw's bad-debt-first floor, donations), Parameters (a mat, gap, divisor, wage or earnMat change applied mid-flight), the feeds (a first value, a widened epoch's first value, a stale window, a Chainlink outage) and SwarmRelay (relayMany, relayAndBark, relayAndBite bundling a feed update with a liquidation or a pace): any sequence that pays a redemption more than the honest backing plus the rise the elapsed time allows, mints work against debt not held for the follow rate, moves the fee base faster than the follow rate, or desynchronises a record.
> 3. THE LAUNCH WINDOW, hour by hour for the first day, with docs/MAINNET-RUNBOOK.md section 7 against the code: stage one, the first values and verifySeeded, stage two with VAULT_SALT through a private relay, listing sIMD, the keeper (ETH, imdUSD inventory to bite, IMD for the oracle fallback; pace() hourly; resecure after each update), the first draws, the first redemptions (the fee floor, the seeded paced supply), the first price fall and liquidation (grace, tail, the dust rules, bad debt and its covering with no fees yet accrued), and every way the protocol can halt in that day and how each recovers.
> 4. THE ORACLE AS AN ATTACK SURFACE ON THE VAULT, with the pool as it is (about $2.0M a side, 1% fee, checked 2026-10-06): the cheapest profitable manipulation of collateral prices (over-borrowing, then a redemption or liquidation) or of NHI (mat and grace) in money and hours at LINE $1M, now that redemptions are paced; whether pacing or resecure gives a manipulated print a longer or shorter reach than before.
> 5. GOVERNANCE AND THE TREASURY for regressions only (unchanged since 6085c8a): the timelock and bounds, every exit from the Treasury bounded as documented, bad debt first, the reserve valuation, the work oracle's replacement rules, the factories.
> 6. THE DEPLOYMENT: DeployMainnet.run, verifySeeded, runVault (VAULT_SALT, PUBLIC_VAULT_SALT refused, _refuseAnotherVault, record before verify), verify, plan.py and the pinned bodies, the gas and EIP-7825 checks; what can still be deployed wrong and pass, and what a stranger can do before, between and during the stages. Contract size: ParameterizedVault initcode 47,089 of 49,152 bytes.
> 7. Every comment, NatSpec or runbook line in scope that claims a property the code does not have, and the list of what you read in full and what you could not reach.
>
> 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 | `a3aa9e4d4f9492cac2406901a11f786018addcd5` |
| Job | `a69e204e-d047-4c2f-9944-f37a69dd2796` |
| Judged | 2026-10-09 04:48 UTC |
| Findings | 1 high · 2 low · 4 info |

Four agents audited the code as it is at `a3aa9e4`, 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: cash pays IMD at a one-step manipulated feed low: a 20% pool push held ~65 min takes ~18.75% of in-band debt from candidates (fall/redemption route missing from the accepted walk analysis)

`src/CDPVault.sol:742`

```
        uint256 payoutScale = Math.mulDiv(_backingPerUnit(price), 10_000 - feeBps, 10_000);
```

CDPVault.cash pays gemOut = amount x min(backing,1) x (1-fee) / price. On a book with slack above par the paced backing is 1e18 and does not bind, so a redemption's IMD per imdUSD follows the attested price directly. SwarmFeed accepts a single step of 20% from a fresh anchor (FEED_MAX_DEVIATION_BPS 2000; 40% after two silent hours), and the primary (13-sample 2h median) and the spot (last block) both read IMD's one Uniswap v4 pool, so pushing the pool moves both and SKEW_BPS sees agreement. Sequence: hold imdUSD (drawn earlier at the honest price); sell ~12% of the pool's IMD side (~$240k against ~$2.0M) to drop the price 20% and hold it ~65 minutes so 7 of 13 median samples sit low; relay primary and spot (askPaid, or the Treasury's own 5% fall trigger buys it); cash(amount,0,candidate) against every in-band candidate (at -20% every position under ~275% honest CR is in band) and the reserve; buy IMD back. Gain 1.25 x 0.95 - 1 = 18.75% of redeemed volume (58% after two hours of feed silence), taken from candidates' collateral and the Treasury's sIMD; cost ~$5k pool fees plus whatever dip-buyers absorb during the hold, ~$9 attestations. With LINE $1M the takeable amount is up to ~$187k at fee cap. The accepted 'oracle walk cost' (docs/PARAMETERS-2026-10-05.md) prices only the over-borrowing walk (x1.7, several hourly steps, ~$40k); this route needs one step inside the committed allowance. Pacing and resecure bound backing per unit, not price per IMD, so they do not shorten its reach. Smallest fix: (a) floor the redemption fee at the primary feed's fall from its current epoch anchor (feeBps >= (anchor - price) x 10000 / anchor), or (b) pace the payout price (pay at max(price, pacedPrice) with pacedPrice falling at a bounded fraction per hour). Either makes the proof pass; both underpay redeemers for up to an hour after an honest fall, the conservative direction.

**Reproduction**

Proof below (test/scratch/OneStepFallRedemption.t.sol): ParameterizedVault over MockIMD at $1, NHI 0.85 (mat 170). BOOK locks 199,000/draws 99,500 (200%, candidate); HOLDER locks 300,000/draws 100,000; 24 hourly pacings; backingPerUnit()==1e18. Both feeds set to 0.80x price; next block HOLDER cash(50_000e18,0,BOOK). Expected <= 50,000 IMD (pre-fall price). Actual gemOut 59,375e18 (50,000 x 0.95 / 0.80), all from BOOK's collateral; BOOK loses $9,375 at the pre-fall price. Run on a3aa9e4: fails with 'a one-step feed fall pays the redeemer more than it burned: 59375000000000000000000 > 50000000000000000000000'. Reachable with committed constants; real-world feasibility depends on how much dip-buying the attacker must absorb during the 65-minute hold.

**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 redemption is paid IMD at the feed's price with the backing capped at par, so a one-step feed fall within
// the committed per-epoch allowance (20% on a fresh feed, 40% after two hours of silence) lets a redeemer take
// 1 / (1 - step) IMD per imdUSD from the reserve and from any candidate, while the redemption fee is capped at
// 5%. On a par book the paced backing does not bind (B = 1), so pacing does not slow it. The property asserted:
// after a 20% feed fall, one redemption does not pay more IMD, valued at the pre-fall price, than the imdUSD it
// burned. Fails on a3aa9e4: 50,000 imdUSD takes 59,375 IMD (worth $59,375 at the pre-fall price) out of the
// candidate's collateral, and the candidate's debt falls by only 50,000.

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 OsFeed is ISwarmFeed {
    uint256 public constant maxAge = 1 hours;
    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 view returns (bool) {
        return block.timestamp - updatedAt > maxAge;
    }
}

contract OsAggregator {
    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 OneStepFallRedemptionTest is Test {
    address private constant BOOK = address(0xB00C);
    address private constant HOLDER = address(0x401D);
    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    OsFeed private primary;
    OsFeed private health;
    OsFeed private spot;
    uint256 private imdEth = DOLLAR;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new OsAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new OsFeed(DOLLAR);
        health = new OsFeed(0.85 ether); // mat 170, gap 50: a position at 200% is a candidate
        spot = new OsFeed(DOLLAR);
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(spot)
        );
        stable = vault.stablecoin();
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(BOOK, 200_000 ether);
        imd.mint(HOLDER, 300_000 ether);
        vm.stopPrank();
        vm.startPrank(BOOK);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(199_000 ether);
        vault.draw(99_500 ether); // 200%: the candidate
        vm.stopPrank();
        vm.startPrank(HOLDER);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(300_000 ether);
        vault.draw(100_000 ether); // the redeemer's imdUSD, held for a day
        vm.stopPrank();
        for (uint256 i; i < 24; ++i) _hour();
        assertEq(vault.backingPerUnit(), 1e18, "a par book");
    }

    function _next(uint256 seconds_) private {
        vm.warp(block.timestamp + seconds_);
        vm.roll(block.number + 1 + seconds_ / 12);
        primary.set(imdEth);
        spot.set(imdEth);
        health.set(0.85 ether);
    }

    function _hour() private {
        _next(1 hours);
        vault.pace();
    }

    /// @dev One step the feeds accept on a fresh epoch (SwarmFeed.maxDeviationBps 2000): primary and spot both
    /// 20% below the price of a moment ago, within SKEW_BPS of each other. The next block, a holder redeems.
    function test_oneStepFallDoesNotPayMoreThanTheImdUSDBurnedAtThePreFallPrice() public {
        uint256 preFall = DOLLAR;
        imdEth = DOLLAR * 80 / 100;
        _next(12);
        (uint256 price,) = vault.collateralPriceFeed().latestValue();
        assertEq(price, 0.8 ether, "the vault prices at the attested low");
        assertEq(vault.backingPerUnit(), 1e18, "the par book stays at par through a 20% fall: pacing does not bind");
        uint256 collateralBefore = vault.securedCollateral();
        (uint256 bookCollateralBefore, uint256 bookDebtBefore) = vault.positions(BOOK);
        vm.prank(HOLDER);
        uint256 gemOut = vault.cash(50_000 ether, 0, BOOK);
        (uint256 bookCollateralAfter, uint256 bookDebtAfter) = vault.positions(BOOK);
        // The candidate funded all of it (no reserve), and its debt fell by exactly what was burned.
        assertEq(bookCollateralBefore - bookCollateralAfter, gemOut, "paid from the candidate");
        assertEq(bookDebtBefore - bookDebtAfter, 50_000 ether, "fifty thousand of debt cancelled");
        collateralBefore; // silence
        // EXPECTED: 50,000 imdUSD takes at most 50,000 IMD at the pre-fall price (the fee is a brake, not a
        // premium). ACTUAL on a3aa9e4: 50,000 x 0.95 / 0.80 = 59,375 IMD, worth 59,375 at the pre-fall price.
        uint256 valueAtPreFall = Math.mulDiv(gemOut, preFall * 2000, 1e18); // DOLLAR x 2000 = $1 per IMD
        assertLe(valueAtPreFall, 50_000 ether, "a one-step feed fall pays the redeemer more than it burned");
    }
}
```

### 2. Low: _clampPacedDebt mismeasures cancellations: a draw in one tx and a cancellation of seasoned debt in the next counts zero-second debt in full; a self-redemption of the tx's own fresh draw zeroes the pac

`src/CDPVault.sol:907`

```
        uint256 live = _debtForPacing();
```

Merged from three specialist reports (d6e63b8b, db768696, f3224fb3): one root cause. _clampPacedDebt (after draw, cash, bite, cover) clamps _debtPaced to totalDebt + WIPED_THIS_TX - MINTED_THIS_TX. The tallies are transient, so (1) debt drawn in an EARLIER transaction is invisible: tx1 lock+draw(X) leaves paced at T; tx2 (next block) cash(X,0,victim) cancels X of seasoned debt, live = T, no clamp; the churner's one-block-old X now counts in full for ParameterizedVault.backedDebt, contrary to NatSpec at CDPVault 310-313 / 899-904 and ParameterizedVault 237-240 / 262-264 ('debt cancelled by a redemption or liquidation and drawn again by someone else backs nothing until it has been held'). A plain wipe by another borrower behaves the same way. (2) In the other direction, cancelling the transaction's OWN fresh draw nets MINTED out of a total that no longer contains it: lock(200k)+draw(100k)+cash(100k,0,self) in one call writes paced debt 0 though the seasoned book (99,500) is untouched; it then climbs ~10,000/hour, so earnLine falls to the reserve term (a gas-priced, repeatable denial of the work channel). The only consumer is the work ceiling and WAGE_WAD is 0 at launch, so nothing is takeable or blockable with the constants as committed (hence low); once a wage is set behind the 48h timelock, (1) is the sweep-panel high's round trip at one-block holding time (25% of cancelled seasoned debt minted as work against debt unwound next block) and (2) is a denial of earn. Smallest fix: record the paced debt and totalDebt as the transaction found them (ParameterizedVault already records debtAtTxStart in _debtChanged) and clamp to pacedAtTxStart - cancelledPreexisting, where cancelledPreexisting = debtAtTxStart - (totalDebt + WIPED - MINTED) saturated, with a per-position transient 'minted this tx' tally netted out of principal cancelled by cash/bite/cover so a tx cancelling its own fresh draw moves nothing.

**Reproduction**

(1) Proof below: BOOK 199,000/99,500 at 200%, 24 hourly pacings (backedDebt 99,500e18). CHURNER lock(40,000)+draw(20,000) (paced stays 99,500); next block cash(20,000,0,BOOK); next block backedDebt(). Expected <= 79,600e18; actual 99,512,105,242,694,063,896,500 wei (fails on a3aa9e4). Same result from the independent proof db768696 (100,066e18 > 50,100e18). (2) Same fixture; a contract with 200,000 IMD runs approve; lock(200_000e18); draw(100_000e18); cash(100_000e18,0,address(this)) in one tx. Next block: totalDebt 99,500e18, paced().debt 0, backedDebt() 33,333,333,333,333,333,333 wei. Expected ~99,500e18. Reproduced in test/scratch/SelfRedeem.t.sol.

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

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

// The paced debt's clamp after a cancellation (CDPVault._clampPacedDebt, added in c1ecb05 for the paced vault
// panel's medium #1) measures "the debt this transaction began with less what it has cancelled" from totalDebt
// and the transaction's own transient tallies. A draw in ONE transaction and the cancellation of another
// position's seasoned debt in the NEXT (same block or the next) leaves the paced debt at the book's total: the
// cancelled seasoned debt is replaced, in the counted figure, by debt drawn a transaction earlier, which the
// NatSpec (CDPVault 310-313, ParameterizedVault 237-240, 261-264) says backs nothing until it has been held.
// Fails on a3aa9e4: after lock+draw(20,000) and, in the next block, cash(20,000, 0, BOOK), backedDebt() reads
// 99,500 where the seasoned book is 79,500 and the churner's 20,000 is twelve seconds old.

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

contract DcFeed 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 DcAggregator {
    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 DrawThenCancelAcrossTransactionsTest is Test {
    address private constant BOOK = address(0xB00C);
    address private constant CHURNER = address(0xC4A1);
    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1

    MockIMD private imd;
    ParameterizedVault private vault;
    DcFeed private primary;
    DcFeed private health;
    DcFeed private spot;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new DcAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new DcFeed(DOLLAR);
        health = new DcFeed(0.85 ether); // mat 170, gap 50: the book at 200% is a candidate
        spot = new DcFeed(DOLLAR);
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(spot)
        );
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(BOOK, 200_000 ether);
        imd.mint(CHURNER, 40_000 ether);
        vm.stopPrank();
        vm.startPrank(BOOK);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(199_000 ether);
        vault.draw(99_500 ether); // 200%: eligible for redemption, and the only debt on the book
        vm.stopPrank();
        vm.prank(CHURNER);
        imd.approve(address(vault), type(uint256).max);
        // A day of hourly pacing: the paced debt catches up with the book.
        for (uint256 i; i < 24; ++i) {
            vm.warp(block.timestamp + 1 hours);
            vm.roll(block.number + 300);
            primary.set(DOLLAR);
            spot.set(DOLLAR);
            health.set(0.85 ether);
            vault.pace();
        }
        assertEq(vault.backedDebt(), 99_500 ether, "the book counts in full after a day");
    }

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

    function test_drawThenCancelInTheNextTransactionCountsZeroSecondDebt() public {
        // Transaction 1: the churner locks and draws 20,000 (its debt is not yet counted: paced stays 99,500).
        vm.startPrank(CHURNER);
        vault.lock(40_000 ether);
        vault.draw(20_000 ether);
        vm.stopPrank();
        (,, uint256 pacedAfterDraw,,) = vault.paced();
        assertEq(pacedAfterDraw, 99_500 ether, "the fresh draw is not counted");
        // Transaction 2, the next block: redeem the drawn 20,000 against the seasoned book.
        _next();
        vm.prank(CHURNER);
        vault.cash(20_000 ether, 0, BOOK);
        // The next block: the book's total is back at 99,500, of which 20,000 is twelve seconds old.
        _next();
        // A day's stability fees (about 12 imdUSD) were cancelled first, so the principal is a few imdUSD above.
        assertApproxEqAbs(vault.totalDebt(), 99_500 ether, 20 ether);
        (, uint256 bookDebt) = vault.positions(BOOK);
        assertApproxEqAbs(bookDebt, 79_500 ether, 20 ether, "the book lost 20,000 of seasoned debt");
        // EXPECTED per the NatSpec: at most the seasoned 79,500 plus two blocks of the follow step (about 67).
        // ACTUAL on a3aa9e4: 99,500: the churner's zero-second debt counts in full for the work ceiling.
        assertLe(vault.backedDebt(), 79_600 ether, "cancelled seasoned debt replaced by zero-second debt counts in full");
    }
}
```

### 3. Low: Seeded paced supply takes the whole live supply at the first pacing after the first draw, unpaced: whoever draws first sets the launch fee base in either direction

`src/CDPVault.sol:829`

```
        if (paced == 0) return live;
```

Merged from a57a6cb5 and dcc57c32. _pacedSupply returns the live supply while _supplyPaced == 0. The first borrower's own pacing sees supply 0, so the next capital-moving transaction by anyone writes that borrower's whole draw into the fee base with no follow-rate limit. (a) Large draw held one block: WHALE lock(1.6M)+draw(900k), BOOK draws 100k next block (seeds 900k), WHALE wipes and frees the block after: the fee base stays ~900k and decays only 10%/hour, so redemption fees are diluted for about a day (a 50,000 redemption pays 328 bps instead of 500), against the NatSpec 'principal drawn for a block cannot dilute the fee' (CDPVault 307-309) and the seed's comment 'there is no earlier base for a draw to dilute'. Redeemed-against borrowers lose the fee difference. (b) Small first draw (lock(10)/draw(1), by a front-runner or an honest test draw): the seed is ~1 and the base sits at the 100,000 floor for the first day while the live supply is several times it, exactly the state paced vault panel #5 marked 'Fixed' (redemptionFeeBps(5,000) 300 instead of 100 with a 500,000 book). The same reset recurs whenever the live supply returns to zero. Cost: gas plus one block of collateral. Smallest fix: seed no higher than the floor (`if (paced == 0) return Math.min(live, _feeBaseFloor());`), which makes (a) impossible and errs high on fees on day one (the borrower-protective direction), and restate panel #5 as accepted; or have the operator make the first draw the launch book in the deployment block sequence.

**Reproduction**

Proof below (SeededFeeBase.t.sol): fresh vault, IMD $1, NHI 0.85. Block 1 WHALE lock(1,600,000)+draw(900,000); block 2 BOOK lock(200,000)+draw(100,000) (seeds 900,000); block 3 WHALE wipe(900,000)+free(1,500,000); block 4 redemptionFeeBps(50,000). Expected 500 (control); actual 328, paced().supply 900,300e18. Fails on a3aa9e4 with 'a block-long draw diluted the redemption fee: 328 != 500'. Direction (b): lock(10e18)/draw(1e18), pace, WHALE lock(1.5M)/draw(500k), pace an hour later: paced supply ~10,001e18, redemptionFeeBps(5,000e18)=300 vs 100 intended.

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

// CDPVault._pacedSupply seeds a zero paced supply with the whole live supply at once. At launch the first borrower
// draws (the pacing at the start of its own transaction sees zero), and the NEXT capital-moving transaction by anyone
// writes that draw into the fee base in full. Repaid a block later, it keeps the fee base inflated for about a day,
// falling only at FOLLOW_BPS_PER_HOUR: principal drawn for a block dilutes every redemption fee in that window.

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

contract SeedFeed 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 SeedAggregator {
    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 SeededFeeBaseTest is Test {
    address private constant WHALE = address(0xA11CE);
    address private constant BOOK = address(0xB00C);
    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    SeedFeed private primary;
    SeedFeed private health;
    SeedFeed private spot;

    function setUp() public {
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new SeedAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new SeedFeed(DOLLAR);
        health = new SeedFeed(0.85 ether);
        spot = new SeedFeed(DOLLAR);
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(spot)
        );
        stable = vault.stablecoin();
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(WHALE, 2_000_000 ether);
        imd.mint(BOOK, 220_000 ether);
        vm.stopPrank();
        vm.prank(WHALE);
        imd.approve(address(vault), type(uint256).max);
        vm.prank(BOOK);
        imd.approve(address(vault), type(uint256).max);
    }

    function _next() private {
        vm.roll(block.number + 1);
        vm.warp(block.timestamp + 12);
        primary.set(DOLLAR);
        spot.set(DOLLAR);
        health.set(0.85 ether);
    }

    function _bookBorrows() private {
        // An honest book of 100,000 imdUSD at 200%: inside the redeemable band (mat 170 + gap 50).
        vm.startPrank(BOOK);
        vault.lock(200_000 ether);
        vault.draw(100_000 ether);
        vm.stopPrank();
    }

    /// Control: the same honest book with no block-long draw in front of it. 50,000 burned against a 100,000 base
    /// (the floor) stores the cap: 50 + 450 = 500 bps.
    function test_controlFeeAtLaunch() public {
        _bookBorrows();
        _next();
        vault.pace();
        _next();
        assertEq(vault.redemptionFeeBps(50_000 ether), 500);
    }

    function test_blockLongDrawAtLaunchDilutesTheFeeBase() public {
        // Block 1: the first borrower draws 900,000 (LINE is 1,000,000). Its own pacing sees a zero supply.
        vm.startPrank(WHALE);
        vault.lock(1_600_000 ether);
        vault.draw(900_000 ether);
        vm.stopPrank();
        // Block 2: the honest book borrows; its pacing seeds the paced supply with the whole 900,000.
        _next();
        _bookBorrows();
        // Block 3: the whale repays its principal and leaves. Live supply is 100,000 again.
        _next();
        vm.startPrank(WHALE);
        vault.wipe(900_000 ether);
        vault.free(1_500_000 ether); // a few cents of accrued fee stay owed; the collateral comes out
        vm.stopPrank();
        _next();
        (, uint256 pacedSupply,,,) = vault.paced();
        emit log_named_uint("paced supply (fee base)", pacedSupply);
        emit log_named_uint("live supply", stable.totalSupply());
        uint256 fee = vault.redemptionFeeBps(50_000 ether);
        emit log_named_uint("fee for 50,000, bps", fee);
        // EXPECTED (the paced figures' NatSpec: principal drawn for a block cannot dilute the fee): 500 bps, as in
        // the control. ACTUAL: 328 bps, the 900,000 drawn for two blocks counting in the fee base in full.
        assertEq(fee, 500, "a block-long draw diluted the redemption fee");
        // And it lasts: twelve hours later the base is still about 300,000 against a live supply of 100,000.
        for (uint256 i; i < 12; ++i) {
            vm.warp(block.timestamp + 1 hours);
            vm.roll(block.number + 300);
            primary.set(DOLLAR);
            spot.set(DOLLAR);
            health.set(0.85 ether);
            vault.pace();
        }
        assertEq(vault.redemptionFeeBps(50_000 ether), 500, "still diluted twelve hours later");
    }
}
```

### 4. Info: Runbook 7 step 5 'Only then open deposits' describes a gate the vault does not have

`docs/MAINNET-RUNBOOK.md:392`

```
5. **Only then** open deposits.
```

Merged 760cda55 and 8a726544. ParameterizedVault/CDPVault have no pause, allowlist or opening switch: lock, lockIMD and draw are open from the block runVault lands in, with feeds fresh from stage one. Steps 3-4 (keeper start and funding, ORACLE_ASKER prefund) are therefore not preconditions of borrowing, and the rollback window ('abandon only before anyone deposits') closes at block N+1 without the operator acting; together with the paced-supply seed the first borrower picks the launch fee base. Fix: say deposits are open from the vault's first block; move keeper start and asker prefund before runVault, and have the operator make the first position.

**Reproduction**

After runVault's CREATE2 tx is mined at block N, any EOA with sIMD calls vault.lock(x), vault.draw(y) at N+1: both succeed; grep finds no launch flag in src/CDPVault.sol or src/ParameterizedVault.sol. Expected per runbook: deposits refused until step 5.

### 5. Info: Stale initcode size in comments: ParameterizedVault is 47,089 bytes of initcode, not 36,416

`src/CDPVault.sol:418`

```
            // bytes and this vault's subclass is already at 36,416 of the 49,152 EIP-3860 permits.
```

Merged 6009be2d and b809730a. CDPVault constructor comment and WorkOracleFactory NatSpec (src/WorkOracleFactory.sol 10-11, '36,416 ... 52,880 bytes') state 36,416; actual is 47,089, 2,063 bytes of headroom, not ~12.7 KB. Conclusion holds (47,089 + 16,464 = 63,553 > 49,152) but the margin is overstated. Fix: update both figures.

**Reproduction**

forge inspect src/ParameterizedVault.sol:ParameterizedVault bytecode -> (hex length - 2)/2 = 47089 (run at a3aa9e4). Comment states 36,416.

### 6. Info: Paced-figures NatSpec states the dip condition without the remaining positions' secured value

`src/CDPVault.sol:326`

```
    /// book WITHOUT the leaving position is below par: reserve + mat x (debt - bad debt - its principal) less than
```

_liveBacking is (reserve + min(held x price, mat x (debt - bad)/100)) / supply (_securedCollateralValue). The NatSpec gives only the cap term, so a book whose remaining positions are underwater satisfies the stated 'no dip' condition and still dips (the repository's own test_aParBookDipsWhenThePositionCarryingTheCapLeavesAndReturns). Fix: 'reserve + min(the remaining positions' secured value at the price, mat x (debt - bad debt - its principal) / 100)'.

**Reproduction**

Kept test's numbers: reserve 20,000 IMD x $0.40 = 8,000; cap 1.7 x 99,500 = 169,150; 177,150 >= 99,500 so the sentence predicts no dip; the test asserts backingPerUnit() < 0.9e18 (secured term 79,600 + 8,000 over 99,500 = 0.88).

### 7. Info: Two comments state the follow bound as 'at most FOLLOW_BPS_PER_HOUR an hour' without the per-pacing compounding

`src/ParameterizedVault.sol:262`

```
        // D1: debt counts only up to the paced debt, which rises by at most FOLLOW_BPS_PER_HOUR an hour and falls
```

_step is a fraction of the current paced value per pacing, and pace() is permissionless, so paced every block the figures grow e^0.1-1 = 10.52%/hour. CDPVault 305-307 was corrected to 'compounding per pacing'; ParameterizedVault 238-239, 262 and CDPVault 1105 were not. Fix: use the corrected wording.

**Reproduction**

Paced debt 1,000,000e18, live far larger: one pace after 1h -> 1,100,000e18; 300 paces 12s apart -> 1,000,000 x (1+1/3000)^300 ~ 1,105,100e18 > the 1,100,000e18 the comments state. grep 'at most FOLLOW_BPS_PER_HOUR an hour' src/ finds CDPVault.sol:1105 and ParameterizedVault.sol:262 (and 238-239 wraps the same phrase).

---

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