# 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. Twelve audit rounds and their fixes are in (docs/AUDIT-*.md; the chain is web/content/docs/reference/audit-history.md; the newest are docs/AUDIT-FINAL-SWEEP-PANEL-2026-10-08.md and docs/AUDIT-DELTA-PANEL-2026-10-08.md, whose Resolution sections say how each finding was answered). This is the final full audit of this system before mainnet: read it in full, as it will deploy, not only the newest diff. 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 repay-then-redeem premium and the new-loan dilution of the reserve's share (CDPVault._backingPerUnit); new capital counting by its warmed fraction (BACKING_HALF_LIFE); a price fall's re-pricing counted as cold; a debt-side orphan in the totals (_cool); a redraw after a redemption releasing a repayment's fee share early (_lag). Rank severity by what a finding lets someone take or block, with the constants as committed.
>
> 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 BACKING FIGURE AND THE LAG, adversarially and in full. backingPerUnit = min(live, lagged), capped at par; lagged = (reserve x warm / supply + warm secured) / warm, warm = supply - fresh, supply = live + REPAID_THIS_TX_SLOT; cold capital kept per position (coldDebt, coldSecured, coldAt), cooling at BACKING_HALF_LIFE with a BACKING_WARMUP cutoff for positions, totals and banks alike; banks crediting a position's own warmth back; draw making a band position's new debt's share of its term cold (delta panel #1). Search every sequence of lock, lockIMD, free, draw, wipe, cash, cover, bite, earn and a Treasury donation, by one account or several, within one transaction or across many, for one that makes a redemption paid more than the honest backing of the book it found, or honest redeemers paid less than the accepted cases state. Treat the accepted cases' bounds as claims to verify numerically.
> 2. THE FEE BASE: _feeBase (live supply + cooled feeExcess - cold principal - work minted this transaction, floored at 100,000 imdUSD), feeExcess per position (added on repayments, released on bank credit), the fresh-debt record (FRESH_DEBT_WINDOW, 1e18-scaled dates), _redemptionRate with prior read once in cash, and the stored base rate's decay. Cheapest way to pin the cap or to dilute it; whether any repayment, redemption or draw ordering moves the base off the honest warm supply.
> 3. REDEMPTION PAYOUTS: cash's routes (reserve, candidate, mixed), candidate eligibility (mat + gap), RedemptionWorsensRatio, ExcessRepayment, minGemOut, the reserve valued at the vault's price, and the transient tallies (MINTED, SECURED, REPAID, WORK_MINTED this transaction). Can a redeemer pay less than the fee for its size, worsen a candidate, or take reserve beyond its share?
> 4. 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, the Treasury's imdUSD) and totalBadDebt against the per-position record. Can anyone take more than the formula, freeze a position unliquidatable, hold cover off cheaply, or desynchronise the bad-debt record?
> 5. 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, and the unreadable-price path in _resecureBounded). Below mat, double counting, reentrancy through the share vault, acting on a stale or divergent price.
> 6. WORK ISSUANCE with WAGE_WAD 0 at launch (earn refused, WorkMintingOff), and once governance turns it on: earnLine, earnMat, backedDebt and its transient slot, the lag as it applies to the ceiling. Unbacked minting by any route?
> 7. ARITHMETIC AND SIZE: overflow at extreme collateral, price or elapsed time (_pow, 128-bit saturation of cold figures and banks), rounding direction in every division that pays someone, units where a price, a 24-decimal amount and basis points meet; ParameterizedVault initcode 46,795 of 49,152 bytes.
> 8. Every comment or NatSpec in these files 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 | `9bd5f599678a10cbd3a47657300c7366c8c5605e` |
| Job | `5383ced0-fa82-4434-b6bd-62eb0fd2ff2b` |
| Judged | 2026-10-08 22:42 UTC |
| Findings | 1 high · 2 medium · 1 low · 1 info |

Four agents audited the code as it is at `9bd5f59`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: CDPVault.draw/_reduceDebt: the band-draw cooling (delta panel #1 fix) cools a redraw of the position's own WARM principal and is never reversed when the principal is repaid, so a band borrower's wipe/

`src/CDPVault.sol:507`

```
        if (position.secured == termBefore && termBefore > position.coldSecured) {
            _lag(position, true, termBefore, termBefore + Math.min(
                Math.mulDiv(termBefore, amount, position.debt), termBefore - position.coldSecured));
        }
```

Q1 (the lag) and Q8. `draw` first runs `_lag(position, false, debt, debt + amount)` (line 498), whose increase path credits the new principal WARM from `bankDebt` when the position repaid it within the last day. The band branch at lines 507-510 then cools `term x amount / debt` of the secured term for the WHOLE `amount`, never for the cold part only, through `_lag(position, true, term, term + X)`; that call is credited only from `bankSecured`, which a band position's repayment never fills (its term is its collateral, and min(collateral, 2 x principal / price) does not move for a repayment that leaves the ratio under 200%, so `_resecureBounded` -> `_lag` returns at `after_ == before`). And `_reduceDebt` (1423-1441) has no inverse: the debt `_lag` retires `coldDebt` first, but the secured term does not move, so the cold secured share added by the draw stays until it decays (6-hour half-life). Each wipe(a)/draw(a) pair therefore adds min(term x a / debt, term - coldSecured) to `position.coldSecured` and `_coldSecured` while `coldDebt` returns to zero; a handful of pairs in one transaction make the position's entire collateral term cold with its debt warm. `_backingPerUnit` (799-812) then computes lagged = (reserve x warm / supply + warm secured value) / warm with the churner's collateral out of the numerator and its debt still in the denominator, and `cash` (732) pays min(live, lagged) x (1 - fee) on every route, reserve- or candidate-funded. WHO AND WHAT: any band borrower (170-200%, which is every position drawn near mat), or an attacker who opens one for the purpose; `wipe` has no feed or health check and `draw` needs only fresh, agreeing feeds and health; no price exposure, gas only, re-armable every block, lasting hours. Honest redeemers are underpaid (the peg's defence yields a fraction of par on demand); a churner that is also the candidate has its debt cancelled at par while releasing collateral at the reduced figure, keeping the difference; anyone short imdUSD, or wanting to buy back its own debt cheaply, benefits from the redemption floor being switched off. Honest band borrowers' ordinary repay-and-redraw does the same to a lesser degree. NUMBERS (test/scratch/WarmRedrawColdTerm.t.sol, the attached proof; ParameterizedVault over MockIMD at $1, NHI 0.85 so mat 170, no reserve): CHURNER lock(180,000) draw(100,000) (180%, term = whole collateral); HONEST lock(100,000) draw(50,000), hands REDEEMER 10,000 imdUSD; +2 days: backingPerUnit() == 1e18, laggedNow() == (150,000e18, 280,000e18). CHURNER runs wipe(10,000)/draw(10,000) ten times in one transaction. EXPECTED: an unchanged book, backingPerUnit() still 1e18, laggedNow() still (150,000, 280,000), REDEEMER's cash(10,000, 0, CHURNER) paid 9,616e18 raw IMD. ACTUAL: laggedNow() == (150,000.009e18, 100,113e18), the churner's 180,000 term cold and its 100,000 debt warm; backingPerUnit() == 667420416224269858; the redemption is paid 6,417.9e18 raw IMD (3,198 IMD under), and the churner's debt falls by the full 10,000 while it releases 6,418 of collateral. Two more reproductions: test/scratch/PhantomCold.t.sol (a churner holding 90% of the book's collateral, twenty draw(5,000)/wipe(5,000) pairs in one transaction: 1.0 -> 0.2607, a 1,000 imdUSD redemption paid 258 raw IMD) and test/scratch/BandRedrawCooling.t.sol (a below-par book at 0.8617, a band borrower holding a quarter of the collateral, nine pairs: 0.5751). Reachable with the constants as committed (mat 170 at NHI >= 0.85, LINE 1M, wage 0). Not one of the three accepted underpayments at lines 781-784 (a price fall's re-pricing, the stale orphan, the new-loan dilution): those are natural and bounded; this is attacker-chosen and bounded only by the churner's share of the book's secured collateral (at LINE 1M a borrower with 40% of the secured collateral cuts every payout by about 40%). The NatSpec at 506 ('The new debt's share of the term goes cold with it'), 775-776, 781-782 ('three accepted cases, all the safe direction'),

**Reproduction**

forge test --match-path test/scratch/WarmRedrawColdTerm.t.sol (fails on 9bd5f59 with 'a warm wipe/redraw round trip lowered the backing of an unchanged book: 667420416224269858 < 1000000000000000000'). Setup: ParameterizedVault over MockIMD at $1 (Chainlink 2000e8 etched at CHAINLINK_ETH_USD, TreasuryFactory etched at TREASURY_FACTORY), NHI 0.85, no reserve. CHURNER lock(180_000e18), draw(100_000e18); HONEST lock(100_000e18), draw(50_000e18), transfers 10_000e18 imdUSD to REDEEMER; +2 days. backingPerUnit() == 1e18, laggedNow() == (150_000e18, 280_000e18). CHURNER: ten times wipe(10_000e18); draw(10_000e18) in one transaction. EXPECTED: backingPerUnit() == 1e18, laggedNow().secured == 280_000e18, REDEEMER cash(10_000e18, 0, CHURNER) paid 9_616e18 raw IMD. ACTUAL: backingPerUnit() == 667420416224269858, laggedNow() == (150_000.009e18, 100_113e18), paid 6_417.9e18 raw IMD. Also test/scratch/PhantomCold.t.sol (1.0 -> 0.2607, 1,000 imdUSD paid 258e18 raw IMD) and test/scratch/BandRedrawCooling.t.sol (0.8617 -> 0.5751).

**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 (170-200%) that repays WARM principal and redraws it has the debt credited back warm from
// its bank, but `draw`'s band branch (the delta-panel fix) still cools `term x amount / debt` of its secured
// term, with no bank credit, because the repayment never moved the term. Repeating the round trip walks the
// position's whole secured term into the cold total while its debt stays warm. `_backingPerUnit`'s lagged
// figure then drops that collateral and pays every redeemer against the rest of the book, for gas.
//
// Fails on the pinned commit: backing reads 1.0 before the churn and 0.667 after it, with nothing about the
// book changed; an honest 10,000 imdUSD redemption is paid 6,566 raw IMD where 9,850 is honest.

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 WrFeed 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 WrMirror 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 WrAggregator {
    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 WarmRedrawColdTermTest is Test {
    address private constant CHURNER = address(0xC4);
    address private constant HONEST = address(0xB0B);
    address private constant REDEEMER = address(0x5EED);

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

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    WrFeed 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 WrAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new WrFeed(DOLLAR);
        WrFeed health = new WrFeed(0.85 ether); // mat 170
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new WrMirror(primary))
        );
        stable = vault.stablecoin();
        vm.startPrank(APPROVED_OPERATOR);
        imd.mint(CHURNER, 180_000 ether);
        imd.mint(HONEST, 100_000 ether);
        vm.stopPrank();
        vm.prank(CHURNER);
        imd.approve(address(vault), type(uint256).max);
        vm.prank(HONEST);
        imd.approve(address(vault), type(uint256).max);
    }

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

    function test_warmRedrawInBandCoolsTermAndUnderpaysRedeemers() public {
        // A warm book: the churner at 180% (term = its whole collateral), an honest borrower at 200%.
        vm.startPrank(CHURNER);
        vault.lock(180_000 ether);
        vault.draw(100_000 ether);
        vm.stopPrank();
        vm.startPrank(HONEST);
        vault.lock(100_000 ether);
        vault.draw(50_000 ether);
        stable.transfer(REDEEMER, 10_000 ether);
        vm.stopPrank();
        vm.warp(block.timestamp + 2 days);
        _next();

        uint256 before = vault.backingPerUnit();
        emit log_named_uint("backing before the churn (book fully warm)", before);
        (uint256 price,) = vault.collateralPriceFeed().latestValue();
        uint256 feeBps = vault.redemptionFeeBps(10_000 ether);
        uint256 honestPay = Math.mulDiv(Math.mulDiv(10_000 ether, before, 1e18) * (10_000 - feeBps) / 10_000, 1e18, price);

        // The churner repays 10% of its warm principal and redraws it, ten times, in ONE transaction. Each
        // repayment leaves its term (= collateral) unchanged, so nothing secured is banked; each redraw is
        // credited warm from the debt bank, yet cools term x 10,000 / 100,000 = 18,000 IMD of the term.
        vm.startPrank(CHURNER);
        for (uint256 i; i < 10; ++i) {
            vault.wipe(10_000 ether);
            vault.draw(10_000 ether);
        }
        vm.stopPrank();
        _next();

        uint256 after_ = vault.backingPerUnit();
        emit log_named_uint("backing after the churn (same book, same prices)", after_);
        (uint256 lagDebt, uint256 lagSecured) = vault.laggedNow();
        emit log_named_uint("lagged debt (warm principal)", lagDebt);
        emit log_named_uint("lagged secured (warm collateral)", lagSecured);

        vm.prank(REDEEMER);
        uint256 paid = vault.cash(10_000 ether, 0, CHURNER);
        emit log_named_uint("paid for 10,000 imdUSD (raw IMD)", paid);
        emit log_named_uint("honest payout (raw IMD)", honestPay);

        assertGe(after_, before, "a warm wipe/redraw round trip lowered the backing of an unchanged book");
        assertGe(paid, honestPay, "an honest redemption was underpaid against the honest backing of the book");
    }
}
```

### 2. Medium: CDPVault.draw: the band-draw cooling fires only when the term is exactly unchanged and cools through the position's secured bank, so a one-wei repayment, a repay-and-redraw-more, a draw crossing into

`src/CDPVault.sol:507`

```
        if (position.secured == termBefore && termBefore > position.coldSecured) {
            _lag(position, true, termBefore, termBefore + Math.min(
                Math.mulDiv(termBefore, amount, position.debt), termBefore - position.coldSecured));
        }
```

Q1 and Q8, the fix for delta panel #1 (job fc96f209, medium) recorded as complete in docs/AUDIT-DELTA-PANEL-2026-10-08.md Resolution #1. Two gaps in the branch at lines 507-510. (i) The guard `position.secured == termBefore` is defeated by any movement of the term in the draw's own `_resecure`, and `_lag`'s increase path (1020-1023) then restores the term WARM from `bankSecured` (`credit = min(after_ - before, bank)`), which any earlier repayment, free, redemption or bite against the position filled. (ii) The cooling itself goes through `_lag(position, true, term, term + X)`, whose increase path credits that same bank first, so a position holding a secured bank has nothing cooled. In every variant the state is cold debt with no cold secured term: `_backingPerUnit`'s lagged figure drops the new imdUSD from the warm supply but keeps all the collateral now standing behind it, reading above the honest live figure; a newcomer's lock + draw in one transaction (its capital cold, so the lagged figure is untouched) lifts the live figure, and a `cash` in the next transaction is paid min(live, lagged) = the overstated lagged figure from the reserve or from an eligible candidate (see the candidate finding); the newcomer unwinds. VARIANTS, all reproduced on 9bd5f59 with the panel's own fixture (ParameterizedVault over MockIMD at $1, NHI 0.85, Treasury 10,000 IMD; BORROWER lock(200,000) draw(100,000), +2 days): (a) test/scratch/BandFixBypass.t.sol::test_oneWeiWipeSkipsBandCooling: wipe(stabilityFee + 1), one wei of principal, moves the term by 2 wei and banks them; next block draw(17,000) restores the term from the bank and the branch is skipped: laggedNow() == (100,006e18, 200,000e18) where the stated property gives 170,940e18; after IMD halves the honest live figure is 0.897435897435897435; NEWCOMER lock(400,000)+draw(100,000) in one transaction; next block backingPerUnit() == 1e18 and NEWCOMER cash(5,000, 0, address(0)) is paid 9,700e18 raw IMD from the reserve against at most 8,705e18 (the committed fix without the wipe gives 0.878 and 8,518). (b) ::test_wipeAllThenRedrawMoreSkipsBandCooling: wipe(100,000) then draw(117,000) in one transaction: the term goes 200,000 -> 48 -> 200,000, credited whole from the bank; lagged secured 200,000e18 against the property's 170,951e18; 1.0 against 0.8972; 9,700 against 8,703. (c) test/scratch/CrossingDraw.t.sol::test_drawCrossingIntoBandSkipsCooling (the economics specialist's proof): collateral 200,001 against 100,000, so the term moves by one IMD on the draw into the band: 1.0 against 0.8974, 9,700 against 8,705. (d) ::test_secondBankAbsorbsBandCooling: free(20,000) at 200% banks 20,000 of warm term; draw(5,800)'s cooling share 9,868 is credited from that bank and nothing goes cold: 0.9247 against 0.8979, 8,969 against 8,710 raw IMD. (e) test/scratch/FreeConsumesPhantom.t.sol: after a cooled band draw of 8,000 (14,815 of the term cooled) a free(14,814) retires the band share first (`_lag` 1013-1018 takes the position's own cold before banking), so laggedNow().secured == securedCollateral == 185,186e18 while 8,000 of debt is cold; the stated property gives at most 171,480e18. Reachable with the constants as committed (mat 170 at NHI >= 0.85, LINE 1M, wage 0). Preconditions and bound as the panel stated: a below-par book, a reserve or an eligible candidate, gain at most fresh_band / warm of the gap to par; cost one wei of repayment, the redemption fee and a newcomer's collateral comparable to the warm book for one block; the same actor can run both halves with two addresses. The NatSpec at 775-776 ('new debt, the imdUSD minted against it and the collateral behind it are excluded together, a band position's draw included') claims the property the code lacks, and 981 ('a bank only ever returns warmth to the position that lost it') is broken by the band call spending the secured bank on an increase that never happened (variant d: a later re-lock of the freed 20,000 is credited only 10,132 warm). SMALLE

**Reproduction**

forge test --match-path test/scratch/BandFixBypass.t.sol (both tests fail on 9bd5f59: 'fresh capital lifted a redemption's backing: 1000000000000000000 > 897435897435897435' and '... > 897249312090033908'). ParameterizedVault over MockIMD at $1 (Chainlink 2000e8 etched), NHI 0.85, Treasury 10,000 IMD. BORROWER lock(200_000e18), draw(100_000e18); +2 days. (a) BORROWER wipe(stabilityFeeOf(BORROWER) + 1); next block draw(17_000e18): laggedNow() == (100_006e18, 200_000e18), EXPECTED secured at most 200_000 x 100_006 / 117_000 = 170_940e18. IMD to $0.50: backingPerUnit() == 897435897435897435 (honest). NEWCOMER lock(400_000e18), draw(100_000e18) in one tx; next block backingPerUnit() == 1e18 and NEWCOMER cash(5_000e18, 0, address(0)) paid 9_700e18 raw IMD. EXPECTED at most 0.8974e18 and 8_705e18. (b) BORROWER wipe(100_000e18); draw(117_000e18) in one tx: laggedNow().secured == 200_000e18, EXPECTED at most 170_951e18; then as (a): 1e18 and 9_700e18 against 0.8972e18 and 8_703e18. Further variants: forge test --match-path test/scratch/CrossingDraw.t.sol (1e18 > 897440170940170940; 924658507768884829 > 897920604914933837) and test/scratch/FreeConsumesPhantom.t.sol (laggedNow().secured 185185999999999999999999 > 171480079155302587017743).

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

// Judge's reproduction of the band-draw fix bypass (delta panel #1, CDPVault.draw lines 507-510).
// The fix cools the new debt's share of a band position's term only when `position.secured == termBefore`, and
// cools it THROUGH `_lag`'s increase path, which credits the position's secured bank first. Three ways to leave the
// collateral behind new cold debt warm in the lagged figure:
//   1. wipe one wei of principal first: the term moves by 2 wei, the branch is skipped, the bank restores the term;
//   2. wipe everything then redraw more: the term moves from ~0 back to the collateral, credited whole from the bank;
// (A third way, a free after a cooled band draw consuming the phantom cold, is in FreeConsumesPhantom.t.sol.)
// In each, a newcomer's one-transaction lock+draw lifts the live figure and a reserve-funded cash in the next
// transaction is paid the overstated lagged figure.

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 JbFeed 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 JbMirror 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 JbAggregator {
    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 BandFixBypassTest 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;
    JbFeed 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 JbAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new JbFeed(DOLLAR);
        JbFeed health = new JbFeed(0.85 ether); // mat 170
        vault = new ParameterizedVault(
            address(imd), address(0), address(0), address(primary), address(health), address(new JbMirror(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();
        vm.prank(BORROWER);
        imd.approve(address(vault), type(uint256).max);
        vm.prank(NEWCOMER);
        imd.approve(address(vault), type(uint256).max);
    }

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

    function _warmBook() private {
        vm.startPrank(BORROWER);
        vault.lock(200_000 ether);
        vault.draw(100_000 ether); // 200%: term = 200,000 = the whole collateral
        vm.stopPrank();
        vm.warp(block.timestamp + 2 days);
        _next();
    }

    /// @dev Variant 1: one wei of principal repaid moves the term by two wei; the next draw is not cooled.
    function test_oneWeiWipeSkipsBandCooling() public {
        _warmBook();
        uint256 oneWeiOfPrincipal = vault.stabilityFeeOf(BORROWER) + 1;
        vm.prank(BORROWER);
        vault.wipe(oneWeiOfPrincipal);
        _next();
        vm.prank(BORROWER);
        vault.draw(17_000 ether);
        _next();
        (uint256 lagDebt, uint256 lagSecured) = vault.laggedNow();
        emit log_named_uint("lagged debt", lagDebt);
        emit log_named_uint("lagged secured", lagSecured);
        _crashAndRedeem();
    }

    /// @dev Variant 2: repay everything (term to ~0, banked), redraw more: term restored whole from the bank.
    function test_wipeAllThenRedrawMoreSkipsBandCooling() public {
        _warmBook();
        vm.startPrank(BORROWER);
        vault.wipe(100_000 ether);
        vault.draw(117_000 ether);
        vm.stopPrank();
        _next();
        (uint256 lagDebt, uint256 lagSecured) = vault.laggedNow();
        emit log_named_uint("lagged debt", lagDebt);
        emit log_named_uint("lagged secured", lagSecured);
        // The panel's property: the lagged secured term should be at most term x warm debt / debt.
        uint256 expectedAtMost = Math.mulDiv(200_000 ether, lagDebt, 117_000 ether);
        emit log_named_uint("lagged secured at most, per the stated property", expectedAtMost);
        _crashAndRedeem();
    }

    function _crashAndRedeem() private {
        primary.set(DOLLAR / 2);
        _next();
        uint256 honest = vault.backingPerUnit();
        emit log_named_uint("honest, the live figure before the newcomer", 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);

        vm.startPrank(NEWCOMER);
        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);
        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");
    }
}
```

### 3. Medium: CDPVault._redeemPosition/_backingPerUnit: a candidate-funded redemption is paid the lifted lagged figure in full; RedemptionWorsensRatio only caps it at the candidate's own collateral fraction, so the

`src/CDPVault.sol:877`

```
        if (gemOut > Math.mulDiv(position.collateral, amount, debt)) revert RedemptionWorsensRatio();
```

Q1 (an accepted case whose stated bound is checked numerically) and Q3. The acceptance of delta panel #3 (CDPVault 325-330, docs/AUDIT-DELTA-PANEL-2026-10-08.md Resolution #3, docs/MAINNET-RUNBOOK.md section 7) rests on 'gains at most the gap to par on the Treasury's reserve, since a position-funded payout stays pro rata'. `cash` computes `payoutScale = _backingPerUnit(price) x (1 - fee)` once (732) and the candidate route pays `gemOut - reserveOut` from the candidate at that scale; the only guard on that route is this line, `gemOut > collateral x amount / debt`, which keeps the payout within the CANDIDATE's own collateral per unit of its debt (its ratio cannot worsen) and says nothing about the book's backing. So every candidate below mat + gap (220 at launch) whose ratio is at or above the lifted figure x (1 - fee) pays the lifted figure in full, and with an EMPTY reserve the whole overpayment comes from the candidate's collateral: the gap to par is taken from the book, bounded by the debt of every eligible candidate (at launch most of the book), not by the Treasury's sIMD. REPRODUCTION (test/scratch/CandidateLift.t.sol; ParameterizedVault over MockIMD at $1, NHI 0.85, Treasury holds NOTHING): UNDERWATER lock(170,000) draw(100,000); CANDIDATE lock(30,000) draw(10,000); +2 days; IMD to $0.50 (UNDERWATER 85%, CANDIDATE 150%, eligible): backingPerUnit() == 863636363636363636, the honest figure ((85,000 + 10,000) / 110,000, live = lagged). NEWCOMER lock(2,000,000) draw(500,000) (200% at $0.50, about 4.5 times the warm book); +15 minutes: backingPerUnit() == 995346872925276794. NEWCOMER cash(5,000, 0, CANDIDATE). EXPECTED per the stated bound: nothing beyond the reserve can be taken, so with no reserve the candidate pays at most the honest 0.8636 x (1 - fee): 8,419.6e18 raw IMD. ACTUAL: 9,703.6e18 raw IMD paid, all from the candidate's collateral (its collateral falls by exactly the payout, the Treasury's balance is unchanged), 1,284 IMD ($642, 15%) above the honest payout; the newcomer then repays and is unwound. The attacker redeems the imdUSD it drew, so no market purchase is needed for the redemption; its cost is the lift's collateral for fifteen minutes and the 0.5% fee. Reachable with the constants as committed while the book is thin (the lift needs collateral about twice k times the warm book, which binds on a mature book but not at launch). The lift itself is accepted and is not the finding; the finding is that the reason given is wrong (the candidate's ratio staying constant is not a pro-rata payout of the book's backing) and the bound and its mitigation are therefore wrong: keeping the Treasury's sIMD small bounds nothing on this route. SMALLEST FIX is a decision for the requester, as the panel said: either restate the bound at CDPVault 327-329, in the delta panel Resolution #3 and in docs/MAINNET-RUNBOOK.md section 7 as the gap to par on the reserve PLUS the debt of every eligible candidate, dropping 'a position-funded payout stays pro rata'; or close it in code, for instance by counting a fresh position's lagged contribution at min(its secured value, its warm principal) rather than its whole term (the panel declined a slower collateral warm-up because it underpays honest loans; this narrower cap only bounds a fresh position's contribution at par).

**Reproduction**

forge test --match-path test/scratch/CandidateLift.t.sol (fails on 9bd5f59 with 'a position-funded redemption was paid the lifted figure, above the honest backing: 9703636664148523460000 > 8419590909090909087364'). ParameterizedVault over MockIMD at $1 (Chainlink 2000e8 etched), NHI 0.85, no Treasury sIMD. UNDERWATER lock(170_000e18) draw(100_000e18); CANDIDATE lock(30_000e18) draw(10_000e18); +2 days; IMD to $0.50: backingPerUnit() == 863636363636363636. NEWCOMER lock(2_000_000e18) draw(500_000e18); +15 minutes: backingPerUnit() == 995346872925276794. NEWCOMER cash(5_000e18, 0, CANDIDATE). EXPECTED: at most 8_419.6e18 raw IMD (honest 0.8636 x (1 - fee) / 0.5). ACTUAL: 9_703.6e18 raw IMD from the candidate's collateral, Treasury untouched.

### 4. Low: CDPVault.securedCollateral: a position's term fixed at a crash low is counted at the recovered price in both the live and the lagged figure, and the 'aggregate cap' the NatSpec says bounds the over-co

`src/CDPVault.sol:285`

```
    /// tightens the guard until the position is touched, and after a rise for more, which the
    /// aggregate cap bounds. A deposit, repayment or any other change re-prices the position's term.
```

Q1, Q5 and Q8. Each term in `securedCollateral` is min(collateral, 2 x principal / price) at the price of the position's LAST touch (`_secured`, 940-946), and `_securedCollateralValue` (836-851) values the sum at the CURRENT price, capped at mat x prior principal. The NatSpec admits the surplus above 200% is approximated and says a rise counts 'for more, which the aggregate cap bounds'. That cap is 170% of the warm debt, above par by construction, so it never binds while the book is below par, which is exactly when the payout is scaled by the figure; the over-count flows straight into both the live and the lagged figure (both use the same stale `held`), and nothing but the owner's own transaction re-prices the term (`bark`, `heel` and `bite` do not touch a healthy position's term; `cash` cannot name one above mat + gap). A position re-collateralised at a low to above 200% (the prudent action) fixes its term at 2P / p_low; at a recovered price f x p_low it is worth 2fP against an honest 2P. The same holds for any band position (term = collateral) untouched through a rise: counted collateral x new price, above 2P. REPRODUCTION (test/scratch/StaleTermRise.t.sol; ParameterizedVault over MockIMD at $1, NHI 0.85, Treasury 2,000 IMD): UNDERWATER lock(170,000) draw(100,000); WHALE lock(50,000) draw(10,000) (500%, term 20,000) and hands REDEEMER 5,000 imdUSD. IMD to $0.25 (UNDERWATER 42.5%, WHALE 125%); WHALE lock(50,000) at the low: 250%, term re-priced to min(100,000, 2 x 10,000 / 0.25) = 80,000 IMD. +2 days (all warm). IMD to $0.50: UNDERWATER at 85% ($85,000), WHALE at 500%, honest secured value 2 x 10,000 = $20,000, counted 80,000 x $0.50 = $40,000; securedCollateral == 250,000 IMD, worth $125,000, below the cap of $187,000. EXPECTED: backingPerUnit() == (85,000 + 20,000 + 1,000) / 110,000 = 963636363636363636 and REDEEMER cash(1,000, 0, address(0)) paid at most 1,908.8e18 raw IMD. ACTUAL: backingPerUnit() == 1e18 and 1,980.8e18 raw IMD paid from the reserve (72 IMD, 3.8%, over the honest payout). Reachable with the constants as committed; needs a fall, a partial recovery and an untouched position whose term was fixed at the low; bounded by the stale surplus over the supply and by the gap to par on the reserve (and on eligible candidates, per the previous finding), and the whale itself can redeem at the over-count. Low. The price-fall half ('counts for less') is an accepted item; the finding is that the stated bound on the rise half is vacuous in the only regime where the figure matters. SMALLEST FIX: a permissionless re-price, e.g. `function poke(address owner) external nonReentrant { _requireFreshFeeds(); _requirePriceAgreement(); _resecure(_positions[owner], _price()); }`, so a keeper or a redeemer's counterparty can correct a stale term before paying against it (the increase side of such a re-price is already the accepted cold direction; the initcode margin is 2,357 bytes); otherwise state at 284-286 that the cap does not bound the over-count below par.

**Reproduction**

forge test --match-path test/scratch/StaleTermRise.t.sol (fails on 9bd5f59 with 'a stale term priced at the low overstates backing after the recovery: 1000000000000000000 > 963636363636363636'). ParameterizedVault over MockIMD at $1 (Chainlink 2000e8 etched), NHI 0.85, Treasury 2_000 IMD. UNDERWATER lock(170_000e18) draw(100_000e18); WHALE lock(50_000e18) draw(10_000e18), transfers 5_000e18 to REDEEMER. IMD to $0.25; WHALE lock(50_000e18); +2 days; IMD to $0.50. EXPECTED backingPerUnit() == 963636363636363636, REDEEMER cash(1_000e18, 0, address(0)) paid at most 1_908.8e18 raw IMD. ACTUAL: 1e18 and 1_980.8e18 raw IMD from the reserve; securedCollateral() == 250_000e18.

### 5. Info: Comments and NatSpec that claim properties the code does not have at 9bd5f59: redeemable positions run to 220% not 200%; the initcode figure at the oracle note is stale; `line` is governed on the depl

`src/CDPVault.sol:282`

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

Q8. (1) CDPVault 282, 'at most 200% at that price, which includes every redeemable one': `redemptionCeilingCR()` is mat + gap (229-231), 220 at launch with gap 50 (Parameters allows 25-100), and `_redeemPosition` accepts any ratio below it (871), so a 210% candidate is redeemable while its term is 2 x principal / price, not its collateral; the surplus-approximation caveat in the same paragraph applies to redeemable positions too. (2) CDPVault 408, 'this vault's subclass is already at 36,416 of the 49,152 EIP-3860 permits': `forge build --sizes` reports ParameterizedVault initcode 46,795 bytes (margin 2,357) and runtime 22,582 (margin 1,994), the figures the brief states; the number in the comment is from an earlier revision. (3) CDPVault 163-165, 'There is no admin, so the value a deployment chooses is permanent for that vault — raising a ceiling means a new vault and a migration': true of the base vault, misleading for the deployment: ParameterizedVault 96-98 overrides `line()` with `parameters.line()`, which APPROVED_OPERATOR proposes and applies after the 48-hour timelock (Parameters 301-302, 490; only a zero ceiling is refused). (4) CDPVault 1203-1204, 'A borrower may mark its own position and so recover the marker's share ... its effective penalty is then 18%': holds when a third party bites. A borrower that marks AND bites its own position receives `collateralSeized - protocolCut` (1266-1267): for R imdUSD burned at price p it is paid 1.2R/p - 0.1 x 0.2R/p = 1.18R/p of its own collateral, a 2% penalty. Not a code defect (the protocol's cut and the bad debt are the same whoever bites, and the position must be marked and past grace), but the comment should state the 2% floor. (5) Covered by the findings above and listed here for completeness: 506 ('The new debt's share of the term goes cold with it'), 775-776 (the band case included in the exclusion), 781-782 ('three accepted cases, all the safe direction'), 49-50 and 1121 (the band share read as cold capital), 327-329 (the lift's bound and 'a position-funded payout stays pro rata'), 285 ('which the aggregate cap bounds'), 981 ('a bank only ever returns warmth to the position that lost it'). VERIFIED AS HOLDING: REDEMPTION_SECOND_DECAY 999983955055097432 = floor(1e18 x 2^(-1/43200)) (exact ...432.54) and COLD_SECOND_DECAY 999967910367635122012970996 = floor(1e27 x 2^(-1/21600)) (exact ...996.36); the supply identity at 148 (each repayment burns fee + principal and remints the fee; a redemption burns everything); `_pow`'s no-overflow bound (899-900); `_cool`'s rounding directions and the day cutoff (1046-1057); `_redemptionRate`'s 'prior is never zero on a deployed vault' (the 100,000 floor); `_feeBase`'s 9,000 / about 250 / 450 figures; `tail()` 1365-1367; `mat` rounding up; `bite`'s floor payout and dust-whole rule; `cover`'s dust and re-lock-at-value rules; ImdUSD 12-17 (no ownership, upgrade, pause, rescue or role-management path; the single minter/burner is bound at construction or once by APPROVED_OPERATOR); ParameterizedVault 166-175 (unlisted sIMD valued at the vault's price on both sides) and 240-242 (per-position banks).

**Reproduction**

(1) read src/CDPVault.sol 229-231 and 871 against 282. (2) forge build --sizes: ParameterizedVault 22,582 runtime / 46,795 initcode. (3) read src/ParameterizedVault.sol 96-98 and src/Parameters.sol 301-302, 490 against CDPVault 163-165. (4) read CDPVault 1224, 1241, 1266-1267: R = 1_000e18 at price 1e18 seizes 1_200e18, protocolCut 20e18, a self-marked self-biter receives 1_180e18, net loss 20e18 = 2%. (5) the scratch tests of the findings above. Decay constants: python3 -c "from decimal import *; getcontext().prec=40; print(Decimal(2)**(Decimal(-1)/43200)*10**18, Decimal(2)**(Decimal(-1)/21600)*10**27)".

---

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