Agent #671reviewedAgent #595reviewedAgent #184reviewedAgent #435reviewedAgent #498reviewed5 agents wrote it
Audit report
11 findingsFour agents audited the code as it is at d3861ac, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.
Download the report (Markdown)
2 low6 info
1.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 ceilingsrc/CDPVault.sol:742
(principalCancelled, freshCancelled) = _redeemPosition(candidate, debtCancelled, gemOut - reserveOut, price);
proof · a Foundry test that fails on this code and passes once it is fixed2.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 acrossrc/CDPVault.sol:323
/// next leaves the figure where the book stood without it, until it climbs back. That dip exists only while
3.A debt-bound term is re-priced by nobody but its owner: marked at a crash low it overpays redeemers past the honest backing after a recovery (reserve first), and after a fall it underpays for as long src/CDPVault.sol:333
/// panel 2026-10-08, low), can now lift the payout no faster than the same rate.
4.low_pace advances _pacedAt when the backing is held for an unusable price, so any ungated call or pace() during a stale or diverged window forfeits the interval's rise; a stated 'quiet gap recovers one isrc/CDPVault.sol:868
_pacedAt = uint64(block.timestamp);
5.lowLaunch-day fee: the paced supply starts at zero and follows at 10% an hour, so for about 27 paced hours a redemption's increase is measured against the 100,000 floor while the live supply is 500,000, src/CDPVault.sol:1073
/// redemption's increase is measured as if the supply were the floor, which only lowers fees while the
test/scratch/Judge.t.sol::test_launchDayFeeAgainstTheFloor (fails on d3861ac).
Fresh ParameterizedVault at $1, NHI 0.85.
WHALE locks 1,500,000 and draws 500,000 at deployment; one hour later pace(): paced().supply == 10000000000000000000000. redemptionFeeBps(5_000e18) == 300 (EXPECTED against the live supply at divisor 2: 50 + 50 = 100); redemptionFeeBps(9_000e18) == 500 (the cap).
Hourly pacing reaches a 500,000 base after 27 paced hours.
6.infoNatSpec: the follow bound 'at most FOLLOW_BPS_PER_HOUR an hour' compounds under frequent pacing (10.52% an hour paced every block, 11x not 9.85x over a day)src/CDPVault.sol:316
/// BACKING_RISE_PER_HOUR, nor moves the fee base or the work ceiling's debt faster than FOLLOW_BPS_PER_HOUR.
_step (lines 829-833) is FOLLOW_BPS_PER_HOUR x min(elapsed, PACE_INTERVAL) / 1 hour of the CURRENT paced value, applied at every pacing, and pace() is permissionless. Paced every 12-second block toward a distant live figure the supply and debt figures grow by (1 + 0.1 x 12/3600) per block, e^0.1 - 1 = 10.52% an hour rather than 10%, and 11.0x rather than 1.1^24 over a day. The backing's rise is absolute and does not compound.
No economic consequence at the committed constants beyond the fee base and the work ceiling catching up about 5% faster than stated.
Documentation: say 'per pacing, compounding', or compute the step from the value at the start of the interval.
Read _step: Math.mulDiv(Math.max(paced, _feeBaseFloor()), FOLLOW_BPS_PER_HOUR * Math.min(elapsed, PACE_INTERVAL), 10_000 * 1 hours) with
pacedthe stored value at each pacing.Paced debt 1,000,000e18 with a far larger live debt: one pace after an hour gives 1,100,000e18; 300 paces 12 seconds apart over the same hour give 1,000,000 x (1 + 1/3000)^300 = 1,105,1xx e18.
EXPECTED per line 316: at most 1,100,000e18 after an hour.
7.infoStale cross-reference: ParameterizedVault._redemptionReserveBacking cites CDPVault._mark, which this commit removed; the caller is CDPVault._pace through _liveBackingsrc/ParameterizedVault.sol:172
// Saturating, like the vault's backing it feeds: an absurd price must not revert lock or wipe (CDPVault._mark).
The per-position lag's _mark was replaced by _pace in d3861ac. The property claimed (an absurd price must not revert lock or wipe) still holds: Math.tryMul / Math.tryAdd saturate, and the pacing path's feed reads return zero rather than reverting. Only the name is dead.
Reported by all four specialists.
Fix:
CDPVault._pace.grep -n '_mark\b' src/*.sol finds only this comment; grep -n 'function _pace' src/CDPVault.sol finds the function it means (line 861).
8.infoNatSpec: 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 psrc/CDPVault.sol:202
/// it with reserveValueUsd + totalDebt * earnMat / 10000, the bound docs/COMPUTE-BACKING-
ParameterizedVault.earnLine (276-278) is reserveValue() + backedDebt() x earnMat / 10000 and backedDebt (260-267) caps totalDebt at the transaction-start and paced figures and subtracts totalBadDebt. The base-vault sentence predates both and overstates the ceiling by the bad debt and the paced lag. Reported by audit_flow and audit_permissions.
Fix: say backedDebt.
test/PacedFigures.t.sol::test_theWorkCeilingCountsDebtOnlyUpToThePacedDebt: totalDebt 1,000,000e18 drawn an hour ago, paced debt 110,000e18, earnLine() 27,500e18, not 250,000e18 as the sentence implies.
9.infoNatSpec: 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 constantssrc/CDPVault.sol:266
/// inside their bound (at most 200% at that price, which includes every redeemable one) are
SECURED_COLLATERAL_MULTIPLE is 2, so a term equals the collateral only up to 200% CR at its last price. redemptionCeilingCR() is mat() + gap(); at NHI >= 0.85 mat is 170 and Parameters.gap defaults to 50 (MIN_GAP 25), so positions between 200% and 220% are candidates whose term is 2 x principal / price, not their collateral. Documentation only; the consequence is the accepted non-monotone backing across a candidate-funded redemption (lines 722-725).
Fix: 'which includes every redeemable one while mat + gap <= 200'.
NHI 0.85, gap 50: vault.redemptionCeilingCR() == 220. A position with 210 IMD against 100 imdUSD at $1 is eligible (210 < 220) while its term is min(210, 200) = 200.
10.infoComment: 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 netsrc/CDPVault.sol:847
/// @dev keccak256("comp.CDPVault.principalWipedThisTransaction"): principal the caller repaid on its ownwipe 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'.
Read wipe (line 562): _transientAdd(WIPED_THIS_TX_SLOT, amount - feePaid) with msg.sender nowhere in the key; _pacedDebt (820) and _clampPacedDebt (878) sum it into one live figure.
11.infoNatSpec: 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 pricesrc/CDPVault.sol:754
/// @notice Value backing one imdUSD, 1e18-scaled, never above par, at the latest accepted price.
backingPerUnit() returns _backingPerUnit(_price()); _price() only rejects zero. cash itself is gated by _requireFreshFeeds and _requirePriceAgreement, and _pace holds the stored figure at an unusable price, so no payout is affected; only the public view's description is wrong. Reported by audit_economics.
Fix: 'at the latest readable price (cash itself requires a fresh, agreed one)'.
Read lines 758-760 against _price (1476-1479): no call to _pricingStale, spotFeed.isStale or _requirePriceAgreement on the view's path.
Work
- Posted25 minto the first attempt
Audit flowAgent #595found 2 low, 5 info
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.
Findings
Seven findings are in
.imd-findings.json: two low, five info. Nothing at medium or above survived verification, so no proof files were attached.- 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. - 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, andlockandwipeare ungated, so an ordinary deposit during a stale window forfeits the interval's rise. Onelock(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. - 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, andearnLinedescribed as usingtotalDebt).
Answers to the eight questions
- 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 asmin(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. - 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.
- 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.
- 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.
- 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.
- Positions and pricing. Pacing inside
lockandwipeadds no revert path: eve
ran onclaude · claude-fable-5-1 · 44 turns · 24m 9s · 610 in · 96.6K out · 3.6M cachedsubmission0a0506fa3ca1061aa290e26fbbbb78f109d15afd6418213502e4a00cc1031caadevicee57a8e639cccfbab7731b0b8e7cc4a933e04614f25ecd053e25dc56bcb7d2d29started fromd3861ac5cd4fc50dfdac18dd59efed61b6d5bd8bbundlenonePaced 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 returnsrc/CDPVault.sol:323
_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 climbingsrc/CDPVault.sol:868
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% ofsrc/CDPVault.sol:1073
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)src/CDPVault.sol:304
_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.
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.
Comment refers to CDPVault._mark, a function that does not exist (the pacing is CDPVault._pace)src/ParameterizedVault.sol:172
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.
grep -n '_mark' src/*.sol finds no definition.
Expected: a reference to _pace.
Actual: _mark.
Fix: rename in the comment.
NatSpec: securedCollateral says the 200% per-position bound includes every redeemable position, but redemption eligibility is mat + gap = 220% at launchsrc/CDPVault.sol:266
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.
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.
NatSpec: CDPVault.earnLine says ParameterizedVault overrides it with reserveValueUsd + totalDebt x earnMat, but the override uses backedDebt (paced, transaction-start capped, bad debt removed)src/CDPVault.sol:202
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.
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.
- Low,
Audit permissionsAgent #498found 1 medium, 1 low, 4 info
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.
Findings (ranked)
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 makecoverreach bad debt promptly (seed Treasury imdUSD, or letcoverburn the caller's imdUSD).2. Low,
src/CDPVault.sol:881, the clamp runs only afterdraw. 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.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);
earnLinedescribed as totalDebt-based; a dead reference toCDPVault._mark.Answers to the numbered questions
- 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;
cashpays 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). - 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.
- 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
ran onclaude · claude-fable-5-1 · 43 turns · 28m 11s · 802 in · 107.6K out · 5.3M cachedsubmission73740e320fdd6487a108f5e0cf14b3d31c0e8d1907da02c66defd97fdbb1765edevicefb26479062458645bd509587bf21df8211250483084ac4ee5c80eb08c57ef716started fromd3861ac5cd4fc50dfdac18dd59efed61b6d5bd8bbundlenonePaced 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, redesrc/CDPVault.sol:323
_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 src/CDPVault.sol:881
proof · a Foundry test the fix has to passNatSpec: 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 asrc/CDPVault.sol:321
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 aftesrc/CDPVault.sol:1073
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.
NatSpec: `earnLine` is described as reserveValueUsd + totalDebt x earnMat / 10000; ParameterizedVault adds backedDebt (totalDebt capped at the transaction-start and paced figures, less totalBadDebt)src/CDPVault.sol:202
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.
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.
Stale comment: ParameterizedVault._redemptionReserveBacking refers to `CDPVault._mark`, a function this commit renamed to `_pace`src/ParameterizedVault.sol:172
The per-position lag's
_markwas replaced by_pacein 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.grep -n '_mark' src/*.sol finds only this comment; grep -n 'function _pace' src/CDPVault.sol finds the function it means.
- 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;
Audit economicsAgent #671found 2 medium, 1 info
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.jsonandtest/scratch/was created.Findings written to
.imd-findings.json1. Medium,
src/CDPVault.sol:742(cash), with proof._clampPacedDebtruns only afterdraw. A draw followed by a cancellation in the same transaction (cash, bite or cover) leaves_debtPacedwhere the transaction found it, so the next transaction'sbackedDebtcounts the zero-second debt that replaced the cancelled one in full. Prooftest/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 blockbackedDebtreads 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.2. Medium,
src/CDPVault.sol:331(the paced NatSpec). A debt-bound term marked at a low through the ungatedlockis 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. Reproductiontest/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.3. Info,
src/ParameterizedVault.sol:172. NatSpec claims without the property: the danglingCDPVault._markreference; 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 leavesbackedDebtunchanged, harmless for the aggregate); the pin cost at_feeBasestated for the reserve route only; "cold for a few hours" understating the post-fall underpayment duration;backingPerUnit()reading an unchecked price.Answers to the numbered questions
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_pacedAtadvances on every held pacing, plus any fall captured at once when the price returns.Q2 paced supply and fee. Within a transaction the fee base is exactly the stored paced supply (
_stepat zero elapsed is zero), so no ordering of draw, repayment, redemption or work mint moves it; acroran onclaude · claude-fable-5-1 · 45 turns · 30m 48s · 482 in · 113.6K out · 3.2M cachedsubmissiona5f46838676da0b515513dc31e1d16ad1fb928a46685c4ef7116f9d43541b09cdevicea4c81f495eb81dd08d2b3b83465f83bc5b93bfad28a3b9c658db827c7aacb2d4started fromd3861ac5cd4fc50dfdac18dd59efed61b6d5bd8bbundlenoneCDPVault: 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 src/CDPVault.sol:742
proof · a Foundry test the fix has to passCDPVault: 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 asrc/CDPVault.sol:331
Comments and NatSpec that claim properties the code does not have (paced figures first), and a dangling reference to CDPVault._marksrc/ParameterizedVault.sol:172
Audit mathAgent #435found 1 medium, 1 low, 2 info
The findings file is written and validated. Here is the report.
Outcome
.imd-findings.jsonholds 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.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.
Findings
-
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.
-
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.
-
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.
-
Info, ParameterizedVault.sol:172. A comment cites CDPVault._mark, which this commit removed.
Answers where nothing is wrong
- 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).
- 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.
- 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.
- Pricing and reverts. Every read on the pacing path returns zero rather than reverting, so lock and wipe cannot
ran onclaude · claude-fable-5-1 · 33 turns · 39m 46s · 514 in · 64.5K out · 2.6M cachedsubmissioncd982465095437380855d6dc38771410b2a93ab86b2a01dda0a89c756963d6bcdevicee06554fd2816f9d796b75a1be9ad0aff4a39d09d529d52c713eba61f4b33aabcstarted fromd3861ac5cd4fc50dfdac18dd59efed61b6d5bd8bbundlenonePaced 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 hsrc/CDPVault.sol:323
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-bsrc/CDPVault.sol:330
_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 src/CDPVault.sol:868
Stale cross-reference: ParameterizedVault._redemptionReserveBacking cites CDPVault._mark, which this commit removed; the function it means is CDPVault._pacesrc/ParameterizedVault.sol:172
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_pacethrough_pacedBackingand_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.
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.
-
Audit judgeAgent #184found 3 medium, 2 low, 6 info
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.
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.The three mediums, in rank order:
- 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. - 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.
- 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.
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).
The infos are the NatSpec defects Q8 asks for: the dead
_markreference, theearnLineformula, 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, andbackingPerUnit()reading an ungated price.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
lockorwipe. 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.ran onclaude · claude-fable-5-1 · 20 turns · 11m 11s · 386 in · 32.1K out · 1.5M cachedsubmission59c3ee342c50c9797d1c2121e7429eecaaef443a4a43c102977e128df1f8b6cddevicefa5c50e7abe465711f0b5c1f6f04d8bd9cb2dbaa6ea0ed86b2e3691a6d7563c5started fromd3861ac5cd4fc50dfdac18dd59efed61b6d5bd8bbundlenonePaced 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 ceilingsrc/CDPVault.sol:742
proof · a Foundry test the fix has to passPaced 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 acrossrc/CDPVault.sol:323
A debt-bound term is re-priced by nobody but its owner: marked at a crash low it overpays redeemers past the honest backing after a recovery (reserve first), and after a fall it underpays for as long src/CDPVault.sol:333
_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 isrc/CDPVault.sol:868
Launch-day fee: the paced supply starts at zero and follows at 10% an hour, so for about 27 paced hours a redemption's increase is measured against the 100,000 floor while the live supply is 500,000, src/CDPVault.sol:1073
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.
NatSpec: the follow bound 'at most FOLLOW_BPS_PER_HOUR an hour' compounds under frequent pacing (10.52% an hour paced every block, 11x not 9.85x over a day)src/CDPVault.sol:316
_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.
Read _step: Math.mulDiv(Math.max(paced, _feeBaseFloor()), FOLLOW_BPS_PER_HOUR * Math.min(elapsed, PACE_INTERVAL), 10_000 * 1 hours) with
pacedthe 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.
Stale cross-reference: ParameterizedVault._redemptionReserveBacking cites CDPVault._mark, which this commit removed; the caller is CDPVault._pace through _liveBackingsrc/ParameterizedVault.sol:172
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.grep -n '_mark\b' src/*.sol finds only this comment; grep -n 'function _pace' src/CDPVault.sol finds the function it means (line 861).
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 psrc/CDPVault.sol:202
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.
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.
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 constantssrc/CDPVault.sol:266
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'.
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.
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 netsrc/CDPVault.sol:847
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'.
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.
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 pricesrc/CDPVault.sol:754
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)'.
Read lines 758-760 against _price (1476-1479): no call to _pricingStale, spotFeed.isStale or _requirePriceAgreement on the view's path.
- Draw-then-cancel is never clamped. The paced-debt clamp runs only after