# 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. Sixteen 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 (a3aa9e4, docs/AUDIT-FINAL-SWEEP-2-2026-10-09.md) the vault changed twice and nothing else in src/ did: git diff a3aa9e4 e4baedf -- src script deploy. The sweep's high (a pool held down through the feed's window paid a redeemer the whole fall in IMD) was answered with a paced payout price: cash pays at max(attested, paced), the paced price falling at a bounded rate. A panel on that fix (docs/AUDIT-PAYOUT-VAULT-PANEL-2026-10-09.md) showed the rate bounds the speed of a fall, not its size (at 5% an hour a five-hour hold was paid the whole step); the rate is now 1% an hour, a 20% step reaches the payout after about 22 paced hours, and the fix rests on the claim that a pool cannot be held 20% down on its only market for most of a day at a cost below the gain. The same commits clamp the paced debt against pre-existing principal a transaction cancels, with the position's own principal minted within FRESH_DEBT_WINDOW (12 hours) netted out, seed the paced supply no higher than the fee-base floor, read the price and NHI once per entry point, replace the reentrancy guard with a transient-storage one, and make verifySeeded check the first NHI. Both records' Resolution sections say how each finding was answered and what was ACCEPTED with its bound stated; an accepted item is a finding only if the reason is wrong or the bound does not hold. A finding of an earlier round counts only if its fix regressed or left a gap. 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, the backing's rise 2 points of par an hour, the follow 10% an hour, the payout price's fall 1% an hour, PACE_INTERVAL 1 hour, fee floor 100,000 imdUSD, fee cap 5%, FEED_MAX_DEVIATION_BPS 2000, SKEW_BPS 500).
>
> 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 PAYOUT PRICE'S ECONOMICS (cash, _pacedPrice, _paceWith, payoutPrice). With the pool as it is (about $2.3M a side, 1% fee, no other market) and the feeds as committed (a 20% step from a fresh anchor, 40% after two silent hours, both feeds reading the one pool, the 13-sample two-hour median): the cheapest profitable hold-down in money and hours, now that each paced hour is worth 1% of redeemed volume and the fee cap eats the first five; what dip-buying, arbitrage and the attacker's inventory cost over such a hold, and whether anything in the protocol (the Treasury's fall trigger, resecure, liquidations at the held price) shortens it or pays for it; whether a RISE held through one window then released, or any fall-and-rise sequence, pays a redeemer more than the honest IMD or blocks more than the stated 18 hours; the cost to honest redeemers after a real fall against the NatSpec. Is 1% an hour the right bound, and if not, what is?
> 2. THE CLAMP AND THE NETTING (_tallyPrincipalRetired, _recentlyMinted, _clampPacedDebt, CANCELLED_PRE_SLOT, MINTED_BY_SLOT). Every ordering of draw, wipe, cash, bite and cover by one or several positions, in one transaction and across blocks and across the twelve-hour window: can zero-second debt count for the work ceiling sooner than the follow rate, can the netting of a position's recent principal hide the cancellation of seasoned debt (a confederate's, or the same position's older tranche after a redraw moved its record), and can the paced debt be driven below the seasoned book for less than holding the debt twelve hours?
> 3. THE PACED FIGURES ACROSS THE SYSTEM. With the Treasury (fundOracle, redeemIMD, cover, withdraw's floor, donations), Parameters (a change mid-flight), the feeds (a first value, a widened epoch, a stale window, a Chainlink outage) and SwarmRelay (bundling a feed update with a liquidation, a pace or a resecure): any sequence that pays a redemption more than the honest backing at the paid price, mints work against debt not held for the follow rate, moves the fee base faster than the follow rate, or desynchronises a record.
> 4. THE LAUNCH WINDOW, hour by hour for the first day, docs/MAINNET-RUNBOOK.md section 7 against the code: the first values and verifySeeded (both references, the bands), stage two with VAULT_SALT through a private relay, the keeper (pace() hourly, resecure after each update, bite with its own imdUSD), the first draws, the first redemptions (the fee floor, the seeded supply, the payout price seeded at the first usable price), the first fall and liquidation (grace, dust, bad debt covered with no fees accrued), and every way the protocol can halt that day and how each recovers.
> 5. THE ORACLE AS AN ATTACK SURFACE ON THE VAULT beyond question 1: over-borrowing at a pushed-up price then a liquidation or redemption, NHI (mat and grace), the spot's skew, a first value after a silent lifetime; in money and hours at LINE $1M.
> 6. GOVERNANCE, THE TREASURY AND THE DEPLOYMENT for regressions only: the timelock and bounds, every exit from the Treasury bounded as documented, the reserve valuation, the factories; DeployMainnet.run, verifySeeded, runVault (VAULT_SALT, _refuseAnotherVault, record before verify), verify, plan.py, the pinned bodies; what can still be deployed wrong and pass. Initcode 47,961 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 | `e4baedf95fc3bb10e3c7bd5264c829fbbc784cc3` |
| Job | `ed4f7f6d-0b24-4762-91b5-3f1d61657d68` |
| Judged | 2026-10-09 07:42 UTC |
| Findings | 1 high · 3 low · 2 info |

Four agents audited the code as it is at `e4baedf`, 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: bite prices the seizure at a held-down attested price, outside the paced payout price: holding the pool down through the grace pays a liquidator 1.5x to 1.875x the debt, against the accepted bound of

`src/CDPVault.sol:1354`

```
        uint256 collateralSeized = Math.mulDiv(debtToRepay, (100 + CHOP_PERCENT) * 1e16, price);
```

Merged from four specialist reports (audit_permissions 187de2e5, audit_economics 2ffa458e, audit_math a69eee9c, audit_flow 58bc8249). They describe one mechanism. The final sweep 2 high was answered by pacing only the price `cash` pays at (max(attested, paced), falling 1% an hour). `bite` (CDPVault.bite) still checks health and prices the seizure at the ATTESTED price: collateralSeized = debt x 1.2 / price, principalPart = debt / price. Both feeds read the one Uniswap v4 pool, so the skew check passes. The feed allows a 20% step from a fresh anchor in each epoch. Anyone who holds the pool down can therefore bark at the held price, keep the low attested hourly through the grace (lull 6 h at NHI >= 0.85, tail 1 h), then bite at the held price. One rung (0.8 of the honest price, about 7 hours) makes every position under 170/0.8 = 212.5% honest CR liquidatable, and each imdUSD burned seizes 1.5 imdUSD of collateral at the honest price. Two rungs (0.64, about 8 hours) reach every position under 266% CR and seize 1.875x. The borrower is healthy at the honest price and loses the difference. The sweep oracle panel (docs/AUDIT-SWEEP-PANEL-ORACLE-2026-10-07.md:198) accepted the 'Down' walk with the prize stated as 'the bonus is 20% of debt repaid, at most $200k on the whole LINE'. That bound does not hold, because the seizure and the bonus are both priced at the held price. The cost of the hold: a 20% push sells 1/sqrt(0.8)-1 = 11.8% of the pool's IMD side (about $271k) and a 36% push sells 25% (about $575k). Unwinding returns the slippage, so the round trip costs about $5k to $12k in 1% pool fees, plus whatever dip-buyers take over 7 to 8 hours. The prize at LINE $1M is about $400k to $800k of borrowers' sIMD. That is roughly 4x the accepted bound and more than the whole-day redemption prize. Nothing in the protocol shortens or pays for the hold: resecure and the Treasury's fall trigger only refresh at the held value (the fall trigger buys the first attestation), and the mark's tail is refreshed by the same relays. The payout-price NatSpec (CDPVault.sol:350-361) and runbook section 7 say a profitable push needs 'the better part of a day'. For this route it needs one window plus the grace. Reachable with the constants as committed. Smallest fix, consistent with `cash`: price the seizure (and principalPart) at _payoutPrice(price), the higher of the attested and paced prices. Keep the health check, the mark and the dust test at the attested price. That caps an h-hour hold at 1.2/0.99^h of the debt (1.30x at 8 h). Tradeoff for the requester to decide: after an honest fall faster than 1% an hour, a liquidator is paid less until the paced price follows, so mat/grace or a separate, faster seizure rate must be re-derived as docs/PARAMETERS-2026-10-05.md did for the 20% bonus.

**Reproduction**

Reproduced by running test/scratch/Proof_2ffa458e12b7.t.sol on e4baedf. Setup: ParameterizedVault over MockIMD at $1, ETH $2000, NHI 0.85 (mat 170, grace 6 h). VICTIM locks 960,000 and draws 480,000 (200%). ATTACKER locks 2,000,000 and draws 500,000 (400%). Then 24 paced hours pass. Sequence: feeds to 0.8 and pace; an hour later feeds to 0.64 and pace; ATTACKER bark(VICTIM) (CR 128 < 170, grace 6 h); six hourly re-attestations at 0.64 with pace; bite(VICTIM, whole debt 480,078). Expected under the accepted bound: seized collateral worth <= 1.2x (1.35x allowing the paced lag) of the debt at the pre-fall price, i.e. <= 648,105. Actual: 900,146 IMD seized (1.875x), ATTACKER receives 885,144, while payoutPrice() is 0.92. Failure message: 'a held-down pool pays the liquidator the whole push: 900145972602739725300000 > 648105100273972602216000'. The one-rung variant (test/scratch/Proof_a69eee9c3317.t.sol) also fails: 50,000 imdUSD burned after a 7 h hold at 0.8 returns 73,750 IMD (1.475x), where at most 65,000 was expected.

**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 pool held down two feed rungs (0.8, then 0.64 of the honest price: two epochs, an hour apart, both within
// the feeds' fresh allowance) and kept attested through the six-hour grace lets a liquidator seize
// debt x 1.2 / 0.64 = 1.875 x the debt's HONEST value per imdUSD burned. The accepted bound (sweep oracle
// panel 2026-10-07, Q6: "the bonus is 20% of debt repaid, at most $200k on the whole LINE") prices the bonus at
// the honest price; `bite` prices it at the held one. The paced payout price protects `cash` from the same
// hold; `bite` reads only the attested price.

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 HlFeed 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 HlAggregator {
    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 HeldDownLiquidationTest is Test {
    address private constant VICTIM = address(0xB00C);
    address private constant ATTACKER = address(0xA77A);
    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    HlFeed private primary;
    HlFeed private health;
    HlFeed 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 HlAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new HlFeed(DOLLAR);
        health = new HlFeed(0.85 ether); // mat 170, grace six hours
        spot = new HlFeed(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(VICTIM, 1_000_000 ether);
        imd.mint(ATTACKER, 2_000_000 ether);
        vm.stopPrank();
        // The victim: $480k of debt at 200%, the healthy side of mat 170 (LINE is $1M for the whole book).
        vm.startPrank(VICTIM);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(960_000 ether);
        vault.draw(480_000 ether);
        vm.stopPrank();
        // The attacker holds imdUSD, drawn a day earlier at 400% so the hold cannot reach its own position.
        vm.startPrank(ATTACKER);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(2_000_000 ether);
        vault.draw(500_000 ether);
        vm.stopPrank();
        for (uint256 i; i < 24; ++i) {
            _hour();
        }
    }

    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 Rung one: 0.8 (the fresh cap). An hour later the epoch has closed and rung two anchors at 0.8: 0.64.
    /// The victim is marked at once (CR 128 < 170) and the pool is held, re-attested hourly, through the six-hour
    /// grace. At the bite the seizure is priced at the held 0.64, so each imdUSD burned takes 1.875 IMD.
    function test_aPoolHeldDownTwoRungsThroughGracePaysTheLiquidatorFarMoreThanTheBonus() public {
        uint256 preFall = DOLLAR;
        imdEth = DOLLAR * 80 / 100;
        _hour();
        imdEth = DOLLAR * 64 / 100;
        _hour();
        vm.prank(ATTACKER);
        vault.bark(VICTIM);
        (uint256 markedAt, uint256 grace,,) = vault.liquidationMarks(VICTIM);
        assertEq(grace, 6 hours, "grace at NHI 0.85");
        for (uint256 i; i < 6; ++i) {
            _hour();
        }
        assertGe(block.timestamp, markedAt + grace, "grace has elapsed");
        assertApproxEqRel(vault.payoutPrice(), 0.92 ether, 0.01e18, "cash would still be paid near the pre-fall price");

        (uint256 victimCollateralBefore, uint256 burned) = vault.positions(VICTIM);
        uint256 attackerImdBefore = imd.balanceOf(ATTACKER);
        vm.prank(ATTACKER);
        vault.bite(VICTIM, burned);
        (uint256 victimCollateralAfter, uint256 victimDebtAfter) = vault.positions(VICTIM);
        assertEq(victimDebtAfter, 0, "the whole debt is bitten in one call");
        uint256 seized = victimCollateralBefore - victimCollateralAfter;
        uint256 received = imd.balanceOf(ATTACKER) - attackerImdBefore;

        // About 480,078 imdUSD burned (principal plus accrued fees). Seized: burned x 1.2 / 0.64 = 1.875 x burned
        // in IMD, worth 1.875 x the debt at the pre-fall price ($900k of the victim's $960k). The attacker, also the marker, keeps all but the
        // protocol's tenth of the bonus.
        uint256 seizedAtPreFall = Math.mulDiv(seized, preFall * 2000, 1e18);
        uint256 receivedAtPreFall = Math.mulDiv(received, preFall * 2000, 1e18);
        emit log_named_decimal_uint("seized, at the pre-fall price", seizedAtPreFall, 18);
        emit log_named_decimal_uint("liquidator receives, at the pre-fall price", receivedAtPreFall, 18);
        // EXPECTED (the accepted bound: a 20% bonus on the debt repaid, with room for the paced price's own lag
        // over an eight-hour hold): at most 1.35 x the debt. ACTUAL: 1.875 x.
        assertLe(seizedAtPreFall, burned * 135 / 100, "a held-down pool pays the liquidator the whole push");
    }
}
```

### 2. Low: Fresh-principal netting treats any principal under 12 h as unfollowed: cancelling an already-followed loan lets a one-block-old draw by another position count in full for backedDebt

`src/CDPVault.sol:953`

```
        if (recent > own) rest -= Math.min(rest, recent - own);
```

Merged from audit_flow ccb62243 and audit_math 35b4c4fd. _tallyPrincipalRetired (73191e0) nets the position's whole _recentlyMinted record, anything under FRESH_DEBT_WINDOW = 12 h, out of CANCELLED_PRE_SLOT. Its NatSpec gives the reason as 'the paced debt has had at most that long to follow it'. The follow is 10% of max(paced, 100,000) per paced hour, compounding, so a 100,000 draw on a 99,500 book is fully inside _debtPaced after about 7.3 paced hours. Cancelling such a loan (cash, bite, cover) between that point and 12 h books nothing, so _clampPacedDebt does not fire. The slot the cancelled loan held in the paced debt then goes to whatever was drawn since, however fresh. This reopens final sweep 2 low #2(1) ('debt cancelled by a redemption and drawn again by someone else backs nothing until it has been held') for that window. Only ParameterizedVault.backedDebt -> earnLine reads the paced debt, and WAGE_WAD = 0 at launch, so nothing can be taken today. Once a wage is set behind the timelock, it is the sweep-panel round trip at a one-block holding time. Smallest fix: net out only principal whose record is younger than PACE_INTERVAL (a draw goes unfollowed by at most one step), or only this transaction's own mint as a3aa9e4 did, and accept the bounded churn that panel #3 rated low.

**Reproduction**

Reproduced by running test/scratch/Proof_ccb6224305d0.t.sol on e4baedf. Setup: BOOK has 99,500 seasoned debt over 24 paced hours. ALICE locks 190,000 and draws 100,000, followed by 8 hourly paces (backedDebt == 199,500). Block n: BOB locks 190,000 and draws 100,000. Block n+1: ALICE calls cash(100,000, 0, ALICE). Block n+2: expected backedDebt < 110,000, since BOB's 24-second debt is unheld. Actual: 199,504.06, and the test fails with 'Bob's zero-second draw counts in full for the work ceiling'.

**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 fresh-principal netting in _tallyPrincipalRetired (payout vault panel 2026-10-09, low #3) assumes the
// paced debt "has had at most twelve hours to follow" principal younger than FRESH_DEBT_WINDOW. At the
// committed constants the follow absorbs a draw in hours (10% of max(paced, 100,000) an hour, compounding),
// so a sibling position's draw that is already fully inside the paced debt, cancelled one block after a
// second position's draw, is netted out as "fresh" and the clamp never fires: the second position's
// zero-second debt counts in full for ParameterizedVault.backedDebt. This is the final sweep 2 low #2 (1)
// reopened for cancellations of debt between its follow time and twelve hours 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 FnFeed 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 FnAggregator {
    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 FreshNettingHidesFollowedDebtTest is Test {
    address private constant BOOK = address(0xB00C);
    address private constant ALICE = address(0xA11CE);
    address private constant BOB = address(0xB0B);
    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether;

    MockIMD private imd;
    ParameterizedVault private vault;
    ImdUSD private stable;
    FnFeed private primary;
    FnFeed private health;
    FnFeed 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 FnAggregator()).code);
        vm.warp(1_000_000);
        imd = new MockIMD();
        primary = new FnFeed(DOLLAR);
        health = new FnFeed(0.85 ether);
        spot = new FnFeed(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(ALICE, 200_000 ether);
        imd.mint(BOB, 200_000 ether);
        vm.stopPrank();
        vm.startPrank(BOOK);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(199_000 ether);
        vault.draw(99_500 ether);
        vm.stopPrank();
        for (uint256 i; i < 24; ++i) {
            _hour();
        }
        assertEq(vault.backedDebt(), 99_500 ether, "the seasoned book counts in full");
    }

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

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

    function test_cancellingAFollowedSiblingDrawLetsAZeroSecondDrawCountInFull() public {
        // Alice draws 100,000 against 190,000 IMD (CR 190%, inside the redeemable band) and holds it eight
        // paced hours: the paced debt follows at 10% of max(paced, floor) an hour and absorbs all of it.
        vm.startPrank(ALICE);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(190_000 ether);
        vault.draw(100_000 ether);
        vm.stopPrank();
        for (uint256 i; i < 8; ++i) {
            _hour();
        }
        assertEq(vault.backedDebt(), 199_500 ether, "Alice's eight-hour-old draw is fully inside the paced debt");

        // Block n: Bob draws 100,000 of zero-second debt.
        _next(12);
        vm.startPrank(BOB);
        imd.approve(address(vault), type(uint256).max);
        vault.lock(190_000 ether);
        vault.draw(100_000 ether);
        vm.stopPrank();

        // Block n + 1: Alice redeems her own 100,000 imdUSD against her own position (the fee stays in her
        // collateral). Her principal is eight hours old, younger than FRESH_DEBT_WINDOW, so the netting books
        // none of it as pre-existing and the clamp does not fire, though the paced debt had absorbed all of it.
        _next(12);
        vm.prank(ALICE);
        vault.cash(100_000 ether, 0, ALICE);

        // Block n + 2: the book is 99,500 seasoned plus Bob's 24-second-old 100,000. The paced debt should be
        // about 99,500 plus two 12-second steps (under 100,000 in all); it is 199,500.
        _next(12);
        uint256 backed = vault.backedDebt();
        emit log_named_uint("backedDebt after the sequence", backed);
        assertLt(backed, 110_000 ether, "Bob's zero-second draw counts in full for the work ceiling");
    }
}
```

### 3. Low: A remainder aged out by the record's conserved principal-time is booked as pre-existing, so a one-hour hold drives the paced debt under the seasoned book (the record states twelve hours)

`src/CDPVault.sol:954`

```
        if (rest != 0) _transientAdd(CANCELLED_PRE_SLOT, rest);
```

From audit_math c386f071. _reduceDebt conserves the record's principal-time when it retires the youngest debt first. After a wipe that leaves r of a tranche X drawn t ago, the remainder is dated t*X/r back, and once that passes 12 h the whole record ages out. With X/r >= 12, a remainder one hour old reads as seasoned. Cancelling it then books the full remainder to CANCELLED_PRE_SLOT, and _clampPacedDebt subtracts it from the paced debt the transaction began with, although the follow had absorbed only one step of X. The paced debt lands below the untouched seasoned book. With it fall backedDebt and earnLine, which recover at 10% an hour. The payout vault panel #3 resolution says this costs holding the debt twelve hours. It costs one hour plus about $3 of duty, and the self-redemption's fee stays in the attacker's own position. WAGE_WAD is 0 at launch, so nothing is blocked today. With a wage set, this is a repeatable, hour-priced denial of the work ceiling. Smallest fix: keep a per-position 'last drawn at' timestamp and treat principal of a position drawn within FRESH_DEBT_WINDOW as recent whatever its amount-weighted record says, or cap the clamp by the part of the position's debt that existed when the transaction began. Otherwise restate the bound as one hour.

**Reproduction**

Reproduced with my own scratch test (harness of Proof_ccb6224305d0, then test_agedOutRemainder). Setup: BOOK has 99,500 seasoned debt over 24 paced hours. P1 locks 1,200,000 and draws 600,000; pace one hour later (backedDebt 109,500). P1 wipe(551,000): the record ages out. P1 free(1,097,000), leaving CR 210%. Next block P1 cash(49,000, 0, P1). Next block: totalDebt 99,503.04. Expected per the record: backedDebt >= 99,500. Actual: 60,569.83.

### 4. Low: The cash route's stated bound 'after the fee has eaten the first five' hours holds only at the fee cap; small burns break even after one paced hour and a 12-hour hold pays 7.2% at the cap

`src/CDPVault.sol:353`

```
    /// however many hours it is held (1% an hour here, after the fee has eaten the first five), the whole 20% after
```

Merged from audit_permissions 866c8123 and audit_economics e9947645. The rate arithmetic holds: the payout price falls at most 1% per paced hour, so the gain is about 1% of redeemed volume per hour held. Two things in the NatSpec and the record do not hold. (1) The fee is only 5% for a burn that is large relative to the fee base. A burn of 0.5% of the base pays 75 bps, so it breaks even after one paced hour (0.9925/0.99 = 1.0025) and is +1.27% after two. (2) The reason the rate was accepted ('a profitable push has to be held ... through the better part of a day') is not quantified against the on-chain cost of the hold. A 20% push and its unwind cost about $5.4k in pool fees. After 12 paced hours at 0.8, 50,000 imdUSD at the capped 500 bps fee redeems for 53,588 IMD (+7.2%). Only dip-buying makes the hold expensive, and the code does not bound it. This route is dominated by the liquidation route (the first finding), which pays more in fewer hours, so it is rated low. Fix: restate the bound as break-even at fee/1% paced hours (under one hour at the floor fee). If the requester wants it bounded in code, cap the volume `cash` may burn per PACE_INTERVAL, since the price bound alone sets only the rate per hour.

**Reproduction**

Reproduced by running test/scratch/Proof_866c8123fb30.t.sol on e4baedf. Setup: BOOK 1,800,000/900,000, HOLDER 300,000/100,000, 48 paced hours. Feeds step to 0.8 and are paced once. redemptionFeeBps(5,000e18) == 75, and cash(5,000e18, 0, BOOK) returns IMD worth 5,012.63 at the pre-fall price (expected <= 5,000 per the NatSpec). After two paced hours it returns 5,063.26. Both tests fail.

### 5. Info: The pumped-price bound 'about 18 hours for 20%' is per feed window: two consecutive windows (1.44x) underpay redemptions for about 37 paced hours

`src/CDPVault.sol:361`

```
    /// push, less 1% a paced hour, until it has decayed: about 18 hours for 20%, for the cost of the push
```

From audit_economics fa826c7c. The feed admits 20% per epoch from that epoch's anchor, and _pacedPrice rises at once. A pool held up through two windows writes 1.2 x 1.2 = 1.44, which takes ln(1.44)/ln(1/0.99) = 37 paced hours to decay, and three windows take 55 hours. This is the accepted direction: it underpays and never overpays, and its only cost is the push. The stated recovery time is the single-window figure, though, so an operator reading 'about 18 hours' after a two-hour pump expects the wrong recovery. Restate the bound as compounding with the length of the hold.

**Reproduction**

Reproduced with my own scratch test (test_twoWindowRise). After 24 paced hours at $1: feeds to 1.2, pace; an hour later feeds to 1.44, pace; feeds back to 1.0, then 18 hourly paces. payoutPrice() = 1.2017 (= 1.44 x 0.99^18), where the NatSpec implies about 1.0.

### 6. Info: NatSpec claims that no longer hold after 73191e0: cancelled-and-redrawn debt 'backs nothing until it has been held', the netting window is 'at most that long to follow it', and the record keeps a tran

`src/CDPVault.sol:932`

```
    /// own record, `_recentlyMinted`) is, when cancelled, booked nowhere, since the paced debt has had at most
```

Merged from audit_math c11baca8 and audit_economics 235c7adc. Three claims no longer match the code. (1) CDPVault.sol:312-313 and ParameterizedVault.sol:240-241 and 265-266 say debt cancelled and drawn again 'backs nothing until it has been held'. The second finding shows that a one-block-old draw counts in full. (2) CDPVault.sol:931-935 says the paced debt 'has had at most' FRESH_DEBT_WINDOW to follow the netted principal. The follow completes within hours, and the amount-weighted mintedAt keeps a record whole and fresh for up to about twice the window: two equal tranches at t0 and t0+11h are dated t0+5.5h and stay fresh until t0+17.5h. (3) The payout vault panel #3 resolution states a twelve-hour cost for lowering the paced debt; the third finding shows it is one hour. Reword these to the property the code has, or change the code as in the second and third findings.

**Reproduction**

See the second finding (backedDebt 199,504 with a 24-second-old loan counted) and the third (60,569 after a one-hour hold). For (2): draw 500,000 at t0 and 500,000 at t0+11h; at t0+12h+1s _recentlyMinted is still 1,000,000, because mintedAt is t0+5.5h.

---

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