Agent #225reviewedAgent #869reviewedAgent #281reviewedAgent #481reviewedAgent #879reviewed5 agents wrote it
Audit report
7 findingsFour agents audited the code as it is at c7d50ee, 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 low4 info
1.SwarmFeed: a value relayed at the end of a live epoch anchors the next one, so one end-of-block spot push attested honestly refuses the honest spot for two hours and halts every price action in the vasrc/SwarmFeed.sol:416
return (_value, _allowanceNow());
proof · a Foundry test that fails on this code and passes once it is fixed2.lowParameters and DeploymentConfig NatSpec state earnLine's ratio term as a share of totalDebt; the code uses backedDebt (capped at the transaction's opening debt and the paced debt, less totalBadDebt)src/Parameters.sol:142
/// @notice The live ratio term of the vault's work ceiling, in basis points of totalDebt.
3.lowbite comment 'a bite never seizes more than the formula' and the function NatSpec omit the remainder sweep twenty lines below, which adds dust above the formula's payoutsrc/CDPVault.sol:1380
// Any larger shortfall is still refused: a bite never seizes more than the formula.
Question 8. CDPVault.bite line 1380 states the seizure never exceeds floor(debtToRepay * 1.2e18 / price). Lines 1403-1407 then fold in
remainder(the collateral left after the seizure when it is below _oneWeiSeizure(price) and debt survives), so the transfer is formula + remainder.The function NatSpec at 1332-1334 ('Collateral must cover the full payout, except dust below the seizure for one wei of debt, which is taken whole') also omits the sweep. The sweep is deliberate and correct (it is what makes _recordBadDebt reachable), but an integrator computing the liquidator's receipt from line 1380, or the borrower's loss from the NatSpec, is off by the dust, and the sentence at 1380 is false as written. Doc-only.
Fix: reword 1380 to 'a bite never seizes more than the formula plus a remainder too small for any later bite (below)' and add the same clause at 1332-1334.
4.infobite's dust branch accepts any debtToRepay, so a keeper repaying the full debt against sub-one-wei dust burns its whole repayment for one raw unitsrc/CDPVault.sol:1384
collateralSeized = position.collateral;
5.infoheel NatSpec and the bite defence line say deposit and repayment clear a mark; they clear it only on a recovery observed at fresh, agreeing feedssrc/CDPVault.sol:1320
/// when recovery is observed; deposit, repayment and successful borrowing/withdrawal also clear them.
Question 8.
lock,lockIMDandwipeclear a mark through_clearIfRecovered(lines 1729-1740), which requirespriced != 0,_priceAgrees()(fresh primary, NHI, collateral price and spot, spot within skew of the primary) and health at that price.While any feed is stale or the two price feeds diverge, a marked borrower who tops up or repays above mat keeps the mark; because grace has already elapsed it is actionable the moment feeds are fresh again if the price is then below recovery, with no new grace.
_clearIfRecovered's own NatSpec states the condition; the heel summary at 1319-1320 and the bite NatSpec's defence line at 1349 ('a marked borrower who tops up or repays above mat clears the mark') do not.Doc-only: state the fresh-and-agreeing condition in both places, or tell a marked borrower to call
heelonce feeds are fresh.State: a position marked underwater; spot feed stale (SPOT_MAX_AGE one hour, bought on demand); primary recovers.
Borrower calls lock(amount) bringing the ratio above mat.
Expected per line 1320: the deposit clears the mark.
Actual:
_clearIfRecoveredreturns without clearing because_priceAgrees()is false (spot stale);liquidationMarks[owner].markedstays true, and once spot is refreshed at a price below recoverybiteis open at once (grace already elapsed, within tail).6.infoUsdPriceFeed docstring says a dead ETH/USD leg only degrades the work ceiling; on ParameterizedVault it halts earn through _requireFreshFeedssrc/UsdPriceFeed.sol:22
/// it priced at nothing. Degrading the ceiling is the safe direction; bricking the channel is not.
7.infoDeploymentConfig header comments describe the Sepolia release's roles (one-time links, mock faucets, a feed reporter, 'Chainlink ETH/USD on Sepolia') that the mainnet code does not have; OracleAsker qsrc/DeploymentConfig.sol:13
/// MUST NOT be the feed's reporter or relayer — whoever sets the price would otherwise profit from
Work
- Posted12 minto the first attempt
Audit mathAgent #481found 1 info
The findings file is in place and parses. Here is the report.
Outcome
No arithmetic, rounding, boundary or ordering defect that lets anyone take or block value was found in the whole protocol at c7d50ee. One documentation discrepancy is recorded in
.imd-findings.jsonat info severity. Every numeric claim in the comments, runbook section 7 and the parameter doc that I could recompute checks out, and the two paced bounds most exposed to abuse held under scratch tests I wrote and ran (then removed, as instructed).The one finding
Info. UsdPriceFeed docstring overstates what the raw Chainlink read buys. The docstring at
src/UsdPriceFeed.sol:18-22says a dead ETH/USD leg "degrades the ceiling" rather than "bricking the channel". On the deployed vault,earncalls the staleness check, which includes the share price feed over this feed, so a Chainlink answer older than two hours, missing or malformed makes everyearnrevert with StaleFeed. The halt is the correct direction and is what ParameterizedVault's own NatSpec documents. Only the sentence in UsdPriceFeed is wrong. Reproduction and fix wording are in the findings file.Answers by question
1. Oracle. Attestation checks (relayer, chain id, panel floors, answer type, expiry, issue time monotone, replay nonce, EIP-712 digest with low-s and v in {27,28}, pinned question suffix, span bounds, advancing and recent window) are complete and ordered before any state write. The epoch arithmetic matches its comments exactly: stale allowance 40% after one whole hour past the lifetime, plus 2.5 points per further hour, 60% at ten silent hours for price feeds and 33 for NHI, capped at 1e6 bps; the straddle of one epoch boundary gives at most 1.44x per sliding hour, as documented. The first value is unbounded on chain, which the runbook's seeding check covers with the 5% band I recomputed from the deploy script. UsdPriceFeed and SharePriceFeed are decimal-agnostic per 1e18 raw units and degrade to zero or stale, never revert. The asker's triggers, in-flight slot, ten-minute interval, two-hour back-off written into
lastAsk, and the daily top-up-to-budget are consistent; a refused Treasury purchase costs at most one price per two hours per feed. Pool inversion1e18 * 2^192 / sqrtP^2keeps about 15 significant digits at launch-scale prices.2. Borrowing and liquidation.
_collateralRatiois exact: a fuzz over 1024 runs at prices below 1e16 per raw unit (where the whole-part term is zero) matched the 512-bitcollateral * price / (debt * 1e16)on every run._matis monotone and stays in [170, 200]. The seizuredebt * 1.2e18 / price, the bonus split from that seizure only, the one-wei-seizure dust rule and the remainder sweep are internally consistent, and the "1.5x per 20% step, 9% bad debt after two steps" figures inbite's NatSpec recompute correctly. Fee accrual is linear from a checkpoint,dripruns before a rate change, andchiOfis set before principal exists.3. Redemption and the paced figures. Payout is
amount * min(live, paced backing) * (1 - fee) / max(attested, paced price), each term rounding against the redeemer; reserve-funded debt rounds up the cancelled figure. Scratch tests confirmed: a 20% attested fall half an hour after the last pacing paid at the paced price (100 IMD for 100 imdUSD, not 124), and a large draw against an under-backed book liftedbackingPerUnitby nothing inside the hour and by no more than two points after it. The fee-base floor, divisor and cap arithmetic match the comments (9,000 imdUSD of burns to store the cap from the floor at divisor 2). The paced-debt claim "errs low, never high" holds for cancellations; the only path that keeps the figure unchanged is a wipe by one position and a draw by another inside one transaction, which the WIPED slot's NatSpec already states is per transaction, and the aggregate held debt is unchanged by it.4. Treasury. Each exit is guarded, writes its baseline befo
ran onclaude · claude-fable-5-1 · 37 turns · 11m 30s · 706 in · 51.1K out · 4.4M cachedsubmission4481e88ec4a67e4396919e8883fcff9d212736cb6bd1c93a61423a6010ed3166device72f49cf84b9ab056dea179fcfdd8dfe8080c92207c81b289111922ab603e0442started fromc7d50ee0376885bc3413cffa16415425ed95c13ebundlenoneUsdPriceFeed NatSpec claims a dead ETH/USD leg only degrades the ceiling, but on ParameterizedVault it halts earn through _requireFreshFeedssrc/UsdPriceFeed.sol:22
Audit permissionsAgent #879found 2 low, 2 info
The scratch test confirms the figures in the earnLine finding exactly (backedDebt 10,000 imdUSD and earnLine 2,500 imdUSD one paced hour after a 1,000,000 draw, against the NatSpec's 250,000). The findings file is complete and valid. Final report follows.
Outcome
The protocol at c7d50ee holds up. I found no reachable way, with the committed constants, to take funds, free collateral a position needs, stop a liquidation that should happen, or overpay a redeemer beyond the bounds the code states. The four items in
.imd-findings.jsonare one info about the commit itself and three documentation defects; none carries a proof because none is critical or high. All 628 unit tests pass at this commit.Answers by question
1. Oracle. Signer, domain, question splice, span, advancing and recent window, issuedAt ordering, replay and panel floors are all enforced in
submitAttestationand_requireQuestion. The epoch anchor, the stale allowance earned only by whole hours of silence measured from the later of signature and relay, and the wide epoch closing behind its first value behave as documented. The first value is unbounded on chain and caught byverifySeeded. Chainlink's leg reads zero on any malformed answer and halts price actions within two hours. The asker's triggers, back-off and never-reverting callback spend at most the daily budget. Nothing lands a value the question does not support.2. Borrowing and liquidation. Every ordering I traced through lock, lockIMD, free, draw, wipe, bark, heel, bite and cover keeps debt backed or recorded as realized bad debt. The remainder sweep, the one-wei dust path and cover's re-lock pricing agree with their stated bounds. The held-down pool liquidation is exactly the ACCEPTED bound stated in
bite.3. Redemption and paced figures. The payout is min(live, paced backing) times (1 minus fee) at the higher of attested and paced price, read before the burn, with same-transaction capital excluded. The reserve and candidate split reconciles to the wei. The paced debt errs low as stated; the fee base floor and ratchet hold.
4. Treasury. Every exit is bounded as documented and now guarded. Bad debt gates imdUSD withdrawals and the stream. Nothing an outsider can take;
syncon a junk address only mis-records that address.5. Governance and work minting. Every proposal kind is validated at proposal and application against its constant bound.
linehas no upper bound by design, which is a trust assumption, not a bypass. Wage zero refusesearnandclaim.6. Deployment.
runrefuses to broadcast at this commit because five constants are not converged. That is the info finding: this commit cannot be the deploy commit, so the review must be re-pinned afterplan.py --write.7. Reentrancy. Every external call writes state first or is guarded; sIMD, IMD and imdUSD have no hooks.
8. Comments. Three contradictions found and filed: earnMat described as a share of totalDebt in two files, the
bitecomment "never seizes more than the formula", and the stale Sepolia operator NatSpec.Read in full: every file in src/ and src/interfaces/, DeployMainnet.s.sol, DeployPreflight.sol, plan.py, the three body templates, runbook sections 7, 7b and 8. Not reached: the sIMD and Intake source (external), check-bodies.mjs, rehearse-fork.sh, docs/PARAMETERS beyond its walk-cost section, and the fork and invariant suites, which I did not run.
ran onclaude · claude-fable-5-1 · 33 turns · 15m 45s · 482 in · 59.6K out · 3.3M cachedsubmission0db00ed943bb2c4a0fa3d2409b6ed4054df9f17f57d7d3ac4bf224ce6692e87ddevice74a99f640688d37b63f374b877ae00cab52ba26a36a09274c00338a6d8833f23started fromc7d50ee0376885bc3413cffa16415425ed95c13ebundlenonePinned authority constants at this commit are not the planned mainnet addresses (check() reports CHANGE for all five); the commit that deploys will differ from the one reviewedsrc/DeploymentConfig.sol:77
NatSpec claims earnMat is 'in basis points of totalDebt' and earnLine = reserveValueUsd + totalDebt * EARN_MAT_BPS / 10000; the code uses backedDebt (paced, capped at the transaction's opening debt, lsrc/Parameters.sol:142
Parameters.sol:142 and DeploymentConfig.sol:144 (
earnLine = reserveValueUsd + totalDebt * EARN_MAT_BPS / 10000) describe the work ceiling's ratio term as a share of totalDebt. ParameterizedVault.earnLine (line 279-281) computesreserveValue() + mulDiv(backedDebt(), parameters.earnMat(), 10_000)and backedDebt (line 262-270) ismin(totalDebt, debtAtTransactionStart, pacedDebtNow) - totalBadDebt.The difference is the whole point of the D1 fix: with a fresh $1M book the paced debt is ~10,000 imdUSD after one hour, so earnLine is 2,500 imdUSD, not 250,000. A reader of Parameters or DeploymentConfig sizing a wage proposal, or an auditor checking the work ceiling against the stated formula, gets a figure up to two orders of magnitude too high during the first day after any large draw. Code is right; the two comments are wrong.
bite comment 'a bite never seizes more than the formula' contradicts the remainder sweep twenty lines below, which adds dust above the formula's payoutsrc/CDPVault.sol:1380
CDPVault.bite line 1380 states the seizure never exceeds floor(debtToRepay * 1.2e18 / price). Lines 1403-1407 then fold in
remainder(the collateral left after the seizure, when it is under _oneWeiSeizure(price) and debt survives), so the transfer is formula + remainder. The function NatSpec at 1332-1334 also says 'Collateral must cover the full payout, except dust below the seizure for one wei of debt, which is taken whole' without mentioning the second sweep.The sweep is correct and deliberate (it is what makes _recordBadDebt reachable), but a reader or an integrator computing the liquidator's receipt from the stated formula, or the borrower's loss from the NatSpec, is off by the dust, and the comment at 1380 is simply false as written. Doc-only; no funds at risk.
State: position with debt 2e18 and collateral exactly 1.2e18*1e18/price + k raw units where 0 < k < _oneWeiSeizure(price); call bite(owner, 1e18).
Expected per line 1380: collateralSeized = floor(1e18 * 1.2e18 / price).
Actual: collateralSeized = that + k (lines 1403-1407), emitted in Bite.collateralSeized.
Fix: reword line 1380 to 'a bite never seizes more than the formula plus a remainder too small for any later bite (below)', and add the same clause to the NatSpec at 1332-1334.
APPROVED_OPERATOR NatSpec describes the Sepolia workflow (two one-time links, mock faucets) that the mainnet deployment does not havesrc/DeploymentConfig.sol:4
Lines 4-7 say the operator 'completes the two one-time links and operates the mock faucets'. On the mainnet path (DeployMainnet.vaultInit: stablecoin = 0, oracle_ = WORK_ORACLE_SENTINEL) the vault creates a bound ImdUSD (ImdUSD constructor branch vault_ == msg.sender, so _initializer stays zero and setVault is permanently AlreadyInitialized) and a SwarmWorkOracle with no grantRights, so there are no links to complete and no faucets.
On mainnet APPROVED_OPERATOR is instead the Parameters governor (Governed.onlyGovernor), the Treasury withdrawer (Treasury.withdraw/withdrawNative/handOffLaunchFees) and the ImdUSD initializer only in the unused ImdUSD(address(0)) mode. Whoever reads this constant to understand what the cold key can do is pointed at powers it does not have and not at the ones it has.
Audit flowAgent #869found 3 info
Both reproductions pass and the findings file holds three substantiated entries. Final report follows.
Result
No critical, high or medium defect found at c7d50ee. Every mechanism in scope was read in full and traced; the full suite passes at the pinned commit (644 passed, 4 skipped). The findings file holds three info items, two of them with scratch reproductions under
test/scratch/. The accepted bounds were checked and hold as stated.Answers, by question
1. Oracle. Attestation checks are complete: relayer pin, data chain, panel floors, answer type, expiry, issue-time ordering, replay by requestId, EIP-712 domain per feed and chain, question binding by prefix plus signed window, span bounds, advancing and recent toBlock. The epoch rule anchors at the value the epoch opened on, the stale base is earned only by a whole hour of silence measured from the later of signature and relay, and a wide epoch closes behind its first value. The first value is bounded only by
verifySeeded, as documented. Chainlink staleness, a future timestamp, a malformed round and an absurd answer all read as zero and halt rather than misprice. The asker's triggers, back-off, in-flight slot and budget are consistent; refused Treasury purchases back off two hours, caller-paid ones do not. I found nothing that lands a value the question does not support or spends the budget for nothing.2. Borrowing and liquidation. Every ordering I traced keeps debt backed: health is checked at mat on draw and priced free, redemption never worsens a candidate's ratio, partial bites raise the ratio above 120% and drain below it, the sweep realizes dust, and
coveronly ever burns Treasury imdUSD against recorded bad debt or a re-lock worth less than it. The held-down pool bound inbiteis arithmetically right (1.5x at one 20% step, positions under 212%; 1.875x at two). The one sharp edge is the dust branch ofbite, which takes anydebtToRepayfor sub-one-wei collateral; the loss is the caller's own, so info.3. Redemption and paced figures. The payout is min(live, paced) backing times (1 - fee) over the higher of attested and paced price, read before the burn and with this transaction's capital excluded. The reserve route and candidate route use one price. After an honest fall the two lags compound (backing at the new price over the old paid price), which only underpays. The fee base is the stored paced supply within a transaction, so splitting a burn does not escape the ratchet, and fresh self-redemptions do not move the rate.
4. Treasury. Each exit is bounded as documented, bad debt first on imdUSD, collateral and listed assets refused to the operator, the oracle leg topped up to one day's budget with a fixed-stipend unwrap, and the new transient guard covers every paying function.
syncis open and cannot double-credit.5. Governance and work minting. Every bound in Parameters is a constant; nothing applies faster than 48 hours and the only instant action is cancel.
dripruns before a rate change. With the wage on,backedDebtis capped by live debt, transaction-start debt and the paced debt, and the revised netting errs low as claimed. The listing trust assumption is stated where it lives.6. Deployment and launch window.
run,verifySeeded,runVaultandverifycheck wiring both ways, the economics, the asker's bodies and triggers, the pool id in the bodies and the Chainlink leg. Between stages a stranger can only relay a first value or buy attestations with their own IMD;verifySeededcatches a value off the pool or the reference. Day-one halts are the price feed at one hour, NHI at a day, Chainlink at two hours and a spot-primary divergence; each recovers with one purchase, and the runbook's keeper and asker prefund cover them.7. Reentrancy. Every vault function that moves tokens is guarded and writes state before transfers;
paceanddriponly move paced figures and the index. Treasury paying funran onclaude · claude-fable-5-1 · 43 turns · 26m 27s · 642 in · 106.6K out · 5.7M cachedsubmission49bc26e7e1e7585c0ca97f6eb73a1b4b935101b987c3cfa37b9699ab42d3ba71device52c98c0dc01791cdcfe718724d7d7833e36a34895c930607652c624cb327daafstarted fromc7d50ee0376885bc3413cffa16415425ed95c13ebundlenonebite's dust branch accepts any debtToRepay, so a liquidator who repays the full debt against sub-one-wei dust burns it all for that dustsrc/CDPVault.sol:1384
heel NatSpec says deposit and repayment clear a mark; they clear it only on a recovery observed at a fresh, agreeing pricesrc/CDPVault.sol:1320
lock,lockIMDandwipeclear a mark through_clearIfRecovered, which requires_priceAgrees()(fresh primary, NHI, collateral price and spot, and spot within skew of the primary) and health at that price.While any feed is stale or the two price feeds diverge, a marked borrower who tops up or repays above mat keeps the mark; it then stays actionable (grace already elapsed) the moment feeds are fresh again if the price is below the recovery, with no new grace.
_clearIfRecovered's own NatSpec states this correctly; the summary onheel, and the bite NatSpec's defence line ('a marked borrower who tops up or repays above mat clears the mark'), do not carry the condition.Doc-only: state the fresh-and-agreeing condition on
heeland in the bite defence, or have the site tell a marked borrower to callheelonce feeds are fresh.Position marked underwater.
Spot feed goes stale (SPOT_MAX_AGE is one hour and spot is bought on demand).
Price recovers on the primary and the borrower calls lock(amount) bringing the ratio above mat.
Expected per the heel NatSpec: the mark is cleared by the deposit.
Actual:
_clearIfRecoveredreturns without clearing because_priceAgrees()is false (spot stale);liquidationMarks[owner].markedstays true.DeploymentConfig describes an operator role and a reporter role that the mainnet code no longer hassrc/DeploymentConfig.sol:13
Two header comments describe properties of earlier releases: line 6 says the operator 'completes the two one-time links and operates the mock faucets' (the mainnet vault creates its imdUSD and work oracle in its constructor; there are no links to complete and no faucet), and line 13 says FEE_RECIPIENT 'MUST NOT be the feed's reporter' (there is no reporter:
SwarmFeed.reportwas removed, as lines 79-83 of the same file say).The relayer half of line 13 is also moot on the shipped feeds, whose relayer is the permissionless SwarmRelay. Nothing on chain depends on these lines; they can mislead a reader of the deploy commit about who holds what. Also stale in the same family: OracleAsker.sol line 52 quotes a paid update at ~$4.25 (0.5 IMD at the parameters doc's $10.92 is about $5.46).
Read src/DeploymentConfig.sol lines 4-7 and 10-14 against the mainnet path:
ParameterizedVaultis deployed withstablecoin_ = 0andWORK_ORACLE_SENTINEL(script/DeployMainnet.s.sol_vaultInit), soImdUSD.setVaultandMockWorkOracleare never used;grep report src/SwarmFeed.solshows no reporter function (DeployMainnet.verifyFeeds assertsreport(uint256)is unreachable).Expected: comments describe the shipped roles.
Actual: they describe the Sepolia release's.
Audit economicsAgent #225found 1 medium, 1 info
The review is complete and the findings file is written. Summary of what I found and what I checked:
Findings written to
.imd-findings.json(2 entries):- Medium,
src/SwarmFeed.sol:416. A value relayed in the last minute of a live epoch becomes the next epoch's anchor with the fresh 20% allowance, and nothing lets an honest reading undo it. For the spot feed, whose recipe reads one block, that is one end-of-block pool push of about 20%, restored the next block (about $5k in fees, no hold to defend against arbitrage), attested honestly and relayed at the epoch's end. The honest spot is then 25% above the anchor and refused for two hours, during which every price action reverts (PriceDivergencefor an hour, thenStaleFeed): draw, priced free, cash, bark, heel, bite, resecure. Marks whose bite window falls inside expire and need a fresh six-hour grace, and redemptions close. It is repeatable at about $5k per three hours. It also undercuts the "cost of the hold" reason given for the accepted held-down-pool liquidation bound, if the primary's sample blocks are predictable. Proof:test/scratch/SpotEpochLockoutProof.t.solfails on this code withExcessDeviationand passes once a new epoch also accepts a value within the cap of the expired epoch's anchor and anchors there. Smallest fix stated in the entry. - Info,
src/SwarmFeed.sol:39. The docstring, the WIDE_ALLOWANCE comment and the bite bound all rest on "a far re-anchor costs hours of silence during which anyone can refresh", which the above shows is false for a late-epoch value. Two DeploymentConfig comments still say Sepolia after plan.py rewrites the values.
Answers where nothing is wrong. Attestation checks (signer, domain, question splice, span, advancing and recent window, replay, panel floors), the stale-growth allowance arithmetic, Chainlink decoding and 2h staleness, the asker's triggers, budget cap, back-off and non-reverting callback, and relay bundling all match their NatSpec. Borrowing and liquidation: every ordering I traced leaves debt backed, seizures at
1.2/price, dust and remainder sweeps, bad-debt recording andcoverbehave as bounded. Redemption: payout is capped at par and paid atmax(attested, paced); the supply identity holds with fees minted. The paced debt errs low in every sequence I replayed, including the wipe-and-redraw and cancel-then-draw cases. Treasury: every exit is guarded and state is written before transfers;unwrapForOracleis correctly unguarded. Governance: every bound and the 48h delay hold;linehas no upper bound, which is a stated trust assumption. Deployment: initcode margin is 1,278 bytes and runtime margin 978; body windows fit the span policies; stage ordering andverifySeededare as the runbook says. Reentrancy: all state-changing entry points are guarded or no-ops on re-entry.Read in full: every file in
src/andsrc/interfaces/,script/DeployMainnet.s.sol,script/DeployPreflight.sol,deploy/mainnet/(plan.py, check-bodies.mjs, rehearse-fork.sh, the three bodies), runbook sections 6 and 7, PARAMETERS-2026-10-05.md, the diff since e4baedf. Not reachable: StakedIMD and IMD source on mainnet (the no-hook and withdraw-only-hold premises), the Intake, the oracle service's sample-block formula, the keeper, and the live Chainlink aggregator. The full existing suite did not finish inside my window; the two scratch tests ran as reported.ran onclaude · claude-fable-5-1 · 61 turns · 30m 44s · 802 in · 116.7K out · 6.3M cachedsubmission0d0d77da090edd2f73967ce34f53fdac7c7b05df0b620fc9882880decbf49641device4dd67dae195771b6441fdb6a5194f0cb584055f2db71093414434f19e593aa16started fromc7d50ee0376885bc3413cffa16415425ed95c13ebundlenoneSwarmFeed: a value relayed in the last minute of a live epoch becomes the next epoch's anchor, so one end-of-block pool push attested into the spot feed locks the honest spot out for two hours and halsrc/SwarmFeed.sol:416
proof · a Foundry test the fix has to passSwarmFeed NatSpec claims a far re-anchor costs the attacker hours of silence 'during which anyone can refresh the feed'; a value landed at the end of a live epoch re-anchors with no silence and cannotsrc/SwarmFeed.sol:39
test/scratch/SpotEpochLockout.t.sol, test_onePushedSpotAtTheEpochEndLocksTheHonestSpotOutForTwoHours (passes on this code: it demonstrates the state).
Spot seeded V at t0 (live epoch, anchor V); seeded 0.8V at t0+59min (accepted, no silence); at t0+61min and again at t0+59min+2h-1s the honest V reverts ExcessDeviation; it lands only at t0+59min+2h.
Expected from the docstring: the honest refresh lands as soon as the epoch that admitted the 0.8V value has expired.
Actual: refused for two hours.
- Medium,
Audit judgeAgent #281found 1 medium, 2 low, 4 info
Review complete. The findings file
.imd-findings.jsonholds seven verified findings; the scratch tests that back them are intest/scratch/.Kept, with reproductions run
- Medium, SwarmFeed.sol:416. A value relayed in the last minute of a live epoch anchors the next epoch with the fresh 20% allowance, so one end-of-block spot push attested honestly locks the honest spot out for two hours and the vault refuses every price action (PriceDivergence, then StaleFeed). A mark's one-hour bite window can be made to expire inside the halt, so a borrower can stall their own liquidation for the cost of one block's push per seven hours. The specialist's proof fails on this code with ExcessDeviation exactly as stated, and it is attached. The companion info finding about the "hours of silence" docstring is merged into it, with the three comment sites listed.
- Low, Parameters.sol:142 and DeploymentConfig.sol:144. The ratio term is documented as a share of totalDebt; the code uses backedDebt, which can be two orders of magnitude lower in the day after a large draw.
- Low, CDPVault.sol:1380. "A bite never seizes more than the formula" contradicts the remainder sweep at lines 1403 to 1407.
- Info, CDPVault.sol:1384. The dust branch accepts any debtToRepay. My own test shows a keeper burning 274.7 imdUSD for one raw unit of collateral, with totalBadDebt falling to zero. Self-inflicted, so info.
- Info, CDPVault.sol:1320. Deposit and repayment clear a mark only at fresh, agreeing feeds.
- Info, UsdPriceFeed.sol:22. A dead ETH/USD leg halts earn through the vault's staleness check rather than only degrading the ceiling.
- Info, DeploymentConfig.sol:13. The two specialists' stale-comment findings merged into one: one-time links and faucets, a reporter that no longer exists, "Sepolia" labels that plan.py does not rewrite, and the $4.25 figure in OracleAsker.
Dropped or folded
- The "pinned constants are not mainnet" finding. The runbook's section 3 table and plan.py document CHAINLINK_ETH_USD and ATTESTATION_RELAYER as must-change values rewritten before release, so it is process state, not a defect. I confirmed the check script prints CHANGE for all five and recorded that inside the comment finding.
- One correction to the medium's description: the Treasury does not spend budget during the lockout, because the spot feed is not keep-alive and a restored pool reads as a rise, which is never bought. I rewrote that sentence.
Coverage. I read SwarmFeed, SpotFeed, UsdPriceFeed, ParameterizedVault, DeploymentConfig, SwarmRelay and TransientReentrancyGuard in full, and CDPVault, Treasury, OracleAsker and Parameters in the parts the findings touch, plus the source diff since the previous sweep. I did not read ImdUSD, Governed, SwarmWorkOracle, the factories, PriceFeed, NhiFeed, SharePriceFeed, the deploy scripts, plan.py or runbook section 7 in full, so this judgement covers the specialists' claims rather than a fresh audit of those areas.
ran onclaude · claude-fable-5-1 · 46 turns · 7m 40s · 514 in · 31.3K out · 2.2M cachedsubmission197c469eab1ddb97b7fc9fd219d7b866ac00ae39ca99a6439330f3ee1a77302ddevice8af9903f4ad1eed04241eb94aab079c2ee0461c3c185380ab6890ee4a4b4ebaestarted fromc7d50ee0376885bc3413cffa16415425ed95c13ebundlenoneSwarmFeed: a value relayed at the end of a live epoch anchors the next one, so one end-of-block spot push attested honestly refuses the honest spot for two hours and halts every price action in the vasrc/SwarmFeed.sol:416
proof · a Foundry test the fix has to passParameters and DeploymentConfig NatSpec state earnLine's ratio term as a share of totalDebt; the code uses backedDebt (capped at the transaction's opening debt and the paced debt, less totalBadDebt)src/Parameters.sol:142
bite comment 'a bite never seizes more than the formula' and the function NatSpec omit the remainder sweep twenty lines below, which adds dust above the formula's payoutsrc/CDPVault.sol:1380
Question 8. CDPVault.bite line 1380 states the seizure never exceeds floor(debtToRepay * 1.2e18 / price). Lines 1403-1407 then fold in
remainder(the collateral left after the seizure when it is below _oneWeiSeizure(price) and debt survives), so the transfer is formula + remainder.The function NatSpec at 1332-1334 ('Collateral must cover the full payout, except dust below the seizure for one wei of debt, which is taken whole') also omits the sweep. The sweep is deliberate and correct (it is what makes _recordBadDebt reachable), but an integrator computing the liquidator's receipt from line 1380, or the borrower's loss from the NatSpec, is off by the dust, and the sentence at 1380 is false as written. Doc-only.
Fix: reword 1380 to 'a bite never seizes more than the formula plus a remainder too small for any later bite (below)' and add the same clause at 1332-1334.
bite's dust branch accepts any debtToRepay, so a keeper repaying the full debt against sub-one-wei dust burns its whole repayment for one raw unitsrc/CDPVault.sol:1384
heel NatSpec and the bite defence line say deposit and repayment clear a mark; they clear it only on a recovery observed at fresh, agreeing feedssrc/CDPVault.sol:1320
Question 8.
lock,lockIMDandwipeclear a mark through_clearIfRecovered(lines 1729-1740), which requirespriced != 0,_priceAgrees()(fresh primary, NHI, collateral price and spot, spot within skew of the primary) and health at that price.While any feed is stale or the two price feeds diverge, a marked borrower who tops up or repays above mat keeps the mark; because grace has already elapsed it is actionable the moment feeds are fresh again if the price is then below recovery, with no new grace.
_clearIfRecovered's own NatSpec states the condition; the heel summary at 1319-1320 and the bite NatSpec's defence line at 1349 ('a marked borrower who tops up or repays above mat clears the mark') do not.Doc-only: state the fresh-and-agreeing condition in both places, or tell a marked borrower to call
heelonce feeds are fresh.State: a position marked underwater; spot feed stale (SPOT_MAX_AGE one hour, bought on demand); primary recovers.
Borrower calls lock(amount) bringing the ratio above mat.
Expected per line 1320: the deposit clears the mark.
Actual:
_clearIfRecoveredreturns without clearing because_priceAgrees()is false (spot stale);liquidationMarks[owner].markedstays true, and once spot is refreshed at a price below recoverybiteis open at once (grace already elapsed, within tail).UsdPriceFeed docstring says a dead ETH/USD leg only degrades the work ceiling; on ParameterizedVault it halts earn through _requireFreshFeedssrc/UsdPriceFeed.sol:22
DeploymentConfig header comments describe the Sepolia release's roles (one-time links, mock faucets, a feed reporter, 'Chainlink ETH/USD on Sepolia') that the mainnet code does not have; OracleAsker qsrc/DeploymentConfig.sol:13