# Audit report

> Audit the vault: src/CDPVault.sol, src/ParameterizedVault.sol and src/ImdUSD.sol, in full, at the pinned commit, for a mainnet launch. Read whatever else in src/ these depend on, but report on this scope. Thirteen audit rounds and their fixes are in (docs/AUDIT-*.md; the chain is web/content/docs/reference/audit-history.md). The newest, docs/AUDIT-LAUNCH-VAULT-PANEL-2026-10-08.md if present, else the launch vault panel (job 5383ced0 at 9bd5f59: a high and two mediums in the per-position lag's latest repair), ended that lag: this commit replaces it with three PACED figures (CDPVault._pace and the NatSpec at BACKING_RISE_PER_HOUR), the one mechanism no panel has read. Read the vault in full, as it will deploy; the pacing is the newest code and the place to break first. 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 (a withdrawal paced in one transaction and reversed in the next, stated at the paced figures' NatSpec: below par only, bounded by the book without that position, recovering at the rise rate), the stale-term read after a price fall (retry2 #6, same NatSpec), the redemption-fee floor, and the work ceiling as an aggregate once the wage is on. Rank severity by what a finding lets someone take or block with the constants as committed (LINE $1M, wage 0, BACKING_RISE_PER_HOUR 2 points of par, FOLLOW_BPS_PER_HOUR 10%, PACE_INTERVAL 1 hour).
>
> 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 PACED BACKING (CDPVault._pace, _pacedBacking, _liveBacking, _clampPacedDebt, _priceAgrees, PACED_THIS_TX_SLOT, pace()). A redemption is paid min(live, paced backing), the paced backing falls at once to the live figure and rises by at most BACKING_RISE_PER_HOUR for at most PACE_INTERVAL of elapsed time between pacings, written at a transaction's first capital-moving call from the state it found, and only at a fresh, agreed price. Search every sequence of lock, lockIMD, free, draw, wipe, cash, cover, bite, earn, pace, a Treasury donation and a feed update, by one account or several, in one transaction or across many, with and without a price move between them, for one that makes a redemption paid more than the honest backing of the book plus the rise the elapsed time allows, from the reserve or from a candidate; and quantify every way honest redeemers are paid LESS than the live figure (the dip, the stale-term read, a quiet spell, a feed outage), as a cost in points and hours, against the accepted statements.
> 2. THE PACED SUPPLY AND THE FEE (_pacedSupply, _step, _feeBase floored at 100,000, _redemptionRate with prior read once, the stored base rate's decay, the fresh-debt record). The cheapest way to pin the cap for everyone and the cheapest way to dilute the fee, now; whether a draw, repayment, redemption, work mint or their ordering moves the base off the paced supply by more than FOLLOW_BPS_PER_HOUR an hour; the fee a launch-day redeemer pays while the paced supply is below the live one.
> 3. THE PACED DEBT AND THE WORK CEILING (_pacedDebt, _clampPacedDebt, WIPED_THIS_TX_SLOT, ParameterizedVault.backedDebt and earnLine, _debtAtTransactionStart) with WAGE_WAD 0 at launch (earn refused) and once governance turns the wage on: any way debt cancelled by cash, bite or cover and drawn again by anyone backs work minting sooner than the follow rate allows; whether a borrower's own wipe and redraw, in one transaction or across two, moves the ceiling; the cost of the aggregate (the ceiling tracks totals, not whose debt) once the wage is on.
> 4. REDEMPTION PAYOUTS: cash's routes (reserve, candidate, mixed), candidate eligibility (mat + gap), RedemptionWorsensRatio, ExcessRepayment, minGemOut, the reserve valued at the vault's price, the transient tallies (MINTED, SECURED, REPAID, WORK_MINTED, WIPED, PACED this transaction) and the saturating arithmetic in _liveBacking and _securedCollateralValue. Can a redeemer pay less than the fee for its size, worsen a candidate, or take reserve beyond its share?
> 5. LIQUIDATION AND BAD DEBT: bark, barkFor, bite (always marked: grace from lull, then a tail-long window), heel, the chip/cut split at CHOP_PERCENT 20, the dust seizure, cover (a re-lock below recorded bad debt taken at its value, CoverBelowCollateralValue, _coverDust) and totalBadDebt against the per-position record; and what each does to the paced figures. Can anyone take more than the formula, freeze a position unliquidatable, hold cover off cheaply, or desynchronise the bad-debt record?
> 6. POSITIONS AND PRICING: lock, lockIMD (shares credited by balance delta), free, draw, wipe; the stability fee (duty, chi, drip, checkpoints); price gating (_requireFreshFeeds, _requirePriceAgreement, the ungated lock, wipe and debt-free free, which now pace, and the unreadable-price path in _resecureBounded). Below mat, double counting, reentrancy through the share vault, acting on a stale or divergent price, and whether pacing inside the ungated calls can ever revert them or write a figure from a bad price.
> 7. ARITHMETIC, GAS AND SIZE: overflow at extreme collateral, price or elapsed time, rounding direction in every division that pays someone, units where a price, a 24-decimal amount and basis points meet; the gas pacing adds to lock and free; ParameterizedVault initcode 46,987 of 49,152 bytes.
> 8. Every comment or NatSpec in these files that claims a property the code does not have, the paced figures' NatSpec first.
>
> 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 | `d3861ac5cd4fc50dfdac18dd59efed61b6d5bd8b` |
| Job | `dc27aade-6adb-40da-b6d8-0bddfe280ebf` |
| Judged | 2026-10-09 03:25 UTC |
| Findings | 3 medium · 2 low · 6 info |

Four agents audited the code as it is at `d3861ac`, 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: Paced debt is clamped only after draw: a draw followed by cash, bite or cover in one transaction leaves zero-second debt counting in full for the work ceiling

`src/CDPVault.sol:742`

```
            (principalCancelled, freshCancelled) = _redeemPosition(candidate, debtCancelled, gemOut - reserveOut, price);
```

CDPVault._clampPacedDebt (lines 876-882) lowers _debtPaced to totalDebt + WIPED - MINTED and is called from draw only (line 504). In the order lock, draw(X), cash(X, 0, victim) (or draw then bite(victim, X), or draw then cover(drained, X)) the clamp runs while the cancellation has not happened yet (live = T + X - X = T, no change), and _redeemPosition / bite / cover then lower totalDebt with no clamp. The transaction ends with totalDebt = T and _debtPaced = T, though X of that T was drawn seconds ago; the next transaction's _pace finds live T >= paced T and keeps T, so ParameterizedVault.backedDebt counts the fresh X in full. The NatSpec at CDPVault 308-311 and 871-875 and ParameterizedVault 237-239 and 261-263 ('debt cancelled by a redemption, a liquidation or cover and drawn again backs nothing until it has been held'; 'the paced debt never exceeds the debt this transaction began with less what it has cancelled') holds only for cancel-then-draw, the order the sweep panel's proof used. Reachability with the constants as committed: the ordering is reachable now; its only consumer is the work ceiling and WAGE_WAD is 0, so earn is refused and nothing can be taken at launch. Once governance sets a wage (48-hour timelock) it is the D1 round trip at zero holding time: 25% (EARN_MAT 2500) of whatever debt an attacker can cancel in one transaction (bounded by candidates in the 170-220 band, or underwater positions for bite) becomes work-minted imdUSD the next block, after which the attacker wipes and frees. Merged from audit_economics (medium) and audit_permissions (low); both proofs fail on d3861ac for this reason. Smallest fix: call _clampPacedDebt() after every cancellation as well: after _redeemPosition in cash (inside the reserveOut < gemOut branch), after _reduceDebt in bite and after _reduceDebt in cover. Verified: with those three calls both attached proofs pass (5 of 5 tests) and ParameterizedVault initcode goes from 46,987 to 47,009 bytes (2,143 under the limit). wipe needs no change: WIPED_THIS_TX_SLOT offsets its fall.

**Reproduction**

test/scratch/Proof_1306515111da.t.sol (attached as proof). ParameterizedVault over MockIMD at $1 (IMD/ETH 1/2000 x Chainlink 2000e8 etched at CHAINLINK_ETH_USD), NHI 0.85 (mat 170, gap 50), TreasuryFactory etched, no reserve. BOOK locks 199,000 and draws 99,500 (200%, a candidate); 24 hourly pacings so backedDebt() == 99,500e18. A contract holding 40,000 IMD runs in ONE transaction: lock(40_000e18); draw(20_000e18); cash(20_000e18, 0, BOOK). Next block: totalDebt == 99,512e18, BOOK's debt == 79,512e18. EXPECTED backedDebt() <= 79,600e18 (the book less the cancelled 20,000; the churner's 20,000 is 12 seconds old). ACTUAL backedDebt() == 99512103561643835581000. Control in the same file: cash BEFORE draw gives 79545436894977168914333. Second proof (.imd/reads/proofs/Proof_8bb039f8b84d.t.sol, wage 0.01 applied through Parameters): after draw-then-cancel of the whole 99,500, paced debt == 99,500e18, earnLine == 24,878e18 and earn(24_000e18) succeeds where WorkCeilingReached was expected. Run: forge test --match-path test/scratch/Proof_1306515111da.t.sol -vv; test_drawThenCashCountsZeroSecondDebt fails on d3861ac and passes with _clampPacedDebt() added after the cancellation in cash, bite and cover.

**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 is clamped only after `draw` (CDPVault._clampPacedDebt). A draw FOLLOWED by a cancellation of
// another position's debt in the same transaction (cash here; bite and cover take the same path) leaves
// `_debtPaced` where the transaction found it, so in the next transaction the zero-second debt that replaced
// the cancelled one counts in full for the work ceiling (ParameterizedVault.backedDebt). The mirror order
// (cash, then draw) is clamped, as the sweep-panel fix intended.

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 PFeed 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 PAggregator {
    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 One transaction: lock, draw, then redeem the drawn imdUSD against the book (draw FIRST).
contract DrawThenCash {
    function run(ParameterizedVault vault, MockIMD imd, uint256 collateral, uint256 debt, address candidate) external {
        imd.approve(address(vault), type(uint256).max);
        vault.lock(collateral);
        vault.draw(debt);
        vault.cash(debt, 0, candidate);
    }
}

/// @dev The same three steps with the redemption BEFORE the draw (the order the committed clamp covers).
contract CashThenDraw {
    function run(ParameterizedVault vault, MockIMD imd, uint256 collateral, uint256 debt, address candidate) external {
        imd.approve(address(vault), type(uint256).max);
        vault.lock(collateral);
        vault.cash(debt, 0, candidate);
        vault.draw(debt);
    }
}

contract ProofDrawThenCancelTest is Test {
    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;
    PFeed private primary;
    PFeed private health;
    PFeed 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 PAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new PFeed(DOLLAR);
        health = new PFeed(0.85 ether); // mat 170, gap 50: the book at 200% is a candidate
        spot = new PFeed(DOLLAR);
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(spot)
        );
        stable = vault.stablecoin();
        vm.prank(APPROVED_OPERATOR);
        imd.mint(BOOK, 200_000 ether);
        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();
        // 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);
    }

    /// @dev The order the committed clamp covers: cancelling 20,000 of the book then drawing 20,000 leaves the
    /// paced debt at the book without the cancelled part, so the new debt backs nothing until it has been held.
    function test_cashThenDrawIsClamped() public {
        CashThenDraw churner = new CashThenDraw();
        vm.prank(APPROVED_OPERATOR);
        imd.mint(address(churner), 40_000 ether);
        // The churner needs imdUSD to redeem before it draws: the book lends it 20,000.
        vm.prank(BOOK);
        stable.transfer(address(churner), 20_000 ether);
        churner.run(vault, imd, 40_000 ether, 20_000 ether, BOOK);
        _next();
        uint256 counted = vault.backedDebt();
        emit log_named_uint("backedDebt after cash-then-draw", counted);
        assertLe(counted, 79_600 ether, "the redrawn 20,000 does not count until it has been held");
    }

    /// @dev The same capital, the same cancellation, the draw first: the paced debt is never clamped, and the
    /// 20,000 drawn seconds ago counts for the work ceiling in the next transaction.
    function test_drawThenCashCountsZeroSecondDebt() public {
        DrawThenCash churner = new DrawThenCash();
        vm.prank(APPROVED_OPERATOR);
        imd.mint(address(churner), 40_000 ether);
        uint256 debtBefore = vault.totalDebt();
        churner.run(vault, imd, 40_000 ether, 20_000 ether, BOOK);
        _next();
        // The book's 20,000 was cancelled and the churner's 20,000 replaced it: the same total.
        assertApproxEqAbs(vault.totalDebt(), debtBefore, 20 ether, "the total is unchanged");
        (, uint256 bookDebt) = vault.positions(BOOK);
        assertLt(bookDebt, 80_000 ether, "the book's debt was cancelled");
        uint256 counted = vault.backedDebt();
        emit log_named_uint("backedDebt after draw-then-cash", counted);
        // EXPECTED (the paced debt's stated property, CDPVault BACKING_RISE_PER_HOUR NatSpec and
        // ParameterizedVault.backedDebt): at most the book less what was cancelled, about 79,500.
        // ACTUAL: about 99,500, the whole total including 20,000 of debt drawn seconds ago.
        assertLe(counted, 79_600 ether, "debt cancelled by a redemption and drawn again backs nothing until held");
    }
}
```

### 2. Medium: Paced backing: the accepted dip's stated bound 'exists only while the book is backed below par' does not hold; a par book dips to the rest-of-book figure on a dominant position's wipe and redraw acros

`src/CDPVault.sol:323`

```
    /// next leaves the figure where the book stood without it, until it climbs back. That dip exists only while
```

The paced figures' NatSpec (lines 321-328) accepts the dip with the reason that on a par book 'the surplus above the aggregate cap absorbs any one position's exit'. That reasoning assumes the exiting position is not the one carrying the cap. _liveBacking reports par whenever secured >= supply, and _securedCollateralValue caps secured at mat x (totalDebt - MINTED - totalBadDebt) / 100 and compares it with the WHOLE supply. When a dominant healthy position wipes its principal, its term in securedCollateral goes to zero (_secured returns 0 at principal 0), the cap shrinks by mat x P and the supply by P; whenever the rest of the book is below par on its own the next transaction's _pace writes min(live, paced + rise) = the rest-of-book figure, and cash pays min(live, paced) while it climbs back at 2 points of par an hour. Three shapes of par book satisfy this, all reproduced by the specialists and one by me: (a) a par book with an underwater tail (no bad debt, no wage: BOOK 80% underwater, WHALE 500% healthy; audit_math); (b) a par book carrying realized bad debt B after a past liquidation (cap = mat x (D - B - P) can be zero after the exit: audit_permissions reproduced 0.1217 and 0.0000667); (c) a par book with work-minted supply once a wage is on (audit_flow reproduced 0.5429). The stated magnitude bound (the gap to the backing of the book without the position) holds; the stated condition ('only below par', 'only in a book already in crisis') does not, and in shape (b) the figure can reach zero with every open position healthy. What it lets someone do with the constants as committed: a dominant borrower who holds the imdUSD it drew removes the redemption floor (the peg's defence, cash lines 702-729) for about (1 - dip) / 0.02 paced hours, for gas, repeatable every time the figure climbs back; an honest refinance across two blocks triggers it too. It never overpays. Smallest fix: correct the NatSpec (and web/content/docs/economics/risks-and-open-questions.md) to the real condition: the dip exists whenever reserve + mat/100 x (D - B - P) < S - P for the exiting position P, which includes a par book with any underwater position, realized bad debt or work-minted supply, and state its size as (secured_rest + reserve) / supply_rest. If that cost is not acceptable it is a design decision for the requester: either pace a fall caused only by a repayment at the follow rate (which reopens the lift D1 closed unless netted per position) or let cover burn the caller's own imdUSD so anyone can retire the bad debt that arms shape (b).

**Reproduction**

test/scratch/Judge.t.sol::test_parBookDipsOnDominantWipeAndRedraw (fails on d3861ac). ParameterizedVault over MockIMD at $1 (IMD/ETH 1/2000 x Chainlink 2000e8), NHI 0.85 (mat 170, gap 50), no reserve, wage 0. BOOK: lock 199,000 IMD, draw 99,500 (200%). WHALE: lock 2,500,000 IMD, draw 500,000 (500%). 24 hourly pacings. Price to $0.40 (BOOK 80%, underwater; WHALE 200%, healthy); each owner lock(1) to re-price its term; 12 hourly pacings. backingPerUnit() == 1e18 and paced().backing == 1e18 (held 2,699,000 IMD = $1,079,600 >= cap 1.7 x 599,500 = $1,019,150 >= supply 599,500). WHALE wipe(500_000e18) in one block, draw(500_000e18) in the next, one more block. EXPECTED per the NatSpec: backingPerUnit() == 1e18. ACTUAL: backingPerUnit() == 801166056408026274 (the rest of the book: 79,600 / 99,500), paced().backing == 801099389741359608. Three paced hours later cash(1_000e18, 0, WHALE) is paid 2140047258354713965000 IMD where par less the fee pays 2485250000000000000000 (13.9% less). audit_permissions' variant (BOOK 200% plus LOSER bitten to a drained position with 2,917 of bad debt, price back to $1, 60 paced hours at par): BOOK wipe then draw gives backingPerUnit() == 121709869698224744.

### 3. Medium: A debt-bound term is re-priced by nobody but its owner: marked at a crash low it overpays redeemers past the honest backing after a recovery (reserve first), and after a fall it underpays for as long

`src/CDPVault.sol:333`

```
    /// panel 2026-10-08, low), can now lift the payout no faster than the same rate.
```

securedCollateral sums per-position terms min(collateral, 2 x principal / price) in IMD, each fixed at the price of the position's last touch (_secured, _resecure). A term is re-priced only from lock, lockIMD, free, draw and wipe (owner only), cash (candidates below mat + gap = 220 only), bite (unhealthy only) and cover (drained or dust only); a healthy position above 220% is touched by no third party, ever, and lock is ungated, so the owner picks the touch price for free, during a halt included. OVERPAY (Q1): a debt-bound term written at p0 is worth 2P x p / p0 at a later price p. The launch vault panel reported this mirror (low); this commit answers only with the rise rate (line 333) and states no magnitude bound. The paced backing climbs 2 points an hour toward min(live, par) with the inflated live as its target, so after (over-read / 0.02) hours every redemption is paid the stale figure, from the Treasury's sIMD first and then from any candidate in band. The only bound is the aggregate cap (mat x prior debt), which is above par exactly when the book is below par, which is the only time it matters. Reachable with the constants as committed, wage 0, no governance: X (any position above 200%) lock(1) at the low; wait for the recovery and the paced hours; any holder (X included) cash(amount, 0, candidate). Preconditions are a crash leaving the book below par at the recovered price and a lower print before it; honest borrowers topping up during the crash mark their terms at the low exactly as X does. UNDERPAY (the accepted stale-term read, retry2 #6): the NatSpec says 'cold for a few hours, climbs back once positions are touched'. Nothing permissionless touches an idle owner's position, so after a fall by fraction f every untouched debt-bound term reads (1 - f) of its true value and the live figure, and so the payout, reads at most (1 - f x s) of honest backing (s = share of secured value in such positions) for as long as those owners are idle; cost in points: f x s of par, duration unbounded in hours. Merged from audit_economics (medium, the mirror) and audit_math (low, the stale read); both reproduced. Smallest fix, one for both directions: a permissionless re-price, e.g. `function resecure(address owner) external { _requireFreshFeeds(); _requirePriceAgreement(); _resecure(_positions[owner], _price()); }` (about 120 bytes of initcode against a 2,165-byte margin), and have the hourly keeper re-price open positions after each price update; a rise it causes is still bounded by the aggregate cap and the paced rise, a fall is honest. Until then, correct the NatSpec at 331-333: the duration is until the owner acts, and the mirror's magnitude is bounded only by the aggregate cap.

**Reproduction**

test/scratch/Judge.t.sol::test_mirrorLiftOverpaysRedeemer and ::test_staleTermAfterFallIsNotRepricedByAnyone (both fail on d3861ac). Fixture: ParameterizedVault over MockIMD at $1, NHI 0.85, Treasury holding 2,000 IMD. OVERPAY: X locks 30,000 and draws 2,500 (1200%); Y locks 42,500 and draws 25,000 (170%); Z locks 13,000 and draws 5,000 (260%); HOLDER is handed 10,000 imdUSD; 24 paced hours (backingPerUnit() == 1e18). Price to $0.20; X, Y, Z lock(1) (X's term becomes 25,000 IMD = 2 x 2,500 / 0.20); two paced hours. Price to $0.40. Control (snapshot): X, Y, Z lock(1), 40 paced hours: backingPerUnit() == 861538461538461538, the honest (800 + 17,000 + 5,200 + 5,000) / 32,500. Attack branch: only Y and Z lock(1), 40 paced hours: backingPerUnit() == 1000000000000000000 (X's stale 25,000 IMD reads $10,000 against an honest $5,000). HOLDER cash(1_000e18, 0, Z): EXPECTED at most 1,000 x 0.8615 x (1 - fee) / 0.40 = 2132307692307692306550 IMD. ACTUAL 2475000000000000000000 IMD (par less the fee, +16%), the Treasury's whole 2,000 IMD reserve first and 475 out of Z's collateral. UNDERPAY: X locks 600,000 and draws 100,000 (600%); 24 paced hours, par. Price to $0.40; X never transacts; Y lock(1e18) paces; 48 more paced hours. Honest secured value min(600,000, 2 x 100,000 / 0.40) x 0.40 = $200,000 >= supply 100,000, so honest backing is par. ACTUAL backingPerUnit() == 800000000000000000 after 48 paced hours; cash(1, 0, X) reverts IneligibleRedemptionPosition, bark(X) reverts HealthyPosition, cover(X, 1) reverts NoRealizedBadDebt: no external call re-prices X's term.

### 4. Low: _pace advances _pacedAt when the backing is held for an unusable price, so any ungated call or pace() during a stale or diverged window forfeits the interval's rise; a stated 'quiet gap recovers one i

`src/CDPVault.sol:868`

```
        _pacedAt = uint64(block.timestamp);
```

When _priceAgrees() is false _pace passes price 0 and _pacedBacking returns the held value (line 797), but _pacedAt is still written to now (line 868), so the elapsed time is consumed with no rise. The NatSpec says the figure 'holds' through a halt and that 'hourly pacing recovers in full; a quiet gap recovers one interval' (lines 317-321). It holds and also forgets the time. On mainnet a stale window is the ordinary state between purchased attestations (PRICE_MAX_AGE and SPOT_MAX_AGE 1 hour, updates bought on demand), lock, lockIMD, wipe and debt-free free are ungated and pace, and pace() is permissionless, so anyone can keep a recovering payout from climbing with one cheap call per stale window. Cost, never a gain: on a book below par honest redeemers stay underpaid up to 2 points of par per halt, indefinitely if repeated. Merged from audit_math (info), audit_flow (low) and audit_permissions (info); reproduced. Smallest fix: keep a separate timestamp for the backing (written only when _pace writes it at an agreed price) and measure the backing's elapsed from it, still capped at PACE_INTERVAL; _pacedAt keeps serving the supply and debt. Or state at 319-321 that a pacing at an unusable price consumes the interval.

**Reproduction**

test/scratch/Judge.t.sol::test_stalePacingForfeitsTheRise (fails on d3861ac). BOOK at 200% with 99,500 of debt, 24 paced hours; price to $0.40 and BOOK lock(1): paced backing 0.80e18. Price back to $1 (live reads par) and pace(). Case A: a quiet hour, then pace(): the paced backing rises 20000000000000000 (one interval). Case B from the same state: at minute 50 the spot feed is stale and WHALE lock(1) lands (ungated): paced().backing unchanged, paced().at == block.timestamp; at minute 60 the feed is fresh and pace() is called. EXPECTED per the NatSpec: +20000000000000000. ACTUAL: +3333333333333333 (ten minutes' worth).

### 5. Low: Launch-day fee: the paced supply starts at zero and follows at 10% an hour, so for about 27 paced hours a redemption's increase is measured against the 100,000 floor while the live supply is 500,000,

`src/CDPVault.sol:1073`

```
    /// redemption's increase is measured as if the supply were the floor, which only lowers fees while the
```

_feeBase is max(_pacedSupplyNow(), 100,000e18). _supplyPaced is 0 at deployment and each pacing moves it by at most 10% of max(paced, 100,000) per hour (_step), so it takes 10 paced hours to reach the floor and about 17 more to reach 500,000 (1.1^17 = 5.05). Throughout, _redemptionRate measures a redemption's increase against 100,000: a 1%-of-supply redemption (5,000 against a live 500,000) is quoted 300 bps where the live base gives 100, and 9,000 of burns (about 250-450 imdUSD of fee) store the 4.5% cap as everyone's base rate for the next half-life, where 45,000 would be needed against the live supply. So the sentence at 1072-1074 ('only lowers fees while the protocol is that small') is wrong while the paced supply is below the live one: it raises them, and it is stated nowhere in these files or in docs/MAINNET-RUNBOOK.md. The cheapest pin of the cap for everyone (Q2) is therefore 9,000 imdUSD of burns for the first day or so after launch, against 9% of the live supply once the paced supply has caught up. Not the constants, which are deliberate, but the initialization of the paced supply. Merged from audit_flow and audit_permissions (info); reproduced. Smallest fix: document it at _feeBase and in the runbook, or seed _supplyPaced from the live supply the first time _pace runs with _supplyPaced == 0 (one branch), which keeps the follow limit for everything after.

**Reproduction**

test/scratch/Judge.t.sol::test_launchDayFeeAgainstTheFloor (fails on d3861ac). Fresh ParameterizedVault at $1, NHI 0.85. WHALE locks 1,500,000 and draws 500,000 at deployment; one hour later pace(): paced().supply == 10000000000000000000000. redemptionFeeBps(5_000e18) == 300 (EXPECTED against the live supply at divisor 2: 50 + 50 = 100); redemptionFeeBps(9_000e18) == 500 (the cap). Hourly pacing reaches a 500,000 base after 27 paced hours.

### 6. Info: NatSpec: the follow bound 'at most FOLLOW_BPS_PER_HOUR an hour' compounds under frequent pacing (10.52% an hour paced every block, 11x not 9.85x over a day)

`src/CDPVault.sol:316`

```
    /// BACKING_RISE_PER_HOUR, nor moves the fee base or the work ceiling's debt faster than FOLLOW_BPS_PER_HOUR.
```

_step (lines 829-833) is FOLLOW_BPS_PER_HOUR x min(elapsed, PACE_INTERVAL) / 1 hour of the CURRENT paced value, applied at every pacing, and pace() is permissionless. Paced every 12-second block toward a distant live figure the supply and debt figures grow by (1 + 0.1 x 12/3600) per block, e^0.1 - 1 = 10.52% an hour rather than 10%, and 11.0x rather than 1.1^24 over a day. The backing's rise is absolute and does not compound. No economic consequence at the committed constants beyond the fee base and the work ceiling catching up about 5% faster than stated. Documentation: say 'per pacing, compounding', or compute the step from the value at the start of the interval.

**Reproduction**

Read _step: Math.mulDiv(Math.max(paced, _feeBaseFloor()), FOLLOW_BPS_PER_HOUR * Math.min(elapsed, PACE_INTERVAL), 10_000 * 1 hours) with `paced` the stored value at each pacing. Paced debt 1,000,000e18 with a far larger live debt: one pace after an hour gives 1,100,000e18; 300 paces 12 seconds apart over the same hour give 1,000,000 x (1 + 1/3000)^300 = 1,105,1xx e18. EXPECTED per line 316: at most 1,100,000e18 after an hour.

### 7. Info: Stale cross-reference: ParameterizedVault._redemptionReserveBacking cites CDPVault._mark, which this commit removed; the caller is CDPVault._pace through _liveBacking

`src/ParameterizedVault.sol:172`

```
        // Saturating, like the vault's backing it feeds: an absurd price must not revert lock or wipe (CDPVault._mark).
```

The per-position lag's _mark was replaced by _pace in d3861ac. The property claimed (an absurd price must not revert lock or wipe) still holds: Math.tryMul / Math.tryAdd saturate, and the pacing path's feed reads return zero rather than reverting. Only the name is dead. Reported by all four specialists. Fix: `CDPVault._pace`.

**Reproduction**

grep -n '_mark\b' src/*.sol finds only this comment; grep -n 'function _pace' src/CDPVault.sol finds the function it means (line 861).

### 8. Info: NatSpec: CDPVault.earnLine says ParameterizedVault overrides it with reserveValueUsd + totalDebt x earnMat / 10000; the override uses backedDebt (min of totalDebt, the transaction-start debt and the p

`src/CDPVault.sol:202`

```
    /// it with reserveValueUsd + totalDebt * earnMat / 10000, the bound docs/COMPUTE-BACKING-
```

ParameterizedVault.earnLine (276-278) is reserveValue() + backedDebt() x earnMat / 10000 and backedDebt (260-267) caps totalDebt at the transaction-start and paced figures and subtracts totalBadDebt. The base-vault sentence predates both and overstates the ceiling by the bad debt and the paced lag. Reported by audit_flow and audit_permissions. Fix: say backedDebt.

**Reproduction**

test/PacedFigures.t.sol::test_theWorkCeilingCountsDebtOnlyUpToThePacedDebt: totalDebt 1,000,000e18 drawn an hour ago, paced debt 110,000e18, earnLine() 27,500e18, not 250,000e18 as the sentence implies.

### 9. Info: NatSpec: securedCollateral says the exactly-counted set (at most 200% at the touch price) 'includes every redeemable one', but redemption eligibility is mat + gap = 220 at the launch constants

`src/CDPVault.sol:266`

```
    /// inside their bound (at most 200% at that price, which includes every redeemable one) are
```

SECURED_COLLATERAL_MULTIPLE is 2, so a term equals the collateral only up to 200% CR at its last price. redemptionCeilingCR() is mat() + gap(); at NHI >= 0.85 mat is 170 and Parameters.gap defaults to 50 (MIN_GAP 25), so positions between 200% and 220% are candidates whose term is 2 x principal / price, not their collateral. Documentation only; the consequence is the accepted non-monotone backing across a candidate-funded redemption (lines 722-725). Fix: 'which includes every redeemable one while mat + gap <= 200'.

**Reproduction**

NHI 0.85, gap 50: vault.redemptionCeilingCR() == 220. A position with 210 IMD against 100 imdUSD at $1 is eligible (210 < 220) while its term is min(210, 200) = 200.

### 10. Info: Comment: WIPED_THIS_TX_SLOT is said to tally principal the caller repaid 'on its own position'; the tally is per transaction, so one contract's wipe and another's draw in the same transaction also net

`src/CDPVault.sol:847`

```
    /// @dev keccak256("comp.CDPVault.principalWipedThisTransaction"): principal the caller repaid on its own
```

wipe adds amount - feePaid to the slot with no position key, and _pacedDebt / _clampPacedDebt read it as a single number (live = totalDebt + WIPED - MINTED). A seasoned borrower A wiping X and a fresh borrower B drawing X inside one transaction (through a relay) leave the paced debt where it was, exactly as a position's own wipe and redraw does; the aggregate is unchanged and the new debt is collateralised at mat, so this is the accepted cost of a ceiling that tracks totals, not whose debt (Q3), but it is not the per-position property the comment at 847-848 and line 310-311 state. Reported by audit_economics. Fix: say 'in this transaction, whoever repaid it'.

**Reproduction**

Read wipe (line 562): _transientAdd(WIPED_THIS_TX_SLOT, amount - feePaid) with msg.sender nowhere in the key; _pacedDebt (820) and _clampPacedDebt (878) sum it into one live figure.

### 11. Info: NatSpec: backingPerUnit() is said to read 'the latest accepted price', but it reads _price() with no freshness or agreement check, so it quotes against a stale or diverged nonzero price

`src/CDPVault.sol:754`

```
    /// @notice Value backing one imdUSD, 1e18-scaled, never above par, at the latest accepted price.
```

backingPerUnit() returns _backingPerUnit(_price()); _price() only rejects zero. cash itself is gated by _requireFreshFeeds and _requirePriceAgreement, and _pace holds the stored figure at an unusable price, so no payout is affected; only the public view's description is wrong. Reported by audit_economics. Fix: 'at the latest readable price (cash itself requires a fresh, agreed one)'.

**Reproduction**

Read lines 758-760 against _price (1476-1479): no call to _pricingStale, spotFeed.isStale or _requirePriceAgreement on the view's path.

---

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