{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"dc27aade-6adb-40da-b6d8-0bddfe280ebf","kind":"audit","nodes":[{"acceptedSubmissionHash":"a5f46838676da0b515513dc31e1d16ad1fb928a46685c4ef7116f9d43541b09c","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0a0506fa3ca1061aa290e26fbbbb78f109d15afd6418213502e4a00cc1031caa","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"59c3ee342c50c9797d1c2121e7429eecaaef443a4a43c102977e128df1f8b6cd","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"cd982465095437380855d6dc38771410b2a93ab86b2a01dda0a89c756963d6bc","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"73740e320fdd6487a108f5e0cf14b3d31c0e8d1907da02c66defd97fdbb1765e","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"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).\n\nimdUSD 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.\n\nAnswer each numbered question, including the ones where nothing is wrong:\n1. 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.\n2. 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.\n3. 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.\n4. 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?\n5. 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?\n6. 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.\n7. 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.\n8. Every comment or NatSpec in these files that claims a property the code does not have, the paced figures' NatSpec first.\n\nNot 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.\n\nFor 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.","parentJobId":null,"planHash":"e69b51252fbb4169b57f34767c641ce85b283cbf8edb7c116275c6526b79d6a8","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"dc27aade-6adb-40da-b6d8-0bddfe280ebf","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51143","feedbackHash":"86ac44bc5b59896a518820bd7939fd149c60a59f680987cfd3640e62bafeaea9","nodeKey":"audit_economics","submissionHash":"a5f46838676da0b515513dc31e1d16ad1fb928a46685c4ef7116f9d43541b09c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51142","feedbackHash":"f5c14d8f1bb83922d2fd46cc0cf40416ca573cd92ea6ced7935105c4ee7f4dd4","nodeKey":"audit_flow","submissionHash":"0a0506fa3ca1061aa290e26fbbbb78f109d15afd6418213502e4a00cc1031caa","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52205","feedbackHash":"48aa1542d2f2b4c0ae935d1bf26f0361d221810185a4a27f7ebad70546a0ae77","nodeKey":"audit_judge","submissionHash":"59c3ee342c50c9797d1c2121e7429eecaaef443a4a43c102977e128df1f8b6cd","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52255","feedbackHash":"2765948283714f8c64b3e6d3be46353f22e853f67dfb0e271d52c1a2751b7df5","nodeKey":"audit_math","submissionHash":"cd982465095437380855d6dc38771410b2a93ab86b2a01dda0a89c756963d6bc","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51874","feedbackHash":"179ba491d6d4e9f50d2670102c62e0d0c1de03bfe289e56b8ef49e091e876a11","nodeKey":"audit_permissions","submissionHash":"73740e320fdd6487a108f5e0cf14b3d31c0e8d1907da02c66defd97fdbb1765e","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"2e28b4b8f6b34b83cab740f20396cf4906bf4b0e8fb3bb822037e003a543105a","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e57a8e639cccfbab","findings":[{"citation":"resolved","description":"The paced figures' NatSpec accepts the dip (a withdrawal paced in one transaction and reversed in the next) with the stated bound that it 'exists only while the book is backed below par', because 'on a par book the surplus above the aggregate cap absorbs any one position's exit'. That absorption argument holds only while supply equals principal. _liveBacking caps secured collateral at mat x prior principal (_securedCollateralValue) and divides by the whole supply; once work-minted imdUSD is outstanding (earn, open as soon as governance sets a wage), supply exceeds principal, and a dominant position's exit removes mat x P from the cap but only P from the supply. The book is at par before and after the exit-and-return, yet the pacing writes the book-without-the-position figure and every redemption for the next ~23 paced hours is paid that figure. Unreachable with WAGE_WAD 0 (earn reverts WorkMintingOff); reachable the moment a wage is applied, with the other constants as committed (LINE $1M, EARN_MAT 2500). Grief only: it underpays honest redeemers and drops the peg floor min(1-fee, backing) to 0.54 for a day at a cost of two transactions, repeatable; the exiting borrower holds the position it needs anyway. The stated bound is wrong, which the brief lists as the condition for an accepted item to count.","line":323,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault with MockIMD collateral at $1, NHI 0.85 (mat 170), Treasury holding 20,000 IMD. BOOK locks 200,000, draws 100,000; WHALE locks 1,800,000, draws 900,000 (totalDebt = LINE). Governance applies wage 0.01e18 (proposeWage, 48h, applyPending). Pace hourly for 40 hours so the paced debt reaches 1,000,000; earnLine = 250,000; WORKER earns 250,000. Supply 1,250,000 against cap 1,700,000 + reserve 20,000: backingPerUnit() == 1e18 and paced backing == 1e18. Tx 1: WHALE wipes its whole debt. Tx 2 (next block): WHALE draws 900,000 again. Expected per the NatSpec: a par book, no dip. Actual: paced() backing = 542857142857142857 ((170,000 + 20,000) / 350,000), backingPerUnit() one block later = 0.5429e18, and it climbs at 0.02/hour, so a 1,000 imdUSD cash is paid 543 x (1 - fee) IMD instead of 995. Scratch test test/scratch/Probe.t.sol::test_parBookWithWorkSupplyDipsOnExitAndReturn passes on this code with those figures. Smallest fix: correct the NatSpec bound (the dip exists whenever cap + reserve - supply < (mat - 1) x the exiting principal, which a par book with work-minted supply satisfies), and, if the design wants the stated bound, pace a fall caused only by a repayment at the follow rate rather than at once, or exclude totalEarned - totalNonPrincipalRedeemed from the supply the cap is compared against.","severity":"low","snippet":"    /// next leaves the figure where the book stood without it, until it climbs back. That dip exists only while\n    /// the book is backed below par (on a par book the surplus above the aggregate cap absorbs any one","title":"Paced backing: the accepted dip is not 'below par only' once work-minted supply exists; a par book dips to 0.54 on a dominant borrower's two-transaction exit and return"},{"citation":"resolved","description":"_pacedBacking allows the paced backing to rise by BACKING_RISE_PER_HOUR x min(elapsed, PACE_INTERVAL) where elapsed = block.timestamp - _pacedAt. When _priceAgrees() is false (feeds stale or diverged) _pace passes price 0, the backing holds, but _pacedAt is still written to now. The NatSpec states 'a quiet gap recovers one interval' and 'hourly pacing recovers in full'; neither holds when the pacing in the gap happens at an unusable price, which on mainnet is the normal state between purchased attestations (PRICE_MAX_AGE 1 hour, updates bought on demand, 'ten silent hours for the one-hour price feeds'). lock and wipe are ungated and pace, so every deposit or repayment made while the feed is stale resets the clock, and a griefer can do it on purpose: one lock(1) shortly before each refresh keeps the rise at a few seconds' worth. After any dip (the accepted exit-and-return, a stale-term read after a fall, or a real recovery from a crisis) honest redeemers then stay underpaid indefinitely instead of recovering at 2 points per paced hour. Reachable with the constants as committed; the cost is one cheap transaction per refresh.","line":868,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault, MockIMD at $1, NHI 0.85, feeds with maxAge 1 hour. BOOK locks 199,000 and draws 99,500; IMD falls to $0.40, BOOK lock(1) re-prices the term: backingPerUnit() = 0.8804e18 (paced there). The market recovers to $1 but the feed is not refreshed for an hour. Case A (no pacing in the gap): warp 1 hour, refresh the feeds: backingPerUnit() = 0.9004e18, one interval recovered as stated. Case B (same hour): warp 62 minutes (feed stale), WHALE calls lock(1): paced().at == block.timestamp, backing unchanged; 12 seconds later refresh the feeds: backingPerUnit() = 0.88047e18. Expected per the NatSpec: 0.9004e18. Actual: 0.8805e18, the hour's rise forfeited by an ungated call anyone may make. test/scratch/Probe.t.sol::test_staleTimePacingForfeitsTheRise. Smallest fix: keep a separate timestamp for the backing (e.g. _backingPacedAt) written only when _pace writes the backing at an agreed price, and measure the backing's elapsed from it (still capped at PACE_INTERVAL); _pacedAt keeps serving the supply and debt figures.","severity":"low","snippet":"        _pacedAt = uint64(block.timestamp);","title":"_pace advances _pacedAt when the backing is held for an unusable price, so a lock(1) during a stale window forfeits the interval's rise; anyone can keep a recovering redemption payout from climbing"},{"citation":"resolved","description":"_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 100,000 and about 24 more to reach 1,000,000 (1.1^24 = 9.85). If borrowing fills the $1M line on launch day, the fee base is 100,000 to ~380,000 for the first day and a half. The NatSpec says the floor 'only lowers fees while the protocol is that small'; while the paced supply is below the live one it raises them: a 10,000 imdUSD redemption against a live supply of 1,000,000 pays 50 + ceil(10,000 / 100,000 / 2) = 500 bps (capped) instead of 100 bps, and stores the 4.5% cap as the base rate everyone pays for the next half-life (12 hours). The cheapest pin of the cap for everyone is therefore 9,000 imdUSD of burns (about 250-450 imdUSD of fee) for the first ~34 paced hours, against 9% of the live supply once the paced supply has caught up. Both constants are deliberate; this records the cost the brief asks for (question 2) and the one NatSpec sentence that does not cover it.","line":1073,"path":"src/CDPVault.sol","reproduction":"Fresh ParameterizedVault, feeds at $1, NHI 0.85. Borrowers lock 2,000,000 IMD and draw 1,000,000 imdUSD in the first hour. One hour later (one pacing): paced() supply = 10,000e18, redemptionFeeBps(10,000e18) = 500, while 10,000 / 1,000,000 / 2 = 0.5% would give 100 bps against the live supply. Nine redemptions of 1,000 imdUSD in that hour store redemptionBaseRate = 0.045e18; redemptionFeeBps(0) = 500 for the following hours, decaying with a 12-hour half-life. Expected per the NatSpec: the floor only lowers fees. Actual: a five-fold fee and a pinned cap on launch day. Smallest fix: document it in the _feeBase NatSpec and the runbook, or seed _supplyPaced with the live supply at the first pacing after deployment (one-time, when _supplyPaced == 0 and totalSupply == 0 at the previous pacing).","severity":"info","snippet":"    /// redemption's increase is measured as if the supply were the floor, which only lowers fees while the\n    /// protocol is that small.","title":"Launch-day redemption fee: the paced supply starts at zero and follows at 10% an hour, so for about 34 hours after supply reaches the line a redemption's increase is measured against at most 10-38% of"},{"citation":"resolved","description":"_step 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 block the supply and debt figures grow by (1 + 0.1 x 12/3600) per block, which is e^0.1 - 1 = 10.52% an hour rather than 10%, and over 24 hours 11.0x rather than 1.1^24 = 9.85x. The backing's rise is absolute (0.02 of par per hour) and does not compound. The claim at line 316 ('nor moves the fee base or the work ceiling's debt faster than FOLLOW_BPS_PER_HOUR') is therefore off by about 5% of the rate; no economic consequence at the committed constants beyond the fee base and work ceiling catching up slightly faster than stated.","line":304,"path":"src/CDPVault.sol","reproduction":"Paced debt at 1,000,000e18 with live debt 10,000,000e18. (a) One pace after 1 hour: _pacedDebt = 1,100,000e18. (b) 300 paces 12 seconds apart over the same hour: 1,000,000 x (1 + 1000 x 12 / (10000 x 3600))^300 = 1,105,1xx e18. Expected per the NatSpec: at most 1,100,000e18 after an hour. Actual: 1,105,1xx e18. Smallest fix: say 'per pacing, compounding' in the NatSpec, or compute the step from the value at the start of the current interval.","severity":"info","snippet":"    ///   SUPPLY follows the live supply, up or down, by at most FOLLOW_BPS_PER_HOUR of itself (or of the fee-base\n    ///   floor, when that is larger) an hour (`_pacedSupply`); the redemption fee is measured against it, floored","title":"NatSpec: the follow bound 'at most FOLLOW_BPS_PER_HOUR of itself an hour' compounds under frequent pacing to 10.5% an hour (and 11x, not 9.85x, over a day)"},{"citation":"resolved","description":"Stale reference left from a rename: the code the comment points to is CDPVault._pace / _liveBacking, which read _redemptionReserveBacking inside the ungated lock and wipe. The property described (saturating, never reverting) is correct; only the name is wrong.","line":172,"path":"src/ParameterizedVault.sol","reproduction":"grep -n '_mark' src/*.sol finds no definition. Expected: a reference to _pace. Actual: _mark. Fix: rename in the comment.","severity":"info","snippet":"        // Saturating, like the vault's backing it feeds: an absurd price must not revert lock or wipe (CDPVault._mark).","title":"Comment refers to CDPVault._mark, a function that does not exist (the pacing is CDPVault._pace)"},{"citation":"resolved","description":"SECURED_COLLATERAL_MULTIPLE is 2, so a position is counted exactly (term = collateral) only up to 200% CR at its last price. redemptionCeilingCR() is mat() + gap(); with NHI at or above 0.85 and the governed gap at its default 50 that is 220 (Parameters.gap = 50, MIN_GAP 25). Positions between 200% and 220% are redeemable candidates whose term is 2 x principal / price, not their collateral, so the sentence is wrong for the launch constants. The consequence is the accepted non-monotone backing across a candidate-funded redemption (lines 722-725), not a new one; this is a documentation defect only.","line":266,"path":"src/CDPVault.sol","reproduction":"NHI 0.85, gap 50: vault.redemptionCeilingCR() == 220. A position with 210 IMD of collateral and 100 imdUSD of debt at $1 is eligible (CR 210 < 220) and _redeemPosition accepts it, while its term in securedCollateral is min(210, 200) = 200, outside the exactly-counted set the NatSpec describes. Fix: say 'which includes every redeemable one while mat + gap <= 200' or restate the bound.","severity":"info","snippet":"    /// inside their bound (at most 200% at that price, which includes every redeemable one) are","title":"NatSpec: securedCollateral says the 200% per-position bound includes every redeemable position, but redemption eligibility is mat + gap = 220% at launch"},{"citation":"resolved","description":"ParameterizedVault.earnLine is reserveValue() + backedDebt() x earnMat / 10000, where backedDebt is min(totalDebt, debt at transaction start, paced debt) less totalBadDebt. The base-vault NatSpec predates the paced debt and the bad-debt subtraction and overstates what the ceiling counts; a reader relying on it would expect debt drawn this hour to raise the ceiling at once.","line":202,"path":"src/CDPVault.sol","reproduction":"With totalDebt 1,000,000e18 drawn an hour ago and the paced debt at 110,000e18 (test/PacedFigures.t.sol::test_theWorkCeilingCountsDebtOnlyUpToThePacedDebt), earnLine() is 27,500e18, not 250,000e18 as the sentence implies. Fix: reference backedDebt.","severity":"info","snippet":"    /// it with reserveValueUsd + totalDebt * earnMat / 10000, the bound docs/COMPUTE-BACKING-","title":"NatSpec: CDPVault.earnLine says ParameterizedVault overrides it with reserveValueUsd + totalDebt x earnMat, but the override uses backedDebt (paced, transaction-start capped, bad debt removed)"}],"hash":"0a0506fa3ca1061aa290e26fbbbb78f109d15afd6418213502e4a00cc1031caa","nodeId":"7aaf75e2-581d-4305-a440-26ae5dfbb813","outcome":"completed","summary":"Both output files are in place (the harness git-excludes them, so the tree is clean) and the project's own paced-figures suite passes on this commit. The review is complete. Here is the report.\n\n## Findings\n\nSeven findings are in `.imd-findings.json`: two low, five info. Nothing at medium or above survived verification, so no proof files were attached.\n\n- **Low, `CDPVault.sol:323`.** The accepted dip's stated bound \"below par only\" fails once work-minted supply exists. A par book (supply 1.25M against cap 1.7M plus reserve) dips to 0.543 when a borrower holding 900k of debt wipes in one transaction and redraws in the next. The cap loses mat×P while the supply loses only P. Unreachable at wage 0, reachable the moment governance applies a wage. Grief only: redeemers are underpaid for about 23 paced hours per repetition.\n- **Low, `CDPVault.sol:868`.** A pacing at an unusable price holds the backing but still advances the clock. On mainnet the price feed is stale between purchased attestations, and `lock` and `wipe` are ungated, so an ordinary deposit during a stale window forfeits the interval's rise. One `lock(1)` just before each refresh holds a recovering payout at 0.0007 points instead of 2. Fix: a separate timestamp for the backing, written only on agreed-price writes.\n- **Info.** Launch-day fee overcharge from the paced supply starting at zero (9,000 imdUSD of burns pins the 5% cap for everyone for roughly the first 34 paced hours), the follow rate compounding to 10.5% under per-block pacing, a comment naming a nonexistent `_mark`, and two stale NatSpec claims (the 200% bound \"includes every redeemable one\" at a 220% ceiling, and `earnLine` described as using `totalDebt`).\n\n## Answers to the eight questions\n\n1. **Paced backing.** No sequence overpays. Every payout is `min(live, paced)`, the live figure excludes the transaction's own capital through the transient tallies, and the paced value is only ever written as `min(live, previous + rise)`. Donations, candidate lifts, feed moves mid-transaction, and bite or cover before a cash all land on the conservative side. Costs to honest redeemers: the dip is up to the exiting position's share of the cap (0.46 of par in the probe) recovering at 2 points per paced fresh hour; the stale-term read after a fall understates each position above 200% by its surplus times the fall (their own test shows 0.87 against 0.98, six hours); a quiet gap recovers one interval; and a feed outage with any ungated call inside it recovers nothing for that time. That last one is the second low.\n2. **Paced supply and fee.** Cheapest pin: 9% of max(paced supply, 100k), which is 9,000 imdUSD for the first ~34 paced hours. Cheapest dilution: raising the paced supply, 10% per hour, requiring real collateral. No ordering moves the base faster than the step, up to the compounding nit. A launch-day redeemer with live supply 1M pays 500 bps on 10k instead of 100 bps.\n3. **Paced debt and work ceiling.** The clamp after every draw correctly excludes debt cancelled by cash, bite or cover in the same transaction, and across transactions the figure falls at once. A borrower's own wipe and redraw in one transaction leaves the ceiling unchanged; across two it drops by the principal and recovers at the step, which only removes their own contribution. The aggregate's cost once the wage is on is the par-book dip above and ceiling drops from other people's liquidations.\n4. **Redemption payouts.** Routes, eligibility, the worsens-ratio check and rounding are unchanged and pay against the redeemer. Splitting a burn halves the quadratic part of the fee, which the fee-base NatSpec already documents. The reserve pays first at a pro-rata rate, which is neutral by construction.\n5. **Liquidation and bad debt.** No regression. The only change is the pacing call at the top of bite and cover, which can only lower the figures a subsequent cash reads.\n6. **Positions and pricing.** Pacing inside `lock` and `wipe` adds no revert path: eve","treeHash":null,"usage":{"cachedInputTokens":3564957,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":96571,"runtime":"claude","turns":44,"wallClockMs":1448546}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fa5c50e7abe46571","findings":[{"citation":"resolved","description":"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.","line":742,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// The paced debt is clamped only after `draw` (CDPVault._clampPacedDebt). A draw FOLLOWED by a cancellation of\n// another position's debt in the same transaction (cash here; bite and cover take the same path) leaves\n// `_debtPaced` where the transaction found it, so in the next transaction the zero-second debt that replaced\n// the cancelled one counts in full for the work ceiling (ParameterizedVault.backedDebt). The mirror order\n// (cash, then draw) is clamped, as the sweep-panel fix intended.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract PFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        set(v);\n    }\n\n    function set(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract PAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @dev One transaction: lock, draw, then redeem the drawn imdUSD against the book (draw FIRST).\ncontract DrawThenCash {\n    function run(ParameterizedVault vault, MockIMD imd, uint256 collateral, uint256 debt, address candidate) external {\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(collateral);\n        vault.draw(debt);\n        vault.cash(debt, 0, candidate);\n    }\n}\n\n/// @dev The same three steps with the redemption BEFORE the draw (the order the committed clamp covers).\ncontract CashThenDraw {\n    function run(ParameterizedVault vault, MockIMD imd, uint256 collateral, uint256 debt, address candidate) external {\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(collateral);\n        vault.cash(debt, 0, candidate);\n        vault.draw(debt);\n    }\n}\n\ncontract ProofDrawThenCancelTest is Test {\n    address private constant BOOK = address(0xB00C);\n    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    PFeed private primary;\n    PFeed private health;\n    PFeed private spot;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new PAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new PFeed(DOLLAR);\n        health = new PFeed(0.85 ether); // mat 170, gap 50: the book at 200% is a candidate\n        spot = new PFeed(DOLLAR);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        stable = vault.stablecoin();\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(BOOK, 200_000 ether);\n        vm.startPrank(BOOK);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(199_000 ether);\n        vault.draw(99_500 ether); // 200%: eligible for redemption, and the only debt on the book\n        vm.stopPrank();\n        // A day of hourly pacing: the paced debt catches up with the book.\n        for (uint256 i; i < 24; ++i) {\n            vm.warp(block.timestamp + 1 hours);\n            vm.roll(block.number + 300);\n            primary.set(DOLLAR);\n            spot.set(DOLLAR);\n            health.set(0.85 ether);\n            vault.pace();\n        }\n        assertEq(vault.backedDebt(), 99_500 ether, \"the book counts in full after a day\");\n    }\n\n    function _next() private {\n        vm.warp(block.timestamp + 12);\n        vm.roll(block.number + 1);\n    }\n\n    /// @dev The order the committed clamp covers: cancelling 20,000 of the book then drawing 20,000 leaves the\n    /// paced debt at the book without the cancelled part, so the new debt backs nothing until it has been held.\n    function test_cashThenDrawIsClamped() public {\n        CashThenDraw churner = new CashThenDraw();\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(address(churner), 40_000 ether);\n        // The churner needs imdUSD to redeem before it draws: the book lends it 20,000.\n        vm.prank(BOOK);\n        stable.transfer(address(churner), 20_000 ether);\n        churner.run(vault, imd, 40_000 ether, 20_000 ether, BOOK);\n        _next();\n        uint256 counted = vault.backedDebt();\n        emit log_named_uint(\"backedDebt after cash-then-draw\", counted);\n        assertLe(counted, 79_600 ether, \"the redrawn 20,000 does not count until it has been held\");\n    }\n\n    /// @dev The same capital, the same cancellation, the draw first: the paced debt is never clamped, and the\n    /// 20,000 drawn seconds ago counts for the work ceiling in the next transaction.\n    function test_drawThenCashCountsZeroSecondDebt() public {\n        DrawThenCash churner = new DrawThenCash();\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(address(churner), 40_000 ether);\n        uint256 debtBefore = vault.totalDebt();\n        churner.run(vault, imd, 40_000 ether, 20_000 ether, BOOK);\n        _next();\n        // The book's 20,000 was cancelled and the churner's 20,000 replaced it: the same total.\n        assertApproxEqAbs(vault.totalDebt(), debtBefore, 20 ether, \"the total is unchanged\");\n        (, uint256 bookDebt) = vault.positions(BOOK);\n        assertLt(bookDebt, 80_000 ether, \"the book's debt was cancelled\");\n        uint256 counted = vault.backedDebt();\n        emit log_named_uint(\"backedDebt after draw-then-cash\", counted);\n        // EXPECTED (the paced debt's stated property, CDPVault BACKING_RISE_PER_HOUR NatSpec and\n        // ParameterizedVault.backedDebt): at most the book less what was cancelled, about 79,500.\n        // ACTUAL: about 99,500, the whole total including 20,000 of debt drawn seconds ago.\n        assertLe(counted, 79_600 ether, \"debt cancelled by a redemption and drawn again backs nothing until held\");\n    }\n}","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.","severity":"medium","snippet":"            (principalCancelled, freshCancelled) = _redeemPosition(candidate, debtCancelled, gemOut - reserveOut, price);","title":"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"},{"citation":"resolved","description":"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).","line":323,"path":"src/CDPVault.sol","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.","severity":"medium","snippet":"    /// next leaves the figure where the book stood without it, until it climbs back. That dip exists only while","title":"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"},{"citation":"resolved","description":"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.","line":333,"path":"src/CDPVault.sol","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.","severity":"medium","snippet":"    /// panel 2026-10-08, low), can now lift the payout no faster than the same rate.","title":"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 "},{"citation":"resolved","description":"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.","line":868,"path":"src/CDPVault.sol","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).","severity":"low","snippet":"        _pacedAt = uint64(block.timestamp);","title":"_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"},{"citation":"resolved","description":"_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.","line":1073,"path":"src/CDPVault.sol","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.","severity":"low","snippet":"    /// redemption's increase is measured as if the supply were the floor, which only lowers fees while the","title":"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, "},{"citation":"resolved","description":"_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.","line":316,"path":"src/CDPVault.sol","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.","severity":"info","snippet":"    /// BACKING_RISE_PER_HOUR, nor moves the fee base or the work ceiling's debt faster than FOLLOW_BPS_PER_HOUR.","title":"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)"},{"citation":"resolved","description":"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`.","line":172,"path":"src/ParameterizedVault.sol","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).","severity":"info","snippet":"        // Saturating, like the vault's backing it feeds: an absurd price must not revert lock or wipe (CDPVault._mark).","title":"Stale cross-reference: ParameterizedVault._redemptionReserveBacking cites CDPVault._mark, which this commit removed; the caller is CDPVault._pace through _liveBacking"},{"citation":"resolved","description":"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.","line":202,"path":"src/CDPVault.sol","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.","severity":"info","snippet":"    /// it with reserveValueUsd + totalDebt * earnMat / 10000, the bound docs/COMPUTE-BACKING-","title":"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"},{"citation":"resolved","description":"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'.","line":266,"path":"src/CDPVault.sol","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.","severity":"info","snippet":"    /// inside their bound (at most 200% at that price, which includes every redeemable one) are","title":"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"},{"citation":"resolved","description":"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'.","line":847,"path":"src/CDPVault.sol","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.","severity":"info","snippet":"    /// @dev keccak256(\"comp.CDPVault.principalWipedThisTransaction\"): principal the caller repaid on its own","title":"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"},{"citation":"resolved","description":"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)'.","line":754,"path":"src/CDPVault.sol","reproduction":"Read lines 758-760 against _price (1476-1479): no call to _pricingStale, spotFeed.isStale or _requirePriceAgreement on the view's path.","severity":"info","snippet":"    /// @notice Value backing one imdUSD, 1e18-scaled, never above par, at the latest accepted price.","title":"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"}],"hash":"59c3ee342c50c9797d1c2121e7429eecaaef443a4a43c102977e128df1f8b6cd","nodeId":"82f943de-0d54-4569-b488-9b1ac66630ef","outcome":"completed","summary":"The review is complete. The source tree is unchanged, the findings file parses, and every reproduction fails on the pinned commit for the stated reason.\n\n**Outcome.** Eleven findings are in `.imd-findings.json`: three medium, two low, six info. No critical or high. Every kept finding was reproduced against d3861ac with a Foundry test; the specialists' twenty findings were merged into these eleven, and nothing was dropped for failing to reproduce.\n\n**The three mediums, in rank order:**\n\n- **Draw-then-cancel is never clamped.** The paced-debt clamp runs only after `draw`, so a transaction that draws X and then cancels X of someone else's debt by cash, bite or cover leaves zero-second debt counting in full for the work ceiling next block. Both attached proofs fail for exactly this reason. Nothing is takeable at wage 0; once a wage is set it is the D1 round trip at zero holding time. Fix is three `_clampPacedDebt()` calls after the cancellations. I verified the fix makes all five proof tests pass and costs 22 bytes of initcode. This finding carries the proof.\n- **The accepted dip is not \"below par only\".** A par book whose par reading is carried by one dominant healthy position dips to the rest-of-book figure when that position wipes and redraws across two blocks. Reproduced at 0.80 on a par book with an underwater tail, no bad debt, wage 0. The three specialist shapes (underwater tail, realized bad debt, work supply) are merged into one finding, since the mechanism and the NatSpec correction are the same.\n- **Nobody but the owner re-prices a debt-bound term.** Marked at a crash low, a high-CR position's term over-reads after recovery and lifts the payout to par when honest backing is 0.86, draining the Treasury's reserve first. The same gap makes the accepted stale-term read after a fall last until the owner acts, not \"a few hours\". One permissionless re-price function closes both directions.\n\n**The lows** are the pacing clock advancing on an unusable price (anyone forfeits a recovering payout's rise with one call per stale window) and the launch-day fee base starting from zero (a 1% redemption is quoted at 300 bps instead of 100 for about 27 paced hours, contradicting the \"only lowers fees\" sentence).\n\n**The infos** are the NatSpec defects Q8 asks for: the dead `_mark` reference, the `earnLine` formula, the follow rate compounding under frequent pacing, the \"every redeemable one\" claim against a 220 ceiling, the per-transaction WIPED tally described as per-position, and `backingPerUnit()` reading an ungated price.\n\n**Coverage.** Read in full: CDPVault, ParameterizedVault, ImdUSD, plus the Treasury valuation path, SharePriceFeed, UsdPriceFeed and SwarmFeed entry points on the ungated pacing path, which I confirmed cannot revert `lock` or `wipe`. Not reached: liquidation and cover were checked only by reading, not by new tests, since the specialists raised nothing there and earlier rounds cover them.","treeHash":null,"usage":{"cachedInputTokens":1548665,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":32097,"runtime":"claude","turns":20,"wallClockMs":670653}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fb26479062458645","findings":[{"citation":"resolved","description":"The paced figures' NatSpec accepts the dip (a withdrawal paced in one transaction and reversed in the next) with the reason that it 'exists only while the book is backed below par (on a par book the surplus above the aggregate cap absorbs any one position's exit)'. The reason does not hold once realized bad debt is outstanding, which at the committed constants (wage 0) is the ordinary state after any crash that drained a position and before the Treasury holds imdUSD to `cover` it. `_securedCollateralValue` caps the collateral it counts at mat x (totalDebt - MINTED - totalBadDebt) (line 938) while `_liveBacking` divides by a supply that still carries the drained position's imdUSD. With bad debt B, the book reads par as long as R + mat/100 x (D - B) >= S, but after the dominant position P wipes, the cap is mat/100 x (D - B - P) and the supply S - P: with one large borrower and the rest bad debt the cap is 0 and the figure is 0. `_pace` writes the fall at once at the first capital-moving call of the next transaction (the borrower's own redraw paces it before the draw changes anything, `_pace` line 861-869), and `cash` pays min(live, paced) (line 730, `_backingPerUnit`), so every redemption, from the reserve or a candidate, is paid the dipped figure while it climbs back at BACKING_RISE_PER_HOUR: from zero, 50 paced hours to par. The wipe is ungated (no feed needed) and the redraw needs only fresh feeds and health, so the dip is re-armable every hour for gas by the dominant borrower, and an honest refinance (close one block, reopen the next) triggers it too. The stated bound ('at most the gap between the book's backing and the backing of the book without that position') holds; the stated condition ('only below par', 'only in a book already in crisis') does not: the book is at par and every open position is healthy. The same arithmetic applies with work-minted supply W outstanding (par requires 0.7(D) + R >= W at mat 170 and the exit of P needs 0.7(D - P) + R >= W), which the NatSpec half-states ('with work-minted supply outstanding a dominant borrower's exit can take it to zero') while still saying 'only below par'. Who loses: redeemers (paid as little as 0.0067% of par in the reproduction) and the peg's redemption floor, which is the mechanism the cash() comment at 702-729 says must stay open under stress; the protocol keeps the collateral. Reachable with the constants as committed: wage 0, no governance, one past liquidation that drained a position. Smallest fix: there is no small code fix that keeps 'a fall is paced at once' (pacing the fall reopens the lift D1 closed; netting a position's exit and return across transactions is the per-position lag this commit removed), so (1) correct the NatSpec and web/content/docs/economics/risks-and-open-questions.md to the real condition: the dip exists whenever R + mat/100 x (D - B - P) < S - P for the largest position P, i.e. on any book carrying realized bad debt or work-minted supply, and can reach zero; and (2) make `cover` reach the bad debt promptly in the runbook (seed the Treasury with imdUSD at launch, or let `cover` burn the caller's own imdUSD against a drained position), since every unit of bad debt covered removes the precondition. A code-level mitigation that does not reopen the lift: let `cover` burn the CALLER's imdUSD as well as the Treasury's (anyone may then retire bad debt, which removes the precondition), rather than changing how the fall is paced.","line":323,"path":"src/CDPVault.sol","reproduction":"test/scratch/ParBookDipWithBadDebt.t.sol (ParameterizedVault over an 18-decimal MockIMD at $1: IMD/ETH 1/2000 x Chainlink 2000e8 etched at CHAINLINK_ETH_USD; NHI 0.85 so mat 170, gap 50; TreasuryFactory etched; no reserve). BOOK locks 199,000 and draws 99,500 (200%); LOSER locks 17,000 and draws 10,000 (170%) and hands the 10,000 imdUSD to KEEPER; 24 paced hours. Price to $0.50; bark(LOSER); 6 hours; KEEPER bites LOSER for 7,083.33 (the largest debt whose 1.2x seizure fits 17,000 IMD): LOSER drained, totalBadDebt 2,917 + fees. Price back to $1; BOOK lock(1) re-prices its term; 60 paced hours: backingPerUnit() == 1e18 (cap 1.7 x (102,417 - 2,917) = 169,150 over supply 102,417: par, every open position healthy). Then BOOK wipe(99,500) in one transaction and draw(99,500) in the next (12 s later). EXPECTED on the NatSpec's reasoning: backingPerUnit() == 1e18 ('on a par book ... absorbs any one position's exit'). ACTUAL: 121709869698224744 (0.1217: the pacing at the redraw found supply 2,953 against cap 1.7 x (36 + 2,917 - 2,917) = 61). Second test: ten paced hours later the figure is 0.3216 and KEEPER's cash(1,000e18, 0, BOOK) is paid 318.46 IMD where par less the fee pays 995; BOOK then wipes its whole debt and redraws 99,000 one block later: backingPerUnit() == 66666666666666 (0.0000667). Run: forge test --match-path test/scratch/ParBookDipWithBadDebt.t.sol -vv; the first test fails on d3861ac with '121709869698224744 != 1000000000000000000'.","severity":"medium","snippet":"    /// next leaves the figure where the book stood without it, until it climbs back. That dip exists only while\n    /// the book is backed below par (on a par book the surplus above the aggregate cap absorbs any one\n    /// position's exit, `_securedCollateralValue`), is at most the gap between the book's backing and the\n    /// backing of the book without that position, and with work-minted supply outstanding a dominant borrower's\n    /// exit can take it to zero (test/retry-panel/StaleBank.t.sol). ACCEPTED: it underpays redeemers, never","title":"Paced backing: the accepted dip is not confined to a book below par; on a par book carrying realized bad debt a dominant position's wipe and redraw across two transactions paces the figure to ~0, rede"},{"citation":"resolved","description":"`_clampPacedDebt` (lines 876-882) is called from `draw` only (line 504). Its NatSpec says 'After a draw: the paced debt never exceeds the debt this transaction began with less what it has cancelled by redemption, liquidation or cover', and ParameterizedVault.backedDebt's comment (line 262-263) says 'debt cancelled this transaction (by a redemption, a liquidation or cover) backs nothing even if the same amount is drawn again'. Both hold for cancel-then-draw, which the sweep panel's proof used, and fail for draw-then-cancel: lock C, draw X (clamp: live = totalDebt + WIPED - MINTED = D + X - X = D, no change), then cash(X, 0, victim) with the fresh imdUSD (totalDebt back to D, nothing clamps). The transaction ends with totalDebt = D and _debtPaced = D, and the next transaction's `_pace` writes `_pacedDebt` = min(D, D + step) = D: the attacker's zero-second X counts in full, where the NatSpec promises the follow rate (10% of max(paced, 100,000) an hour). Inside the same transaction `backedDebt` is still bounded (`_pacedDebt(0)` reads live = D - X), so the gap is one block, not none. Harm with the wage on: `earnLine` = reserve + 25% x backedDebt counts the swapped X at once and `earn` mints against it; the attacker may then wipe and free. The same end state (work-minted supply against debt that is then cancelled) is reachable without the gap by `earn` against the victim's seasoned debt and then `cash` against it, which the backedDebt NatSpec accepts ('the ceiling gates new minting only'), so this is a hole in a stated guarantee rather than new value taken; at WAGE_WAD 0 `earn` is refused and nothing is reachable. Also reachable through bite (draw X, bite an underwater position for X) and cover (draw X, cover a drained position for X with the Treasury's imdUSD). Smallest fix: call `_clampPacedDebt()` wherever debt is cancelled as well, e.g. at the end of `_reduceDebt` (after `totalDebt -= principalPaid`) or at the end of `cash`, `bite` and `cover`; a position's own wipe and redraw still nets through WIPED_THIS_TX_SLOT. One SLOAD and at most one SSTORE per cancellation.","line":881,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// CDPVault._clampPacedDebt runs only after `draw`. A transaction that draws FIRST and cancels another position's\n// debt afterwards (cash, bite or cover) ends with totalDebt where it began and the paced debt untouched, so the\n// next transaction counts the zero-second principal in full: the sweep panel's high (2026-10-07) by the other\n// ordering. The NatSpec at _clampPacedDebt and ParameterizedVault.backedDebt claim the paced debt never exceeds\n// the debt the transaction began with less what it cancelled.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {CDPVault} from \"src/CDPVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {MockWorkOracle} from \"src/MockWorkOracle.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract CoFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        set(v);\n    }\n\n    function set(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract CoAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract Attacker {\n    ParameterizedVault private immutable vault;\n    MockIMD private immutable imd;\n\n    constructor(ParameterizedVault vault_, MockIMD imd_) {\n        vault = vault_;\n        imd = imd_;\n        imd_.approve(address(vault_), type(uint256).max);\n    }\n\n    /// @dev One transaction: lock, draw X, then cancel X of `victim`'s debt by redeeming the fresh imdUSD against it.\n    function drawThenCancel(uint256 collateral, uint256 amount, address victim) external {\n        vault.lock(collateral);\n        vault.draw(amount);\n        vault.cash(amount, 0, victim);\n    }\n\n    /// @dev The other ordering, which the clamp catches.\n    function cancelThenDraw(uint256 collateral, uint256 amount, address victim) external {\n        vault.cash(amount, 0, victim);\n        vault.lock(collateral);\n        vault.draw(amount);\n    }\n\n    function earn(uint256 amount) external {\n        vault.earn(amount);\n    }\n\n    function wipeAndFree(uint256 amount, uint256 collateral) external {\n        vault.wipe(amount);\n        vault.free(collateral);\n    }\n}\n\ncontract ClampOrderingTest is Test {\n    address private constant BOOK = address(0xB00C);\n\n    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    MockWorkOracle private oracle;\n    CoFeed private primary;\n    CoFeed private health;\n    CoFeed private spot;\n    Attacker private attacker;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new CoAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new CoFeed(DOLLAR);\n        health = new CoFeed(0.85 ether);\n        spot = new CoFeed(DOLLAR);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        stable = vault.stablecoin();\n        oracle = MockWorkOracle(address(vault.oracle()));\n        attacker = new Attacker(vault, imd);\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BOOK, 200_000 ether);\n        imd.mint(address(attacker), 400_000 ether);\n        oracle.grantRights(address(attacker), 100_000 ether);\n        vm.stopPrank();\n        vm.prank(BOOK);\n        imd.approve(address(vault), type(uint256).max);\n        Parameters params = vault.parameters();\n        vm.prank(APPROVED_OPERATOR);\n        params.proposeWage(0.01 ether);\n        vm.warp(block.timestamp + params.TIMELOCK());\n        params.applyPending();\n    }\n\n    function _next(uint256 seconds_) private {\n        vm.roll(block.number + 1 + seconds_ / 12);\n        vm.warp(block.timestamp + seconds_);\n        primary.set(DOLLAR);\n        spot.set(DOLLAR);\n        health.set(0.85 ether);\n    }\n\n    function _hours(uint256 n) private {\n        for (uint256 i; i < n; ++i) {\n            _next(1 hours);\n            vault.pace();\n        }\n    }\n\n    function _book() private {\n        // BOOK at 200%, inside the redeemable band (mat 170 + gap 50 = 220), seasoned for a day: paced debt = 99,500.\n        vm.startPrank(BOOK);\n        vault.lock(199_000 ether);\n        vault.draw(99_500 ether);\n        vm.stopPrank();\n        _hours(30);\n        (,, uint256 pacedDebt,) = vault.paced();\n        assertEq(pacedDebt, 99_500 ether, \"seasoned\");\n    }\n\n    function test_cancelThenDrawIsClamped() public {\n        _book();\n        vm.prank(BOOK);\n        stable.transfer(address(attacker), 99_500 ether); // the spare imdUSD the sweep panel's proof gave the attacker\n        attacker.cancelThenDraw(300_000 ether, 99_500 ether, BOOK);\n        _next(12);\n        (,, uint256 pacedDebt,) = vault.paced();\n        emit log_named_uint(\"paced debt after cancel-then-draw\", pacedDebt);\n        assertLt(pacedDebt, 200 ether, \"the redrawn 99,500 counts only at the follow rate\");\n    }\n\n    function test_drawThenCancelIsNotClamped() public {\n        _book();\n        attacker.drawThenCancel(300_000 ether, 99_500 ether, BOOK);\n        _next(12);\n        (,, uint256 pacedDebt,) = vault.paced();\n        emit log_named_uint(\"paced debt after draw-then-cancel\", pacedDebt);\n        emit log_named_uint(\"totalDebt\", vault.totalDebt());\n        (, uint256 bookDebt) = vault.positions(BOOK);\n        emit log_named_uint(\"BOOK's debt left\", bookDebt);\n        emit log_named_uint(\"backedDebt\", vault.backedDebt());\n        emit log_named_uint(\"earnLine\", vault.earnLine());\n        // EXPECTED (NatSpec): the paced debt is the debt the transaction began with less what it cancelled, about\n        // BOOK's residue, and the attacker's zero-second 99,500 counts at 10% of the floor an hour. ACTUAL: 99,500.\n        assertLt(pacedDebt, 200 ether, \"the redrawn 99,500 counts only at the follow rate\");\n    }\n\n    function test_drawThenCancelBacksWorkMintingAtOnce() public {\n        _book();\n        attacker.drawThenCancel(300_000 ether, 99_500 ether, BOOK);\n        _next(12);\n        // The ceiling: the reserve is empty, so earnLine = 25% of backedDebt. Honest: about 25% of (residue + one\n        // hour's step at most). Actual: 25% of 99,500.\n        uint256 line = vault.earnLine();\n        emit log_named_uint(\"earnLine one block after the swap\", line);\n        vm.expectRevert(CDPVault.WorkCeilingReached.selector);\n        attacker.earn(24_000 ether);\n    }\n}","reproduction":"test/scratch/ClampOrdering.t.sol (same fixture; wage 0.01 applied through Parameters.proposeWage + TIMELOCK + applyPending; MockWorkOracle grants the attacker contract 100,000 of rights). BOOK locks 199,000 and draws 99,500 (200%, in the band) and is paced hourly for 30 hours: paced debt 99,500. The attacker contract, in ONE transaction, locks 300,000, draws 99,500 and calls cash(99,500e18, 0, BOOK) (no reserve, so the whole burn cancels BOOK's debt). One block later: EXPECTED paced debt about BOOK's residue (15.13e18) plus at most one step, as the NatSpec states and as the cancel-then-draw ordering gives (test_cancelThenDrawIsClamped logs 15129452054794526000). ACTUAL: paced() debt == 99500000000000000000000, backedDebt() == 99515129452054794526000, earnLine() == 24878782363013698631500, and attacker.earn(24,000e18) succeeds where WorkCeilingReached was expected. Run: forge test --match-path test/scratch/ClampOrdering.t.sol -vv; two of the three tests fail on d3861ac.","severity":"low","snippet":"        if (live < _debtPaced) _debtPaced = live;","title":"_clampPacedDebt runs only after draw: a transaction that draws first and cancels another position's debt afterwards (cash, bite or cover) leaves the paced debt at the start figure, so the zero-second "},{"citation":"resolved","description":"`_pace` (lines 861-869) writes `_pacedAt = block.timestamp` whether or not `_priceAgrees()`, while the backing itself holds when the price is unusable (`_pacedBacking` line 797). So a `lock`, `wipe`, debt-free `free` or `pace()` made while the spot or price feed is stale (the shipped feeds are updated on demand with a one-hour lifetime, DeploymentConfig lines 32-39, so stale windows are ordinary) does not move the backing but does restart the clock the next rise is measured from. The NatSpec at 319-321 and 317-318 say the figure 'holds' and that 'a quiet gap recovers one interval'; an outage with ungated calls recovers only BACKING_RISE_PER_HOUR x (time since the last such call), and anyone may make that zero by calling `pace()` every block during a stale window. Cost, never a gain: honest redeemers on a book below par are paid less for longer; the 'six dead hours did not count' test (test/PacedFigures.t.sol) pins the behaviour but the NatSpec does not state it. Smallest fix: either leave `_pacedAt` unchanged when the price is unusable (the supply and debt figures would then need their own timestamp, or be stepped with the elapsed time and the backing's clock kept separately), or state at 319-321 that a pacing at an unusable price consumes the interval.","line":321,"path":"src/CDPVault.sol","reproduction":"test/scratch/GasAndFee.t.sol, test_outageWithUngatedCallsEatsTheRise: BOOK at 200% with 99,500 of debt; price to $0.40 and BOOK lock(1): paced at 0.8804. Price back to $1 (live reads par) and pace(): the figure should now climb 0.02 per paced hour. The spot feed reports stale for 50 minutes; at minute 50 BOOK wipe(1) (ungated): paced() backing unchanged (held). At minute 60 the feed is fresh and pace() is called. EXPECTED per 'a quiet gap recovers one interval': +0.02e18. ACTUAL: +3333333333333333 (0.0033, ten minutes' worth). Logged on d3861ac.","severity":"info","snippet":"    /// vault is paced (hourly pacing recovers in full; a quiet gap recovers one interval). And a fall is paced at","title":"NatSpec: a feed outage is said to hold the paced backing, but every ungated call made during it resets the pacing clock, so the first pacing after the feed returns credits less than the one interval a"},{"citation":"resolved","description":"`_feeBase` is max(paced supply, 100,000) (lines 1075-1079) and the paced supply starts at 0 and follows the live supply by 10% of max(paced, 100,000) an hour (`_pacedSupply`, `_step`). After a launch that draws 500,000 in its first hour the paced supply is 10,000 after one paced hour and reaches 500,000 only after 27 paced hours (10 hours of 10,000 steps to the floor, then 17 hours of 10% compounding), with fewer, larger gaps taking longer (elapsed time counts at most PACE_INTERVAL per pacing). Throughout, `_redemptionRate` measures the increase against 100,000: a redemption of 1% of the live supply (5,000) is quoted 300 bps where the live base gives 100, 9,000 pins the 4.5% base rate for every later redeemer (45,000 would be needed against the live supply), and anything from 9,000 up pays the 500 bps cap. The sentence at 1072-1074 describes the floor against the paced supply and is true of that; the launch-day effect (paced below live) charges more, not less, and is stated nowhere in these files or in docs/MAINNET-RUNBOOK.md. A cost in the direction of charging redeemers more and of letting a 9,000 burn pin the cap for everyone on launch day; not a payout error. Smallest fix: state it at `_feeBase` and in the runbook, or seed `_supplyPaced` from the live supply at the first pacing after deployment (one branch in `_pace`: if `_supplyPaced == 0` write the live figure), which keeps the follow limit for everything after launch.","line":1073,"path":"src/CDPVault.sol","reproduction":"test/scratch/GasAndFee.t.sol, test_launchDayFee: WHALE locks 1,500,000 and draws 500,000 at deployment; one hour later pace(): paced() supply == 10000000000000000000000. redemptionFeeBps(5,000e18) == 300 (against the live supply of 500,000 at divisor 2 it would be 50 + 50 = 100); redemptionFeeBps(9,000e18) == 500; redemptionFeeBps(25,000e18) == 500. Hourly pacing reaches a 500,000 base after 27 hours. Logged on d3861ac.","severity":"info","snippet":"    /// redemption's increase is measured as if the supply were the floor, which only lowers fees while the","title":"NatSpec: the fee-base floor is said to 'only lower fees while the protocol is that small', but while the live supply exceeds the floor and the paced supply has not caught up (about 27 paced hours afte"},{"citation":"resolved","description":"ParameterizedVault.earnLine (line 276-278) is reserveValue() + backedDebt() x earnMat / 10000, and backedDebt (260-267) is min(totalDebt, _debtAtTransactionStart(), _pacedDebtNow()) less totalBadDebt. The base-vault NatSpec at 201-203 (and DeploymentConfig line 144, outside this scope) still names totalDebt. Harmless, but a reader sizing the ceiling from this line over-states it by the bad debt and by the paced lag. Fix: say backedDebt.","line":202,"path":"src/CDPVault.sol","reproduction":"Read ParameterizedVault.sol lines 260-278 against CDPVault.sol lines 199-206: the formula named differs from the one enforced; test/scratch/ClampOrdering.t.sol logs earnLine() == 24878782363013698631500 for backedDebt 99,515 (25% of the paced/bad-debt-adjusted figure), not of totalDebt alone when they differ.","severity":"info","snippet":"    /// it with reserveValueUsd + totalDebt * earnMat / 10000, the bound docs/COMPUTE-BACKING-","title":"NatSpec: `earnLine` is described as reserveValueUsd + totalDebt x earnMat / 10000; ParameterizedVault adds backedDebt (totalDebt capped at the transaction-start and paced figures, less totalBadDebt)"},{"citation":"resolved","description":"The per-position lag's `_mark` was replaced by `_pace` in d3861ac (commit message and CDPVault line 861). The comment's claim (an absurd price must not revert lock or wipe) is still what the code does; only the reference is dead. Fix: `CDPVault._pace`.","line":172,"path":"src/ParameterizedVault.sol","reproduction":"grep -n '_mark' src/*.sol finds only this comment; grep -n 'function _pace' src/CDPVault.sol finds the function it means.","severity":"info","snippet":"        // Saturating, like the vault's backing it feeds: an absurd price must not revert lock or wipe (CDPVault._mark).","title":"Stale comment: ParameterizedVault._redemptionReserveBacking refers to `CDPVault._mark`, a function this commit renamed to `_pace`"}],"hash":"73740e320fdd6487a108f5e0cf14b3d31c0e8d1907da02c66defd97fdbb1765e","nodeId":"c153fbbd-1704-4493-8819-ab2fc274c0df","outcome":"completed","summary":"Review complete. The findings file holds 6 items: 1 medium, 1 low, 4 info. The committed suite passes (604 tests, 4 skipped), and every scratch reproduction ran on d3861ac as described.\n\n## Findings (ranked)\n\n**1. Medium, `src/CDPVault.sol:323`, the accepted dip's condition is wrong.** The paced-figures NatSpec accepts the dip only \"while the book is backed below par\" because \"on a par book the surplus above the aggregate cap absorbs any one position's exit\". With realized bad debt outstanding that is false: the cap is mat × (debt − bad debt) while the supply still carries the drained position's imdUSD. Reproduction (`test/scratch/ParBookDipWithBadDebt.t.sol`): BOOK 199k/99.5k at 200%, LOSER drained by one bite after a crash to $0.50 (bad debt 2,917), price back to $1, 60 paced hours, backingPerUnit reads par with every open position healthy. BOOK wipes in one transaction and redraws in the next. Expected par, actual 0.1217. A redemption ten paced hours later is paid 318 IMD per 1,000 imdUSD; a second wipe-and-redraw puts the figure at 0.0000667. Recovery is 50 paced hours from zero and the cycle costs gas (the wipe is ungated). The stated bound holds; the stated condition and \"only in a book already in crisis\" do not. No small code fix keeps \"a fall is paced at once\"; the fix is to state the real condition and make `cover` reach bad debt promptly (seed Treasury imdUSD, or let `cover` burn the caller's imdUSD).\n\n**2. Low, `src/CDPVault.sol:881`, the clamp runs only after `draw`.** Draw X then cancel X of a victim's debt (cash, bite or cover) in one transaction ends with totalDebt and the paced debt where they began, so the zero-second X counts in full one block later. Reproduction attached as proof (`test/scratch/ClampOrdering.t.sol`): paced debt 99,500 where cancel-then-draw gives 15; `earn(24,000)` succeeds with the wage on. The same end state is reachable legally (earn, then cash), so this is a hole in a stated guarantee rather than new value taken, and unreachable at wage 0. Fix: call `_clampPacedDebt()` after every cancellation.\n\n**3–6. Info, NatSpec claims the code does not have:** an ungated call during a feed outage resets the pacing clock (0.33 points credited instead of 2, measured); the fee-base floor \"only lowers fees\" while a launch-day redeemer is charged against 100k for ~27 paced hours (5,000 pays 300 bps not 100; 9,000 pins the cap); `earnLine` described as totalDebt-based; a dead reference to `CDPVault._mark`.\n\n## Answers to the numbered questions\n\n1. **Paced backing.** No sequence found that pays a redemption more than live backing plus 2 points per paced hour. Every capital-moving call paces first from the state found; `cash` pays min(live, paced) with elapsed 0; the live figure excludes same-transaction collateral, principal and repayments; a feed update or donation before the pacing is bounded by the rise, after it by live. Costs, all underpaying: the dip (finding 1, up to 100 points, 50 hours); the stale-term read after a fall above 50% (2(1−f) of par for high-ratio positions, lasting until their owners touch them, not \"a few hours\", then 2 points an hour); a quiet gap credits one interval; an outage with ungated calls credits less than one (finding 3).\n2. **Paced supply and fee.** Cheapest cap pin now: 9,000 of 12-hour-seasoned principal redeemed against the redeemer's own band position with an empty reserve, fee retained in own collateral, re-pinned with ~1,000 every two hours. Cheapest dilution: hold a large loan ~23 paced hours, wipe, and the base decays 10% an hour (a day of stability fee on the loan). No ordering moves the base faster than 10% an hour within or across transactions. Launch-day fee: finding 4.\n3. **Paced debt and work ceiling.** Draw-then-cancel defeats the follow rate (finding 2). A borrower's own wipe and redraw nets in one transaction, and across two lowers the paced debt at once with recovery at 10% an hour, so any large borrower's refinance lowers the ceiling for everyone; that ","treeHash":null,"usage":{"cachedInputTokens":5294832,"inputTokens":802,"model":"claude-fable-5-1","outputTokens":107587,"runtime":"claude","turns":43,"wallClockMs":1691237}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a4c81f495eb81dd0","findings":[{"citation":"resolved","description":"Q3. `_clampPacedDebt` (line 876) lowers `_debtPaced` to `totalDebt + WIPED - MINTED` and is called from `draw` only (line 504). It closes the sweep-panel high for the order cancel-then-draw. In the order draw-then-cancel the clamp runs while the cancellation has not happened yet (live = T + d - d = T, not below the paced T), and `cash` (via `_redeemPosition`), `bite` and `cover` lower `totalDebt` afterwards with no clamp. At the end of the transaction `_debtPaced` is still T while the composition of T changed: the candidate's d (held for hours) is gone and the attacker's d (drawn seconds ago) replaced it. The next transaction's `_pace` finds live = T >= paced = T and keeps T, so `ParameterizedVault.backedDebt()` = min(totalDebt, start, paced) = T counts the fresh d in full. The stated property (BACKING_RISE_PER_HOUR NatSpec: 'debt cancelled by a redemption or a liquidation and drawn again by someone else backs nothing until it has been held'; ParameterizedVault.backedDebt lines 237-239 and 261-263) does not hold for this order. Call sequence from an external caller (one contract, one transaction): lock(C); draw(d); cash(d, 0, candidate) where the candidate is any position in the 170-220% band and the Treasury's sIMD reserve is smaller than the payout (at launch the reserve is empty until the first liquidation); next transaction: earn (once a wage is set) against earnLine = reserve + 25% x backedDebt; third transaction: wipe(d), free(C). The same with draw then bite(victim, d) (profitable on its own) or draw then cover(drained, d) (burns the Treasury's imdUSD, costs the caller nothing). Within the transaction itself the MINTED exclusion holds (backedDebt reads T - d), so the gap is exactly the carried-over `_debtPaced`. 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 at launch, hence medium rather than the sweep panel's high. Once governance sets a wage it is the D1 round trip at zero holding time: 25% of any debt an attacker can cancel in one transaction (bounded by the candidates' debt and the fee on the redemption, about 0.5-5%) becomes unbacked work-minted imdUSD after the attacker unwinds. Smallest fix: call `_clampPacedDebt()` after every cancellation as well as after every draw: after `_redeemPosition` in `cash` (inside the `reserveOut < gemOut` branch), after `_reduceDebt` in `bite`, and after `_reduceDebt` in `cover`. Wipe is unaffected (WIPED offsets its fall, the clamp is a no-op there). Three call sites, no new storage, about 60 bytes of initcode (2,165 under the limit).","line":742,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// The paced debt is clamped only after `draw` (CDPVault._clampPacedDebt). A draw FOLLOWED by a cancellation of\n// another position's debt in the same transaction (cash here; bite and cover take the same path) leaves\n// `_debtPaced` where the transaction found it, so in the next transaction the zero-second debt that replaced\n// the cancelled one counts in full for the work ceiling (ParameterizedVault.backedDebt). The mirror order\n// (cash, then draw) is clamped, as the sweep-panel fix intended.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract PFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        set(v);\n    }\n\n    function set(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract PAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @dev One transaction: lock, draw, then redeem the drawn imdUSD against the book (draw FIRST).\ncontract DrawThenCash {\n    function run(ParameterizedVault vault, MockIMD imd, uint256 collateral, uint256 debt, address candidate) external {\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(collateral);\n        vault.draw(debt);\n        vault.cash(debt, 0, candidate);\n    }\n}\n\n/// @dev The same three steps with the redemption BEFORE the draw (the order the committed clamp covers).\ncontract CashThenDraw {\n    function run(ParameterizedVault vault, MockIMD imd, uint256 collateral, uint256 debt, address candidate) external {\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(collateral);\n        vault.cash(debt, 0, candidate);\n        vault.draw(debt);\n    }\n}\n\ncontract ProofDrawThenCancelTest is Test {\n    address private constant BOOK = address(0xB00C);\n    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    PFeed private primary;\n    PFeed private health;\n    PFeed private spot;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new PAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new PFeed(DOLLAR);\n        health = new PFeed(0.85 ether); // mat 170, gap 50: the book at 200% is a candidate\n        spot = new PFeed(DOLLAR);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        stable = vault.stablecoin();\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(BOOK, 200_000 ether);\n        vm.startPrank(BOOK);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(199_000 ether);\n        vault.draw(99_500 ether); // 200%: eligible for redemption, and the only debt on the book\n        vm.stopPrank();\n        // A day of hourly pacing: the paced debt catches up with the book.\n        for (uint256 i; i < 24; ++i) {\n            vm.warp(block.timestamp + 1 hours);\n            vm.roll(block.number + 300);\n            primary.set(DOLLAR);\n            spot.set(DOLLAR);\n            health.set(0.85 ether);\n            vault.pace();\n        }\n        assertEq(vault.backedDebt(), 99_500 ether, \"the book counts in full after a day\");\n    }\n\n    function _next() private {\n        vm.warp(block.timestamp + 12);\n        vm.roll(block.number + 1);\n    }\n\n    /// @dev The order the committed clamp covers: cancelling 20,000 of the book then drawing 20,000 leaves the\n    /// paced debt at the book without the cancelled part, so the new debt backs nothing until it has been held.\n    function test_cashThenDrawIsClamped() public {\n        CashThenDraw churner = new CashThenDraw();\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(address(churner), 40_000 ether);\n        // The churner needs imdUSD to redeem before it draws: the book lends it 20,000.\n        vm.prank(BOOK);\n        stable.transfer(address(churner), 20_000 ether);\n        churner.run(vault, imd, 40_000 ether, 20_000 ether, BOOK);\n        _next();\n        uint256 counted = vault.backedDebt();\n        emit log_named_uint(\"backedDebt after cash-then-draw\", counted);\n        assertLe(counted, 79_600 ether, \"the redrawn 20,000 does not count until it has been held\");\n    }\n\n    /// @dev The same capital, the same cancellation, the draw first: the paced debt is never clamped, and the\n    /// 20,000 drawn seconds ago counts for the work ceiling in the next transaction.\n    function test_drawThenCashCountsZeroSecondDebt() public {\n        DrawThenCash churner = new DrawThenCash();\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(address(churner), 40_000 ether);\n        uint256 debtBefore = vault.totalDebt();\n        churner.run(vault, imd, 40_000 ether, 20_000 ether, BOOK);\n        _next();\n        // The book's 20,000 was cancelled and the churner's 20,000 replaced it: the same total.\n        assertApproxEqAbs(vault.totalDebt(), debtBefore, 20 ether, \"the total is unchanged\");\n        (, uint256 bookDebt) = vault.positions(BOOK);\n        assertLt(bookDebt, 80_000 ether, \"the book's debt was cancelled\");\n        uint256 counted = vault.backedDebt();\n        emit log_named_uint(\"backedDebt after draw-then-cash\", counted);\n        // EXPECTED (the paced debt's stated property, CDPVault BACKING_RISE_PER_HOUR NatSpec and\n        // ParameterizedVault.backedDebt): at most the book less what was cancelled, about 79,500.\n        // ACTUAL: about 99,500, the whole total including 20,000 of debt drawn seconds ago.\n        assertLe(counted, 79_600 ether, \"debt cancelled by a redemption and drawn again backs nothing until held\");\n    }\n}","reproduction":"test/scratch/Proof_DrawThenCancel.t.sol (attached; fails on this code, the control order passes). ParameterizedVault over an 18-decimal MockIMD at $1 (IMD/ETH 1/2000 times a Chainlink ETH/USD of 2000e8 etched at CHAINLINK_ETH_USD), NHI 0.85 (mat 170, gap 50), TreasuryFactory etched, Treasury empty, launch constants. BOOK locks 199,000 and draws 99,500 (200%, a redemption 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 (unchanged within the cancelled fees), BOOK's debt == 79,512e18. EXPECTED backedDebt() <= 79,600e18 (the book less what was cancelled; the churner's 20,000 has been held for 12 seconds). ACTUAL backedDebt() == 99512103561643835581000. Control, same capital with cash(20_000e18, 0, BOOK) BEFORE draw(20_000e18): backedDebt() == 79545436894977168914333 next block (the committed clamp works for that order). With `_clampPacedDebt()` added after the cancellation in cash, the failing test reads the control's figure.","severity":"medium","snippet":"            (principalCancelled, freshCancelled) = _redeemPosition(candidate, debtCancelled, gemOut - reserveOut, price);","title":"CDPVault: the paced debt is clamped only after draw, so a draw FOLLOWED by a cancellation (cash, bite or cover) in one transaction leaves zero-second debt counting in full for the work ceiling in the "},{"citation":"resolved","description":"Q1 (a redemption paid more than the honest backing) and Q8. `securedCollateral` sums per-position terms min(collateral, 2 x principal / price) in IMD, each fixed at the price of the position's last touch. A debt-bound term (CR above 200%) written at price p0 is worth 2P x p / p0 at a later price p: after a rise it over-reads by 2P (p / p0 - 1), which the launch vault panel reported (low) and this commit answers only with the rise rate. Two things make the rate an incomplete answer. First, nobody but the owner can re-price such a term: `_resecure` runs from lock, lockIMD, free, draw and wipe (owner only), from cash (candidates below mat + gap only, 220%), bite (underwater only) and cover (dust or recorded bad debt only), so a position at 300-1200% is touched by no third party, ever. Second, `lock` is ungated and prices the term at `_priceOrZero()` whatever the feeds' state, so the owner chooses the touch price for free (lock(1) at the lowest print, during a halt included). The paced backing rises 2 points an hour toward min(live, par) with the inflated live as its target, and no honest figure caps it below that: in (over-read / 0.02) hours every redemption is paid the stale figure, from the Treasury's sIMD first (`reserveOut`) and then from any candidate in band, taking collateral beyond its share for as long as the owner leaves the term. This is the opposite direction from the dip and the stale read after a fall, both accepted because they only underpay ('WHAT IT COSTS, all in the direction of paying less', line 319); the mirror pays more, and the NatSpec's 'no faster than the same rate' states no bound on how far. Bound on the magnitude: the aggregate cap (mat x prior debt), which is far above the honest figure whenever the book is below par, which is the only time it matters. Preconditions: a book below par at the recovered price (a crash that leaves positions underwater in aggregate) and a recovery from a lower print; both are the crisis the accepted dip is also confined to, and honest borrowers topping up collateral during a crash mark their terms at the low exactly as the attacker does. Reachable with the constants as committed, wage 0, no governance. Call sequence from an external caller: X (any position above 200%, say 30,000 sIMD-worth against 2,500 of debt) lock(1) at the low; wait for the recovery and (over-read / 0.02) paced hours (the keeper paces hourly); any holder cash(amount, 0, candidate). Smallest fix that preserves the design: let anyone re-price a position's term at a fresh, agreeing price (a permissionless `resecure(address owner)` doing `_requireFreshFeeds(); _requirePriceAgreement(); _resecure(_positions[owner], _price());` about 120 bytes of initcode against a 2,165-byte margin), and have the keeper that paces hourly re-price every open position after each price update; a stale over-read is then corrected hours before the paced figure reaches it. Alternatively cap each debt-bound term at 2P in value at pacing time by storing the touch price with the term, which needs a per-position price word and a second aggregate.","line":331,"path":"src/CDPVault.sol","reproduction":"test/scratch/Lead_MirrorLift.t.sol (logs; passes as a lead, the assertion is the lift). ParameterizedVault over an 18-decimal MockIMD at $1 (Chainlink 2000e8 etched), NHI 0.85 (mat 170, gap 50), launch constants, Treasury holding 2,000 IMD. 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. IMD to $0.20: X lock(1) (its term is now 25,000 IMD = 2 x 2,500 / 0.2), Y and Z lock(1); two paced hours. IMD to $0.40. Control (snapshot): X, Y, Z lock(1) and 40 paced hours: backingPerUnit() == 861538461538461538, the honest (800 + 17,000 + 5,200 + 5,000) / 32,500. Attack branch: only Y and Z lock(1), X untouched; paced hourly: the paid figure reads 0.506 at hour 5, 0.606 at 10, 0.706 at 15, 0.806 at 20, 0.906 at 25 and 1.000 from hour 30 on (X's stale 25,000 IMD reads 10,000 of value against an honest 5,000, lifting the live figure to 33,000 / 32,500, par). HOLDER cash(1_000e18, 0, Z) at hour 40: EXPECTED at most the honest figure less the 1% fee, 1,000 x 0.8615 x 0.99 / 0.4 = about 2,132 IMD. ACTUAL 2475000000000000000000 raw IMD (par less the fee), +16%, the first 2,000 IMD of it out of the Treasury's reserve and the rest out of Z's collateral. The same sequence with X's term re-priced by a third party (the proposed fix) stops at 2,132.","severity":"medium","snippet":"    /// back at BACKING_RISE_PER_HOUR once positions are touched (the paced form of retry2 #6, accepted there as\n    /// cold for a few hours). The mirror, a term fixed at a crash low read at a recovered price (launch vault\n    /// panel 2026-10-08, low), can now lift the payout no faster than the same rate.","title":"CDPVault: a debt-bound term marked at a price low by the ungated lock is re-priced by nobody but its owner, so after a recovery the paced backing climbs past the honest figure to the stale over-read a"},{"citation":"resolved","description":"Q8. (1) ParameterizedVault 172: `CDPVault._mark` does not exist in this tree; the saturation serves `_pace` / `_liveBacking` (CDPVault 779-782). (2) CDPVault 308-311 and 871-875, ParameterizedVault 237-239 and 261-263: 'debt cancelled by a redemption or a liquidation and drawn again by someone else backs nothing until it has been held' and 'the paced debt never exceeds the debt this transaction began with less what it has cancelled' hold only when the draw precedes no cancellation in the same transaction; a draw followed by cash, bite or cover is never clamped (finding 1, line 742). (3) CDPVault 319, 'WHAT IT COSTS, all in the direction of paying less': the mirror three sentences later (331-333) pays more, and its only stated bound is the rate, not a magnitude (finding 2). (4) CDPVault 847-848 (WIPED_THIS_TX_SLOT, 'principal the caller repaid on its OWN position in this transaction') and 310-311 ('a position's own repayment and redraw in one transaction leaves it where it was'): the tally is per transaction, not per position, so a wipe by one contract and a draw by another inside one transaction also cancel out in `_pacedDebt` and `_clampPacedDebt`; harmless for the aggregate ceiling (the total is unchanged and the new debt is collateralised at mat), but not the property the comment states. (5) CDPVault 1069-1074 (`_feeBase`, 'paying about 250 imdUSD of fee if split into small burns (450 in one)'): the cost of pinning the cap is paid away only on the reserve route; a seasoned self-candidate keeps the fee in its own position (b952037a's note at 944-952 says so), so the cheapest pin costs twelve hours of 9,000 in the band and no fee. (6) CDPVault 331-332, 'accepted there as cold for a few hours': under the paced figures the underpayment after a fall lasts (honest - stale read) / 0.02 hours after every position is touched: 6 hours in the reproduction of finding 2 (0.385 read against 0.508 honest), 10 hours for a book of debt-bound positions after a 60% fall (read 0.8 against par), and until the owners act for positions nobody else can touch; the direction (underpays only) and the rate both hold, the duration is understated. (7) CDPVault 754, `backingPerUnit()` 'at the latest accepted price': the view reads `_price()` with no freshness check, so it quotes a stale but nonzero price; `cash` itself is gated. Everything else checked in the paced NatSpec holds: the rise cap per interval (test: a quiet day banks one interval), the hold on a diverged or stale price, the in-transaction exclusions (MINTED, SECURED, REPAID, WORK_MINTED), the fee base read once, the dip's bound (the book without the position), the dust and cover statements with the Treasury-imdUSD caveat, and the supply identity at line 132.","line":172,"path":"src/ParameterizedVault.sol","reproduction":"(1) grep -n '_mark' src/CDPVault.sol returns no definition; only `liquidationMarks` / `_clearMark`. (2) test/scratch/Proof_DrawThenCancel.t.sol. (3) test/scratch/Lead_MirrorLift.t.sol. (4) read `_pacedDebt` (819-825) and `_clampPacedDebt` (876-882): `live = totalDebt + _transient(WIPED_THIS_TX_SLOT) - minted` with no position key. (5) read `_redeemPosition` 944-952 against `_feeBase` 1069-1074. (6) test/scratch/Lead_MirrorLift.t.sol logs 'live at the low, all touched: 0.406' two paced hours after the fall against the honest 0.508. (7) read `backingPerUnit()` 758-760.","severity":"info","snippet":"        // Saturating, like the vault's backing it feeds: an absurd price must not revert lock or wipe (CDPVault._mark).","title":"Comments and NatSpec that claim properties the code does not have (paced figures first), and a dangling reference to CDPVault._mark"}],"hash":"a5f46838676da0b515513dc31e1d16ad1fb928a46685c4ef7116f9d43541b09c","nodeId":"377e5ba2-7d94-4ab5-8842-472164fbb59c","outcome":"completed","summary":"The cross-position wipe/draw case confirms the aggregate-only behaviour (information-level, already recorded), and the committed paced-figure suite passes here, so the environment matches the pinned commit. The findings file holds three entries with snippets verified against the tree, one carrying a Foundry proof. Nothing outside `.imd-findings.json` and `test/scratch/` was created.\n\n## Findings written to `.imd-findings.json`\n\n**1. Medium, `src/CDPVault.sol:742` (cash), with proof.** `_clampPacedDebt` runs only after `draw`. A draw followed by a cancellation in the same transaction (cash, bite or cover) leaves `_debtPaced` where the transaction found it, so the next transaction's `backedDebt` counts the zero-second debt that replaced the cancelled one in full. Proof `test/scratch/Proof_DrawThenCancel.t.sol`: a 99,500 book held a day; one contract locks 40,000, draws 20,000, redeems 20,000 against the book; next block `backedDebt` reads 99,512 where the cancel-then-draw control reads 79,545. Rated medium because the only consumer is the work ceiling and the wage is 0 at launch; once a wage is set it is the sweep-panel high reopened by ordering. Fix: call `_clampPacedDebt()` after the cancellation in cash, bite and cover.\n\n**2. Medium, `src/CDPVault.sol:331` (the paced NatSpec).** A debt-bound term marked at a low through the ungated `lock` is re-priced by nobody but its owner (cash reaches only candidates under 220%, bite only underwater positions, cover only dust or bad debt). After a recovery the paced backing climbs at the rise rate to the stale over-read, and nothing caps it at the honest figure. Reproduction `test/scratch/Lead_MirrorLift.t.sol`: crash to $0.20, the 1200% position touches, recovery to $0.40; honest 0.8615, the paid figure reaches par at hour 30, and a 1,000 imdUSD redemption pays 2,475 IMD against an honest 2,132, out of the reserve first and then a candidate. This is the one crisis-case that overpays, the direction the NatSpec says never occurs. Fix: a permissionless re-price at a fresh agreeing price, driven by the keeper that already paces hourly.\n\n**3. Info, `src/ParameterizedVault.sol:172`.** NatSpec claims without the property: the dangling `CDPVault._mark` reference; the clamp claims (finding 1); \"all in the direction of paying less\" (finding 2); the WIPED slot described as per-position when it is per-transaction (confirmed: A wipes 50k and B draws 50k in one call leaves `backedDebt` unchanged, harmless for the aggregate); the pin cost at `_feeBase` stated for the reserve route only; \"cold for a few hours\" understating the post-fall underpayment duration; `backingPerUnit()` reading an unchecked price.\n\n## Answers to the numbered questions\n\n**Q1 paced backing.** No sequence of lock, lockIMD, free, draw, wipe, cash, cover, bite, earn, pace, donation or feed update lifts a payout past the stored figure plus 2 points per paced hour: in-transaction the payout is min(live, paced at tx start) with the transaction's own capital excluded by the transient tallies, and across transactions each pacing adds at most one interval. The one overpayment is finding 2, which is a stale input to the live figure rather than a pacing gap. Costs to honest redeemers, all in points of par: the dip after a debt-bound exit, at most the book-without-the-position gap, recovering 2 points an hour; the stale read after a fall, (honest minus read)/0.02 hours after touching, 6 hours in the reproduction and 10 for a debt-bound book after a 60% fall; a quiet spell, 2 points total however long, since elapsed time caps at one interval and the keeper's hourly `pace()` is what keeps recovery flowing; a feed outage, zero rise banked because `_pacedAt` advances on every held pacing, plus any fall captured at once when the price returns.\n\n**Q2 paced supply and fee.** Within a transaction the fee base is exactly the stored paced supply (`_step` at zero elapsed is zero), so no ordering of draw, repayment, redemption or work mint moves it; acro","treeHash":null,"usage":{"cachedInputTokens":3189125,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":113646,"runtime":"claude","turns":45,"wallClockMs":1847535}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e06554fd2816f9d7","findings":[{"citation":"resolved","description":"The paced figures' NatSpec accepts the dip (a withdrawal paced in one transaction and reversed in the next) with three stated bounds: it exists only while the book is backed below par, it is at most the gap to the backing of the book without that position, and it recovers at BACKING_RISE_PER_HOUR. The first bound is wrong. _liveBacking reports par whenever secured >= supply, and _securedCollateralValue caps secured at mat x (totalDebt - bad) / 100, so a book whose par reading is carried by one large healthy position next to an underwater one reads AT PAR, yet the live backing of the book without that position is far below par. When that position repays its principal (wipe), its term in securedCollateral goes to zero (principal 0 => _secured returns 0) and the next transaction's _pace writes min(live, ...) = the rest of the book's backing. The borrower redraws a block later; the live figure is back at par but the paced figure climbs only 2 points of par an hour. In the reproduction a par book dips to 0.80 and a redemption three paced hours later is paid 13.9% less than par less fee. With the constants as committed (LINE $1M, mat 170 at NHI 0.85, wage 0, no work-minted supply) this needs only a borrower holding the imdUSD it drew, and it is repeatable every time the figure climbs back (10 paced hours from 0.80), for gas. It does not overpay anyone, but it removes the redemption floor from imdUSD exactly after a crash, when a healthy dominant borrower and an underwater tail coexist, and the NatSpec's reasoning ('on a par book the surplus above the aggregate cap absorbs any one position's exit') is false whenever the exiting position itself carries the cap: after it leaves, cap = mat x remaining debt, and the remaining held collateral (an underwater position's) is below it. Smallest fix: correct the stated bound and the acceptance to 'whenever the book WITHOUT the position is backed below par, which includes a par book with any underwater position', and state the dip's size as (secured_rest + reserve) / supply_rest; if that cost is not acceptable, the mitigation that keeps the no-overpay direction is to let a redeemer be paid against the lower of the paced figure and a floor that excludes positions whose owner repaid within the last PACE_INTERVAL (a 'cooling' exit, tallied per position), which is a design change the requester must decide.","line":323,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault with MockIMD collateral, test feeds at IMD/ETH = 1e18*1e18/2000e18 (IMD = $1 with a 2000e8 Chainlink answer), NHI 0.85 (mat 170, gap 50). BOOK: lock 199,000 IMD, draw 99,500 (200%). WHALE: lock 2,500,000 IMD, draw 500,000 (500%). Pace hourly for 24 hours. Set IMD to $0.40 (BOOK 80%, underwater; WHALE 200%, healthy). Each owner lock(1) to re-price its term; pace hourly 12 hours. Expected and actual: backingPerUnit() == 1e18 (held = 199,000 + 2,500,000 IMD = $1,079,600 >= cap 1.7 x 599,500 = $1,019,150 >= supply 599,500: par). Now WHALE wipe(500_000e18) in one block, then draw(500_000e18) in the next block, then one more block. Expected per the NatSpec ('on a par book ... absorbs any one position's exit'): backingPerUnit() == 1e18. Actual: backingPerUnit() = 801166056408026274 (0.80 = 79,600 / 99,500, the rest of the book). Then pace hourly for 3 hours and redeem: cash(1_000e18, 0, WHALE) at feeBps = redemptionFeeBps(1_000e18). Expected on a par book: 1,000 x (1 - fee) / 0.40 = 2,485.25 IMD. Actual: 2,140.05 IMD (13.9% less; the figure stood at 0.86). Measured with test/scratch/ParBookDip.t.sol on this commit (both assertions fail with those values). The figure climbs back at 0.02e18 per paced hour, so the underpayment lasts about 10 hours and the whale can repeat the wipe/redraw for gas as soon as it has climbed.","severity":"medium","snippet":"    /// next leaves the figure where the book stood without it, until it climbs back. That dip exists only while\n    /// the book is backed below par (on a par book the surplus above the aggregate cap absorbs any one\n    /// position's exit, `_securedCollateralValue`), is at most the gap between the book's backing and the","title":"Paced backing: the accepted dip's stated bound 'below par only' does not hold; a par book dips to the rest-of-book backing on one healthy position's wipe-and-redraw, underpaying every redemption for h"},{"citation":"resolved","description":"A debt-bound position's term in securedCollateral is min(collateral, 2 x principal / price) IMD at the price of its last checkpoint (_secured, _resecure). After a price fall the term is not revalued until the position is touched, and the only calls that touch a position are its owner's lock/lockIMD/free/draw/wipe, or cash (only an eligible candidate, i.e. one with CR below mat + gap, which is collateral-bound and has no stale term), bite (only an unhealthy position) and cover (only a drained one). A healthy, debt-bound position with an inactive owner therefore keeps a term worth 2p x P_new / P_old instead of 2p, the live figure reads low by that amount, and the paced backing (min(live, paced + rise)) cannot climb above it. The NatSpec and the retry2 acceptance say this is cold 'for a few hours'; there is no code that bounds it in hours, only owner action. Quantified: price falls by fraction f; every debt-bound term reads (1 - f) of its true value; with a share s of secured value in such untouched positions, the live figure and so the payout read at most (1 - f x s) of honest backing for as long as those owners are idle; e.g. f = 0.6, s = 0.5 reads 0.70 of honest, indefinitely. Smallest fix: a permissionless `touch(address owner)` (or let `pace()` take an owner) that calls _resecure(position, _price()) only at a fresh, agreed price; a rise it causes is already bounded by the aggregate cap and the paced rise, a fall is honest.","line":330,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault at IMD = $1 (IMD/ETH 1e18*1e18/2000e18, Chainlink 2000e8), NHI 0.85 (mat 170, gap 50). OWNER: lock 600,000 IMD, draw 100,000 (600%): term = min(600,000, 2 x 100,000 / 1) = 200,000 IMD. Pace hourly 24 hours: backingPerUnit() == 1e18. Set IMD to $0.40; OWNER never transacts again; OTHER lock(1e18) paces. Honest secured value at $0.40: min(600,000, 2 x 100,000 / 0.40 = 500,000) x 0.40 = $200,000 >= supply 100,000, so honest backing is par. Actual: securedCollateral stays 200,000 IMD = $80,000, _liveBacking = 80,000 / 100,000 and backingPerUnit() = 800000000000000000. Pace hourly for 48 more hours: still 800000000000000000 (measured, test/scratch/StaleTerm.t.sol). No external call can re-price OWNER's term: cash(…, OWNER) reverts IneligibleRedemptionPosition (collateralRatio 239 >= 220), bite reverts HealthyPosition, cover reverts NoRealizedBadDebt; only OWNER's own lock/free/draw/wipe does, after which the figure climbs at 2 points an hour. Expected per the NatSpec: cold for a few hours. Actual: cold for as long as OWNER is idle.","severity":"low","snippet":"    /// figure reads low until then and the first pacing after the fall captures that read; the payout climbs\n    /// back at BACKING_RISE_PER_HOUR once positions are touched (the paced form of retry2 #6, accepted there as\n    /// cold for a few hours). The mirror, a term fixed at a crash low read at a recovered price (launch vault","title":"Stale-term read after a price fall: the stated recovery ('cold for a few hours, climbs back once positions are touched') has no bound, because nothing permissionless re-prices an absent owner's debt-b"},{"citation":"resolved","description":"When _priceAgrees() is false, _pacedBacking(0, elapsed) returns the held value, but _pacedAt is still set to now, so the elapsed hours are consumed with no rise. The paced figures' NatSpec says a recovery is reached 'at BACKING_RISE_PER_HOUR ... hourly pacing recovers in full; a quiet gap recovers one interval', and that during a halt the figure 'holds'. It holds, and it also forgets the time. Cost, as asked in the brief: a feed outage or divergence halt of H hours during which any lock, lockIMD, wipe, debt-free free or pace() lands (all ungated, and pace() is permissionless) delays a pending recovery by the full H hours; with no call at all during the halt, one interval (2 points) is banked, as for any quiet gap. So anyone can cost redeemers up to 2 points of a pending recovery per halt by calling pace() once during it. Not a defect in the direction the design guards (it only underpays), and test_withNoReadablePriceTheMarkDoesNotRise asserts the behaviour, so this is a documentation note: the sentence 'hourly pacing recovers in full' should read 'hourly pacing at an agreed price recovers in full; hours paced without one are lost'. If the lost interval matters, keep a separate timestamp for the backing and write it only when the backing is written.","line":868,"path":"src/CDPVault.sol","reproduction":"Book below par paced at 0.90 with the live figure recovered to 1.00. Chainlink ETH/USD stops answering for 6 hours; during that time BOOK calls lock(1) once an hour (ungated). Expected per 'hourly pacing recovers in full': after the leg returns, 6 hours have elapsed and one interval is banked at least (0.92). Actual: each lock paced with price 0 and set _pacedAt, so the first agreed pacing after recovery finds elapsed of a few seconds and writes 0.90 + 0.02 x seconds / 3600; the climb to 1.00 begins only now and takes 5 more paced hours.","severity":"info","snippet":"        _pacedAt = uint64(block.timestamp);","title":"_pace advances _pacedAt when the backing is held for a stale or diverged price, so time paced without an agreed price is never credited toward the rise; 'hourly pacing recovers in full' holds only at "},{"citation":"resolved","description":"The comment justifying the saturating arithmetic refers to a function from the per-position lag design (`_mark`) that no longer exists in CDPVault; the caller that must not revert inside lock and wipe is now `_pace` through `_pacedBacking` and `_liveBacking`. The property claimed (no revert) does hold: Math.tryMul / Math.tryAdd saturate, Treasury.reserveValueUsd and reserveValueOf are bounded staticcalls, and every feed read on the pacing path (SwarmFeed, UsdPriceFeed, SharePriceFeed) returns zero rather than reverting. Documentation only.","line":172,"path":"src/ParameterizedVault.sol","reproduction":"grep -n '_mark' src/CDPVault.sol returns nothing; the function named by the comment at src/ParameterizedVault.sol:172 does not exist in the tree at this commit.","severity":"info","snippet":"        // Saturating, like the vault's backing it feeds: an absurd price must not revert lock or wipe (CDPVault._mark).","title":"Stale cross-reference: ParameterizedVault._redemptionReserveBacking cites CDPVault._mark, which this commit removed; the function it means is CDPVault._pace"}],"hash":"cd982465095437380855d6dc38771410b2a93ab86b2a01dda0a89c756963d6bc","nodeId":"f58c4467-701d-4b52-863b-57a1af7dddb8","outcome":"completed","summary":"The findings file is written and validated. Here is the report.\n\n## Outcome\n\n`.imd-findings.json` holds four findings: one medium, one low, two info. The paced figures are sound in the direction they guard. I found no sequence of lock, lockIMD, free, draw, wipe, cash, cover, bite, earn, pace, Treasury donation or feed update that pays a redemption more than the honest backing plus the rise the elapsed time allows, and no way to move the fee base or the work ceiling's debt faster than the follow rate. What I found is in the accepted statements: one stated bound is false, one stated recovery has no bound, and one claim about pacing cadence holds only at agreed prices.\n\n**Read in full:** CDPVault.sol, ParameterizedVault.sol, ImdUSD.sol, Treasury.sol, SharePriceFeed.sol, UsdPriceFeed.sol, SpotFeed.sol, DeploymentConfig.sol, PacedFigures.t.sol. Read in part: Parameters.sol (bounds and getters), SwarmFeed.sol (latestValue, isStale). Not reached: OracleAsker, SwarmRelay, SwarmWorkOracle, Registry.\n\n## Findings\n\n1. **Medium, CDPVault.sol:323, the dip's \"below par only\" bound.** A book that reads at par because one large healthy position carries the aggregate cap next to an underwater one dips to the rest-of-book backing when that position wipes and redraws across two blocks. Measured: par to 0.80, and a redemption three paced hours later paid 2140 IMD where par less fee pays 2485. Repeatable for gas every ten hours. It only underpays, but it removes the redemption floor exactly after a crash. Fix: correct the bound and acceptance, or a design decision on a cooling-exit floor.\n\n2. **Low, CDPVault.sol:330, the stale-term read.** No permissionless call re-prices an idle owner's debt-bound term. A 600% position after a 60% fall holds the figure at 0.80 for 48 paced hours and beyond, since cash refuses it at 239%, bite at healthy, cover without bad debt. Fix: a permissionless touch at a fresh agreed price.\n\n3. **Info, CDPVault.sol:868.** Pacing at a stale or diverged price advances the clock, so those hours never count toward the rise. One pace() call during a halt costs redeemers up to one interval of a pending recovery.\n\n4. **Info, ParameterizedVault.sol:172.** A comment cites CDPVault._mark, which this commit removed.\n\n## Answers where nothing is wrong\n\n- **Paced backing.** Within a transaction the payout is min(live, paced) with zero elapsed, and the live figure adds back same-transaction repayments and excludes same-transaction collateral and principal. Across transactions the rise is capped per pacing, so same-block pacing cannot bank twice. Donations, band draws, candidate lifts and feed rises all reach redeemers at 2 points an hour. A quiet day banks one interval. Honest redeemers are paid under live by the dip (up to the rest-of-book gap, about 20 points in the reproduction, for 10 hours), by stale terms (unbounded, finding 2), and by outages (the halt's length plus the climb).\n- **Paced supply and fee.** Inside a transaction the fee base is exactly the stored paced supply, so no ordering of draw, wipe, cash or earn moves it. Across transactions it moves 10% of max(paced, 100k) per hour. Cheapest cap pin now: 9,000 imdUSD of seasoned debt redeemed against the redeemer's own position, fee retained there, as the b952037a acceptance states. Launch-day redeemer with live supply 500k and paced supply still near the floor pays 300 bps on a 5k burn where the live base would charge 100 bps. The paced supply reaches 500k only after about 27 paced hours.\n- **Paced debt and work ceiling.** Every cancel-then-redraw sequence I traced, same transaction or adjacent, is caught by the clamp or by the next pacing. Own wipe and redraw cancels out. The aggregate cost once the wage is on: 100k an hour of new debt counts at a $1M book, so capital held two hours backs a quarter of itself in work minting and may leave the next transaction.\n- **Pricing and reverts.** Every read on the pacing path returns zero rather than reverting, so lock and wipe cannot","treeHash":null,"usage":{"cachedInputTokens":2559954,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":64516,"runtime":"claude","turns":33,"wallClockMs":2386125}}],"verification":[]}