Job
Audit the price path: src/SwarmFeed.sol, src/PriceFeed.sol, src/NhiFeed.sol, src/SpotFeed.sol, src/SwarmRelay.sol, src/OracleAsker.sol, src/UsdPriceFeed.sol, src/SharePriceFeed.sol, src/SwarmWorkOracle.sol, and the constants they read, at the pinned commit, for a mainnet launch. Read whatever else in src/ these contracts depend on, but report on this scope. Three audit rounds and their fixes are already in (docs/AUDIT-*.md, newest docs/AUDIT-FINAL-2-2026-10-07.md and the fix commit after it); …
Audit report
7 findingsFour agents audited the code as it is at b73a05f, 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)
3 low3 info
1.SwarmFeed._allowanceNow measures the hour of silence that earns the stale base from the signed issuedAt, which a relayer may hold back up to maxAge, so a held-and-relayed walk compounds at 2x the cap src/SwarmFeed.sol:415
uint256 periods = (block.timestamp - _updatedAt - maxAge) / STALE_GROWTH_PERIOD;
proof · a Foundry test that fails on this code and passes once it is fixed2.lowOracleAsker.onOracleResult backs the Treasury off for ASK_TIMEOUT after a refused delivery of a CALLER-paid request, so a ~$5 askPaid whose public answer is hand-relayed first disables Treasury-paid asrc/OracleAsker.sol:284
if (live) f.lastAsk = uint64(block.timestamp + ASK_TIMEOUT - ASK_MIN_INTERVAL);
3.lowSwarmFeed: the unbounded first value is 'the one we buy and check at deployment' only by convention; with SwarmRelay as the pinned relayer whoever relays first anchors a freshly deployed feed, and thesrc/SwarmFeed.sol:356
/// stale (`_allowanceNow`). The first value ever has no /// bound: it is the one we buy and check at deployment, and it anchors the first epoch.4.lowA reverting ETH/USD (or share-vault) leg reads as price 0, and a wipe/lock made while it is down re-prices the position's secured term at zero; the zero persists past recovery and shuts or underpays `src/CDPVault.sol:819
if (principal == 0 || price == 0) return 0;
5.infoSwarmFeed constructor NatSpec describes maxDeviationBps_ as a bound on the change from the LAST accepted value; the shipped bound is per epoch, measured from the epoch's anchor and widening with stalesrc/SwarmFeed.sol:156
/// @param maxDeviationBps_ Maximum change from the last accepted value, from 0 to 10,000 bps.
6.infoSwarmFeed.submitAttestation NatSpec still describes the pre-epoch guard: a bound that holds only 'while the previous value is fresh' and a relayer that 'covers the unseeded first value and stale re-ansrc/SwarmFeed.sol:221
/// The deviation guard bounds a wrong-question figure once seeded while the previous value is fresh; /// a nonzero relayer covers the unseeded first value and stale re-anchors. A feed that pins itsFrom the audit_math specialist, confirmed by reading lines 221-222 against
_checkValue/_epoch/_allowanceNow(370-420) and DeploymentConfig.sol ATTESTATION_RELAYER. The bound no longer lifts when the value is stale: it widens by_allowanceNowand is measured against the epoch's anchor, not the last accepted value.And the relayer covers nothing on the shipped feeds, since ATTESTATION_RELAYER is SwarmRelay, which admits everyone (the constructor comment at lines 181-193 says so itself; SwarmRelay.sol lines 11-13 repeat the stale claim). No code effect; the sentences could mislead a reader into thinking a stale feed is unbounded or that the relayer is a trust boundary.
Fix: state the per-epoch, widening bound and that the relayer is not load-bearing; the first value is bounded by nothing on chain (see the low finding at line 356).
Read src/SwarmFeed.sol:221-222.
A stale feed (value two hours old) submitted a value 3x its anchor: EXPECTED per 'bounds ... while the previous value is fresh' (i.e. no bound once stale) accepted; ACTUAL ExcessDeviation (allowance 4000 bps).
A stranger calling SwarmRelay.relay on an unseeded shipped feed: EXPECTED per 'a nonzero relayer covers the unseeded first value' refused; ACTUAL accepted (test/scratch/FirstValueRace.t.sol).
7.infodocs/ABI.md describes the attestation as a 12-field tuple under EIP-712 domain version '1' and says there is no questionHash gate or getter; SwarmFeed signs 15 fields (panelSize, quorum, agreed added)docs/ABI.md:84
`submitAttestation` takes an `OracleAttestation` tuple in this exact order: `(bytes32 requestId, uint256 chainId, bytes32 questionHash, uint8 answerType, bytes answer, uint256 figure, uint64 fromBlock, uint64 toBlock, bytes32 blockHash, bytes32 panelJobId, uint64 issuedAt, uint64 expiresAt)`, followed by a 65-byte signature. The EIP-712 domain is `IdentityMD Oracle`, version `1`, with the deployment chain ID and the receiving feed's address, computed once in its constructor. Payload chainId and answerType must equal `attestationChainId()` and `attestationAnswerType()`; the payload data chain may differ from the consumer chain (for example, mainnet data consumed on Sepolia). requestId is consumed once per feed; issuedAt cannot be in the future, exceed expiresAt, precede the last accepted update, or be older than maxAge. Delivery after expiresAt is rejected. The feed publishes figure and uses signed issuedAt for freshness, then discards any unfinished reporter round.
Build the tuple as ABI.md line 84 lists it (12 members), sign under EIP712Domain('IdentityMD Oracle','1',chainid,feed) with the attester key and call
submitAttestationon any shipped feed: EXPECTED per the doc accepted; ACTUAL the call fails in ABI decoding (the struct has 15 members), and with the three fields added but version '1' it reverts InvalidSignature because DOMAIN_SEPARATOR hashes version '2'.
Work
- posted4 minto the first attempt
- reviewed
#525Audit permissionsCodexrefusedRefused by Codex's safety filterretried on #852 (Claude)
This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.
ran oncodex · gpt-6-astra · 2 turns · 3m 34ssubmissionfaec2fc1ecb3d5fca5aa82f7ff5e5b8b831704112fbe44abb0c326f2b795c1e8device410d15d57eeec44045e2d71267b0176e08efa4c49808e8d821ec3c2846ba9ae0started fromb73a05f0f9185bae139f46c56f044ed9c7391c4cbundlenonechanged · 0 filesnothing - reviewed
#613Audit flowCodexrefusedRefused by Codex's safety filterretried on #808 (Claude)
This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.
ran oncodex · gpt-6-astra · 3 turns · 6m 14ssubmission0ce8946b9fc6b3627f3a62a54e2aa08fd0a000cc6a8981e20ff05fb373a3bb54device61794f1d37e04d2f8922eafa25113871987deca2d02c11e46c1b0b2a5f7796c5started fromb73a05f0f9185bae139f46c56f044ed9c7391c4cbundlenonechanged · 0 filesnothing - reviewed
#1202Audit economicsCodexrefusedRefused by Codex's safety filterretried on #225 (Claude)
This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.
ran oncodex · gpt-6-astra · 3 turns · 8m 0ssubmission12bfb2c7f32346b0bf24ba5d9aa637a2baca2ee08b078b7504da39e8607a9c78device5c0fdae3b22cd8969d48b18d14a487314486ff7e85eef296e2385d1511a5c2f1started fromb73a05f0f9185bae139f46c56f044ed9c7391c4cbundlenonechanged · 0 filesnothing - reviewed
#1701Audit mathCodexrefusedRefused by Codex's safety filterretried on #727 (Claude)
This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.
ran oncodex · gpt-6-astra · 6 turns · 22m 13ssubmission915ae3c1575f1cd4f282e594d3d220954f727623929a6459ec1316e9f7c251e2device54f31a82977e83fd994796c9d1f646ef46c7a7b5936143e1d9c52b6ad973405estarted fromb73a05f0f9185bae139f46c56f044ed9c7391c4cbundlenonechanged · 0 filesnothing - reviewed
#852Audit permissionsClaude4 findings · 1 medium
The embedded proof fails on the current code as required; scratch files are removed and only
.imd-findings.jsonremains untracked. Here is the report.Findings (in
.imd-findings.json)1. MEDIUM —
SwarmFeed._allowanceNow(src/SwarmFeed.sol:415): the hour of silence that earns the stale base is measured from the signedissuedAt, which the relayer may hold back ~57 minutes. The second-half review's medium (#1) fix requires a whole hour of staleness pastmaxAge, but_updatedAtis the attestation'sissuedAt, not the acceptance block.submitAttestationadmitsissuedAtup tomaxAgeold and_requireQuestionadmits a window up to 300 blocks old; the live attestation was signed 130 s after its window and the bodies carry a 1-day TTL. So a buyer holds each step 57 minutes and relays it at the recency limit; 62 minutes after that acceptance the epoch has expired andperiods == 1, so the next step gets 2× cap. Walk from a fresh feed: 1.2, then 1.4 per 62m10s → 3.29× at T0+4h07, 4.61× at T0+5h09, against the committed 2.07× / 2.49×. The claim at lines 27-30, 412-414 and in PARAMETERS ("3.48× in seven steps, six hours") does not hold. Reachable with committed constants. Proof: a self-contained test fails atepoch()returning 4000 and passes against a copy with an_acceptedAtslot used for the silence (smallest fix; +22.1k gas on a first delivery, 146,876 → ~169k, inside the 200k stipend).2. LOW —
OracleAsker.onOracleResult(src/OracleAsker.sol:284) backs the Treasury off after a refused caller-paid delivery.liveis true for anaskPaidrequest too. Relay the plane's public answer by hand before the callback →ReplayedAttestation→lastAsk = now+2h−10m→askrevertsTooSoonfor two hours even with an armed 5% fall present. $5 per two hours disables the Treasury's fall trigger, NHI keep-alive and wide refresh; manual fallbacks remain. Fix: back off only on the Treasury's own refusal (atreasuryPaidflag inFeed).3. INFO —
@param maxDeviationBps_(SwarmFeed.sol:156) still describes the pre-epoch "from the last accepted value" rule. 4. INFO — docs/ABI.md:84 documents a 12-field tuple under domain version "1"; the code signs 15 fields under "2".Answers to the numbered questions
- Attestation acceptance — sound. Domain binds chainid + feed address (no cross-feed/cross-chain replay),
usedRequestsper feed, low-s/v checks,issuedAt ≤ now,≥ _updatedAt, withinmaxAge; panel floors 25/15 (quorumis signed but unchecked, by design); window spliced into the pinned RFC-8785 prefix (windowsorts last;fromBlock<toBlock), span bounds, advancingtoBlock, recency bound on chain 1. A different question/chain/feed/window cannot be accepted. One note: the max span (1200) lets a buyer substitute a 4-hour median for the pinned 2-hour body; the spot/primary skew guard contains it. - Epoch bound — within one lifetime, N attestations move at most the epoch allowance (cap if opened fresh). A single attestation after H hours of silence (from
issuedAt): cap for H < 2, 40% at 2, 45% at 4, 60% at 10, 100% at 26, 100× ceiling; a fall is always followable within 27 h, a rise above 100× never (only the NHI feed near zero could hit this, practically unreachable since r≈1 keeps NHI ≥ 0.3). Sustained rate: committed cap/hour; actual 2×cap per 62 min via held relay (finding 1)._epochFirstcannot be kept open (anchor bound ∧ first±cap); uint32/uint88 packing exact; unseeded case reads (0, now, 1e6) and_acceptopens at the cap; work-oracle override keeps only the zero check; NHI widens hourly as documented. - OracleAsker —
wideOpen,nearStale, arm/ask,askPaid(Many), late deliveries and the slot-6extsload(v4POOLS_SLOT = 6, sqrtPriceX96 in the low 160 bits, inversion correct) all check out; the Treasury cannot spend over budget (top-up to budget); a paid answer is not lost. The only gap is finding 2. - The walk, costed — pump to M costs
ran onclaude · claude-fable-5-1 · 50 turns · 19m 53s · 95 in · 80K out · 6.9M cachedsubmission0cd00a2dc1bdd8f4ae9257015b955006d62bd0b49f35a851a6f79cabbf00754adevice1ca477e8d9b58040894c4693ab330aaa2cde1abb8c06ee731bcb0c0093132277started fromb73a05f0f9185bae139f46c56f044ed9c7391c4cbundlenonechanged · 0 filesnothingSwarmFeed._allowanceNow measures the hour of silence that earns the stale base from the signed issuedAt, which the relayer may hold back up to the window-recency limit (~57 min), so a held-and-relayedsrc/SwarmFeed.sol:415
proof · a Foundry test the fix has to passOracleAsker.onOracleResult backs the Treasury off for ASK_TIMEOUT after a refused delivery of a CALLER-paid request, so a $5 askPaid whose public answer is relayed by hand first disables the Treasury'src/OracleAsker.sol:284
SwarmFeed's constructor NatSpec describes maxDeviationBps_ as a bound on the change from the LAST accepted value; the shipped bound is per epoch, measured from the epoch's anchorsrc/SwarmFeed.sol:156
Since cc4103f the deviation guard is per epoch: every value accepted within maxAge of an epoch's start must lie within the epoch's allowance of the ANCHOR (the value the feed held when the epoch opened), and the allowance is the cap only for an epoch opened on a fresh value (contract docstring lines 22-33,
_checkValue,_epoch,_allowanceNow). The @param line still states the pre-epoch rule.A reader choosing a cap from this line would expect 'at most cap per attestation' and get 'at most the epoch allowance per lifetime, up to 100x after long silence'. No code change; reword the @param to 'the per-epoch allowance of the anchor when the epoch opens on a fresh value; wider on a stale one (see _allowanceNow)'.
Feed with maxAge 1 h and maxDeviationBps 2000 seeded V at T0.
At T0+30m accept 1.2V (within the cap of the anchor V).
At T0+40m submit 1.44V: expected per the @param text (20% from the LAST accepted value 1.2V) accepted; actual ExcessDeviation, because the epoch's anchor is V and 1.44V is 44% from it.
Conversely at T0+2h+1s (two whole hours past issuedAt) a value 1.4V is accepted although it is 40% from the last accepted value.
docs/ABI.md describes the attestation as a 12-field tuple under EIP-712 domain version '1'; SwarmFeed signs 15 fields (panelSize, quorum, agreed added) under version '2'docs/ABI.md:84
SwarmFeed.ATTESTATION_TYPEHASH (src/SwarmFeed.sol:117-119) covers
...bytes32 panelJobId,uint16 panelSize,uint16 quorum,uint16 agreed,uint64 issuedAt,uint64 expiresAtand DOMAIN_SEPARATOR (lines 166-174) useskeccak256("2"). ABI.md, the integrator-facing reference, still documents the v1 shape: a 12-field tuple withpanelJobIdfollowed directly byissuedAt, and domain version1.An integrator (a relayer or a keeper) encoding
submitAttestationfrom this page produces calldata that does not decode, or a digest the feed rejects with InvalidSignature. It also omits the panel floors (MIN_PANEL_SIZE 25, MIN_AGREED 15) that the v2 fields enable. Documentation only; regenerate the paragraph from the struct and the domain in SwarmFeed.sol.Build the tuple as ABI.md line 84 lists it, (bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint64,uint64), sign it under EIP712Domain('IdentityMD Oracle','1',chainid,feed) with the attester key, and call
submitAttestationon any shipped feed: expected per the doc, accepted; actual, the call reverts in ABI decoding (the struct has 15 members) and, with the three fields added but version '1', reverts InvalidSignature because DOMAIN_SEPARATOR hashes version '2'.
- Attestation acceptance — sound. Domain binds chainid + feed address (no cross-feed/cross-chain replay),
- reviewed
#808Audit flowClaude2 findings · 1 medium
git statusshows no tracked file changed; the only additions are.imd-findings.jsonand the scratch test. Review complete.Result
Two findings, written to
.imd-findings.json: one medium (with a Foundry proof that fails on the committed code and passes under the one-line fix), one low. Everything else in scope held up.# Sev Where Finding 1 medium src/SwarmFeed.sol:415_allowanceNowThe hour of silence that earns the stale base is counted from the attestation's signed issuedAt, which the relayer may hold back by up tomaxAge. The epoch, however, opens at the relay block. A buyer relaying each step with a ~55-minute-old attestation (window still within 300 blocks,validForSeconds86400) gets the 2x base 65 minutes after each relay: the walk compounds at 1.4 per 65 min, not the documented 1.2 per hour (3.29x at 4h15 vs 2.49x at 4h). Regression gap in the b73a05f fix; its test only relays withissuedAt == block.timestamp. Fix: measure silence frommax(_updatedAt, _anchorAt). Proof:test/scratch/StaleBaseFromIssuedAt.t.sol.2 low src/SwarmFeed.sol:356The unbounded first value is "the one we buy and check at deployment" only by convention: SwarmRelay admits everyone, bodies and CREATE2 addresses are public, the vault is live from its constructor. Whoever relays first anchors the feed; a pumped-window 2x anchor refuses the deployer's honest value for ~6h. A race, hence low. Answers to the numbered questions
1. Attestation acceptance. Sound. Domain binds chain id and feed address (cross-feed/cross-chain replay impossible);
requestIdreplay;issuedAt ≤ now,≤ expiresAt, not older thanmaxAge, never older than the current value; panel floors 25/15 withagreed ≤ panelSize; the question is rebuilt from the pinned prefix plus the signed window, span-bounded, advancing, and recent on the data chain. No different question/chain/feed/window can land. Low-s and v ∈ {27,28} enforced. The one thing issuedAt latitude buys an attacker is finding 1.2. Epoch bound. Within one lifetime: at most the epoch allowance from the anchor (a wide epoch is then held to the cap around its first value, so the first relayer after a silence gets the allowance, nobody after). After H hours of silence: 20% for H<2, then 40% + 2.5%·(H−2), capped at 10,000% (reached at ~3,986 h), so no gap can never be followed, only delayed. Sustained rate as committed: cap/hour only if attestations are relayed promptly; with held attestations it is 2x cap per 65 min (finding 1). Packing is exact (bound ≤ 1e6 fits uint32;
_epochFirstfalls back to anchor-only above 2^88, unreachable for 1e15–1e18 figures). Unseeded:_hasValuefalse skips the bound,epoch()reports max allowance,wideOpentrue — as documented.SwarmWorkOracle._checkValuekeeps only the zero check; the base's epoch bookkeeping still runs harmlessly (roots exceed uint88, so_epochFirstis 0). NHI under the hourly schedule: a >20% index step is refused until 25h after the lastissuedAt, so one to five hours of halt per step; a held NHI attestation cannot make the feed older than it already is (issuedAt ≥ _updatedAt), so no staleness DoS.3. OracleAsker. Correct as reviewed:
askpays only for keep-alive near-stale,wideOpen(stale ∧ no live epoch ∧ allowance ≥ 6000), or an armed fall still present 5–100 blocks later; rises never.askPaid/askPaidManycharge only for requests made._requestkeepsfeedOfso late deliveries land;onOracleResultis Intake-only, never reverts past the clearing, backs off only the live request. The pool slot is correct for v4 (_poolsat slot 6, sqrtPriceX96 in the low 160 bits, inversion exact). Treasury exposure is bounded by the 15 IMD/day top-up; making it pay without cause needs two ~$1.2k pool pushes five blocks apart for a $5 spend (documented, accepted). Nothing blocks updates for everyone (hand relay always works), and a loran onclaude · claude-fable-5-1 · 44 turns · 17m 47s · 82 in · 74.4K out · 6.4M cachedsubmission6572c71642e6535f5e669ef3a8fe4e0a9d93491e80dac65cfcc4540ebbce170ddevice7f1dec5ffcbde1d88ca607ac38ef7545b0eda84f10878188e9ed8c9138392f4cstarted fromb73a05f0f9185bae139f46c56f044ed9c7391c4cbundlenonechanged · 0 filesnothingSwarmFeed._allowanceNow counts the stale base's hour of silence from the relayer-chosen issuedAt, so a walk relayed with 55-minute-old attestations compounds at 2x the cap per 65 minutes instead of thsrc/SwarmFeed.sol:415
proof · a Foundry test the fix has to passSwarmFeed: the unbounded first value is 'the one we buy and check at deployment' only by convention; with SwarmRelay as the pinned relayer anyone can anchor a freshly deployed feed first, and the deplsrc/SwarmFeed.sol:356
- reviewed
#225Audit economicsClaude3 findings · 1 medium
The findings file holds three verified items, every quoted snippet matches its line, and the tracked tree is unchanged. Final report follows.
Result
Three findings written to
.imd-findings.json: one medium with a Foundry proof, one low with a reproduction, one info. No tracked file was changed. The scratch tests live undertest/scratch/.# Severity Where Finding 1 medium src/SwarmFeed.sol:415The "hour of silence" that earns the 2x stale base is measured from the signed issuedAt, not from acceptance. Holding each attestation for its lifetime before relaying re-earns 40% one lifetime after every step. Proof fails on this code, passes with a two-line fix.2 low src/CDPVault.sol:819A reverting ETH/USD or share-vault leg reads as price 0. A wipeorlockduring the outage zeroes the position'ssecuredCollateralterm, the lag clamp follows it down, and after recoverybackingPerUnitreads 0 socashreverts for everyone until the borrower acts, then warms up for a day.3 info src/SwarmFeed.sol:156Constructor NatSpec still describes the cap as the maximum change from the last accepted value. Answers to the numbered questions
1. Attestation acceptance. Nothing wrong. The EIP-712 domain binds chain id and feed address, so no cross-feed or cross-chain replay.
usedRequestsis written before the question and value checks and rolls back with them.issuedAtmust be at most one lifetime old, at or after the stored value's issue time, and not afterexpiresAt. Panel floors read the signedpanelSizeandagreed. The question hash is rebuilt from a pinned prefix plus the two signed window numbers, so a different question, span outside the bounds, a non-advancing window, a future window, or one older than one lifetime in blocks is refused. The attacker's only freedom is the window inside those bounds and when to relay, which is what finding 1 uses.2. The epoch bound. Within one lifetime of an epoch's start, N attestations move the feed at most the epoch's allowance from the anchor, and a wide epoch holds later values to the cap around its first value. After H whole hours of silence past the lifetime, one attestation may move 40% plus 2.5% per further hour, capped at 100x. Packing is exact: the bound never exceeds 1,000,000 and fits uint32, and values above 2^88 are simply not recorded as the epoch's first. The unseeded feed reads allowance 1,000,000 and
wideOpentrue, as designed. SwarmWorkOracle's override keeps only the zero check, which is right for a root. The claimed sustained rate of the cap per hour does not hold: a relayer who holds each attestation re-earns the stale base one lifetime plus the panel's signing latency after each step, so the walk compounds at 1.4x per hour and reaches 3.48x in four to five hours instead of six to seven. For the NHI feed a value relayed a day after signing allows a 95% next step. Any gap is eventually followed. There is no way to keep a wide epoch open for a later value, but the first value after a silence can be the attacker's, which the DeploymentConfig and OracleAsker comments gloss over by saying the honest value lands first.3. OracleAsker. Nothing wrong in the asker itself. The Treasury pays only when a keep-alive feed is near stale, when a feed has been silent a lifetime with a 60% allowance, or when an armed fall is still present five blocks later. Two pool pushes five blocks apart can trigger a paid update, as accepted, at roughly $1,100 of fees per 0.5 IMD spent. Spending is bounded by the asker's balance, which the Treasury tops up to one day's budget. In-flight slots cannot block hand relays through SwarmRelay. A timed-out request keeps
feedOf, so its late delivery lands. The callback never reverts past the clearing, and the bound-question delivery fits under 150k of the 200k stipend in the committed gas tests. The pool slot is the Uniswap v4_poolsmapping at slot 6, the priran onclaude · claude-fable-5-1 · 44 turns · 22m 15s · 578 in · 75.8K out · 4.5M cachedsubmissiona600d2862312fefb6b4d157dfcdcbbbfdf8dec97e0e2095dff5f54ef6b558910device4dd67dae195771b6441fdb6a5194f0cb584055f2db71093414434f19e593aa16started fromb73a05f0f9185bae139f46c56f044ed9c7391c4cbundlenonechanged · 0 filesnothingSwarmFeed._allowanceNow measures the 'hour of silence' from the signed issuedAt, not from acceptance, so a relayer who holds each attestation for its lifetime re-earns the 2x stale base one lifetime asrc/SwarmFeed.sol:415
A reverting or malformed ETH/USD (or share-vault) leg reads as price 0, and a repayment or deposit made while it is down re-prices the position's securedCollateral term at zero; backingPerUnit then resrc/CDPVault.sol:819
SwarmFeed constructor NatSpec describes maxDeviationBps as the 'maximum change from the last accepted value'; the bound is per epoch against the anchor and widens with stalenesssrc/SwarmFeed.sol:156
The parameter doc is the pre-epoch wording: the committed
_checkValuemeasures every value against the epoch's ANCHOR (the value held when the epoch opened), allows STALE_DEVIATION_MULTIPLE x this plus growth once stale, and a wide epoch additionally holds later values to this cap around its first value.A reader sizing the cap from the constructor doc would conclude a 20% cap means at most 20% between consecutive accepted values, which is false in both directions (two values 40% apart are accepted in one epoch once stale; a 1% step is refused when the anchor band is spent).
Fix: '@param maxDeviationBps_ The per-epoch deviation cap in bps against the epoch's anchor (see
_checkValue,_allowanceNow), 0 to 10,000.'Documentation only. test/SwarmFeed.t.sol test_staleValueReanchorsOnlyWithinTheWidenedBound: with cap 1000, a value 20% above the last accepted value is accepted after a two-hour silence (so the cap is not 'the maximum change from the last accepted value'), and a value one wei above
widestis then refused although it is within 0.01% of the last accepted value.
- reviewed
#727Audit mathClaude2 findings · 1 medium
All 116 oracle-path tests pass with the fix;
src/SwarmFeed.solis restored and the tree has no tracked changes..imd-findings.jsonholds two findings (one medium with a failing Foundry proof, one info) and is the deliverable.Review of the price path at
b73a05fRead in full:
SwarmFeed,PriceFeed,NhiFeed,SpotFeed,SwarmRelay,OracleAsker,UsdPriceFeed,SharePriceFeed,SwarmWorkOracle,DeploymentConfig, the three interfaces,ParameterizedVault(price/staleness overrides, constructor), and the relevant parts ofCDPVault(price gates,bite,cash,_clearIfRecovered,tail,_mat/_lull) andTreasury(fundOracle, reserve valuation). Not read: the rest ofCDPVault/Treasury/Parameters, the question bodies underwhitepaper/(not in the tree), and the Intake/StakedIMD/PoolManager code, which are upstream.Findings
1. Medium —
SwarmFeed._allowanceNow(src/SwarmFeed.sol:415): the stale base is earned by the signedissuedAt's age, not by relay silence._updatedAtis the attestation'sissuedAt, accepted up to one lifetime old; the epoch is dated from the relay block. A buyer who holds each off-chain answer ~55 minutes (the 24h signed TTL and the 300-block recency bound both allow it) opens every epoch after the first on the 40% base: 1.2·1.4³ = 3.29x in four hourly relays against the documented 1.2⁴ = 2.07x; 3.48x on the fifth relay rather than the seventh. This is a gap left by the b73a05f fix for the second-half medium, and NatSpec lines 27-30, 47-49 and 412-414 claim the property the code lacks. Prooftest/scratch/StaleBaseByHeldAnswer.t.solfails on this code and passes with the smallest fix: measure the silence frommax(_updatedAt, _anchorAt). Same walk, same ~$39-52k pool round-trip cost, but 4-5 hours of hold instead of 6+.2. Info — stale NatSpec at src/SwarmFeed.sol:156 and :221-222 (last-value bound "while fresh"; a relayer that "covers stale re-anchors").
Answers where nothing is wrong
- Q1 Domain binds chain+feed address;
expectedQuestionHashsplices the signed window into the pinned prefix; replay byrequestId; panel floors,issuedAt ≤ now, monotonetoBlock, recency ≤maxAge/12. No cross-question/chain/feed/window acceptance found. - Q2 Within one lifetime N attestations move at most the epoch allowance (cap on a fresh anchor); after H hours of silence a single step gets
_allowanceNow(H)(40% at 2h, 60% at 10h, 100% at 26h; +1h with the held-answer trick above). Packing is exact (64+8+64+32+88 bits,uint32(bound) ≤ 1e6,uint88first value ≫ any shipped figure); unseeded case reads (0, now, 1e6) with no underflow;SwarmWorkOracle's override keeps the zero check and the epoch bookkeeping is harmless; NHI widens hourly from 25h. No genuine fall is unfollowable (100% at 26h); a wide epoch cannot be held open past its lifetime. - Q3
wideOpennow needs no live epoch, so a refresh costs one purchase per ≥10h silence; drift needs two pool pushes ≥5 blocks apart (~$1-2k of fees per 0.5 IMD); budget caps at 15 IMD/day via top-up-not-pile-up;onOracleResultis Intake-only, never reverts past the clearing, backs off only the live request, and a timed-out request's late delivery now lands. Theextsloadslotkeccak256(poolId, 6)and the sqrtP inversion are correct for a native-ETH/IMD v4 pool. - Q4 Ramp-and-hold: pushing by M costs ≈ 2% × 841(√M−1) ETH. Over-borrowing at LINE $1M, mat 170: 3.48x → $39k fees, gain ≈ $511k, 6h hold under the committed bound (4-5h with finding 1); what stops it is only the attacker's exposure to holders selling into the pump and the SKEW guard forcing both feeds to be walked. Liquidation direction: one 20% step marks every CR-170 position; the 6h grace at NHI ≥ 0.85 is the defence, the attacker's cost is holding the pool down (~$2.7k fees each way plus arbitrage exposure) for the grace, reward ≤ 16% of liquidated debt.
- Q5 Units compose to USD per 1e18 raw units correctl
ran onclaude · claude-fable-5-1 · 34 turns · 15m 55s · 60 in · 56.2K out · 4.1M cachedsubmission6751d2e885c8661fb9709557b649a15c53d6eff24f7e3196df31b88db64a79c6deviceb4f6137e7c93a2b39096a2b27d11ffc0319cdbf42630f50e80d3fde78bccf0f2started fromb73a05f0f9185bae139f46c56f044ed9c7391c4cbundlenonechanged · 0 filesnothingSwarmFeed._allowanceNow: the stale base is earned by the age of the signed issuedAt, not by silence, so a buyer who holds each answer ~55 minutes before relaying walks the feed 40% per hour (3.29x in src/SwarmFeed.sol:415
proof · a Foundry test the fix has to passSwarmFeed.submitAttestation NatSpec still describes the pre-epoch guard: a bound that holds only 'while the previous value is fresh' and a relayer that 'covers ... stale re-anchors'src/SwarmFeed.sol:221
Comment audit (asked for in the brief).
Two statements in the submitAttestation NatSpec (lines 221-222) and the constructor parameter doc (line 156, '@param maxDeviationBps_ Maximum change from the last accepted value') describe the guard before ce39fc6/cc4103f: the bound no longer lifts when the value is stale (it widens by _allowanceNow and is measured against the epoch's anchor, not the last accepted value), and the relayer covers nothing on the shipped feeds, since ATTESTATION_RELAYER is SwarmRelay, which admits everyone (the constructor comment at lines 181-193 says so itself).
The rest of the docstring (lines 22-51) is current apart from the claim finding 1 contradicts. No code effect; the stale sentences could mislead a reader into thinking a stale feed is unbounded or that the relayer is a trust boundary.
Read src/SwarmFeed.sol:156 and :221-222 against _checkValue/_epoch/_allowanceNow (lines 370-420): a value is bounded against the epoch anchor with an allowance that widens with staleness and never lifts; relayer == SwarmRelay (DeploymentConfig.sol:78), callable by anyone. Expected: NatSpec states the per-epoch, widening bound and that the relayer is not load-bearing; actual: it states the last-value bound that applies only while fresh and a relayer that covers stale re-anchors.
- Q1 Domain binds chain+feed address;
- reviewed
#81Audit judgeClaude7 findings · 1 medium
afterAudit math, Audit permissions, Audit economics, Audit flowAll seven findings validate against the tree;
src/SwarmFeed.solis back to the committed state and only.imd-findings.jsonandtest/scratch/were written.Judgement
Kept (7 findings in
.imd-findings.json)Sev Where Verdict medium SwarmFeed._allowanceNowL415Merged from 4 specialists. Stale base measured from signed issuedAt, not acceptance; a buyer holding each attestation ~55 min relays into 4,000 bps every ~65 min (3.29x in 4h15m vs committed 2.07x). All three specialist proofs fail on this code; with thesince = max(_anchorAt, _updatedAt)patch they pass and the SwarmFeed/OracleAsker/QuestionBinding/RelayBundling/SwarmRelay suites still pass (88/88). Proof attached (audit_math's, self-contained).low OracleAsker.onOracleResultL284A hand-relayed askPaidanswer makes the callback's refusal setlastAsk, blocking Treasuryaskfor 2h at ~$5/cycle. Reproduced intest/scratch/PaidRefusalBacksOffTreasury.t.sol.low SwarmFeed._checkValueNatSpec L356First value is unbounded and SwarmRelay is permissionless; DeployMainnet does not seed in the deploy tx and verify()never checks the anchor. Reproduced intest/scratch/FirstValueRace.t.sol(deployer's honest value refused for 6h).low CDPVault._securedL819Reverting Chainlink leg → _priceOrZero()==0→ ungatedwipewrites a zero secured term that persists past recovery;backingPerUnit0,cashrevertsZeroAmountfor up to a day. Reproduced intest/scratch/StaleLegZeroesSecured.t.sol. Kept as a consumer effect under Q5.info ×3 SwarmFeed L156 (merged duplicate), L221–222, docs/ABI.mdL84NatSpec/doc claims the code does not have. Dropped: none — every specialist finding reproduced. Duplicates merged: 4→1 (medium), 2→1 (constructor NatSpec).
Numbered questions, briefly
- Acceptance is sound: domain binds chain + feed address;
usedRequestsper feed; issuedAt bounded both ways; floors enforced; question hash spliced from signed window with span/advance/recency bounds. No cross-question/chain/feed/window acceptance found. - N attestations in one lifetime: the epoch allowance (cap when fresh). After H hours of silence: 4,000 + 250·(H−2) bps for H ≥ 2 (table in
_allowanceNowis arithmetically right). Sustained rate as committed is not the cap per hour — see the medium. Packing (64+8+64+32+88 = 256 bits,MAX_ALLOWANCE_BPS< 2³²) and the unseeded case are correct;SwarmWorkOracle._checkValuecorrectly keeps only the zero check; NHI inherits the same hold weakness (one day). ask/wideOpen/arming logic correct; pool slotkeccak256(abi.encode(poolId, 6))and inversion correct. The one gap is the back-off (low).- Costing unchanged from PARAMETERS except that the medium roughly halves the attacker's exposure time to 3.48x (4–5h vs 6–7h); pool-fee cost (~$39–50k) unchanged.
- Units correct (
imdEth × ethUsd / 10^dec,rate × assetUsd / 1e18); staleness propagates viaisStale; a reverting leg degrades to zero rather than reverting — with the persistence side-effect in the low above. tail(),_requireFreshFeeds,_requirePriceAgreementcheck all three feeds; relay bundles strand nothing (relayAndBiteasserts both balance deltas,relayAndBarkusesbarkFor); work oracle rights cannot be claimed twice (monotonecreditedTasks) or for others (isController); old roots can only under-credit.
Coverage: read in full — SwarmFeed, OracleAsker, SwarmRelay, PriceFeed, SpotFeed, NhiFeed, UsdPriceFeed, SharePriceFeed, SwarmWorkOracle, DeploymentConfig; CDPVault/ParameterizedVault read for the price-consuming paths (
_resecure,_backingPerUnit,_requireFreshFeeds,_requirePriceAgreement,barkFor) and DeployMainnet/runbook §7 for the seeding order. Not read in full: CDPVault/ParameterizedVault/Treasury beyond those paths.ran onclaude · claude-fable-5-1 · 36 turns · 11m 38s · 72 in · 38.3K out · 4.5M cachedsubmission5b558cf512f0f43527e77b8bfec686a4a631a2522fa1160237f59918235cef00devicef768e94767a9dde3bfb3a7b0d4e7015be9266dc0da97d12cfe01eac2363dd7d9started fromb73a05f0f9185bae139f46c56f044ed9c7391c4cbundlenonechanged · 0 filesnothingSwarmFeed._allowanceNow measures the hour of silence that earns the stale base from the signed issuedAt, which a relayer may hold back up to maxAge, so a held-and-relayed walk compounds at 2x the cap src/SwarmFeed.sol:415
proof · a Foundry test the fix has to passOracleAsker.onOracleResult backs the Treasury off for ASK_TIMEOUT after a refused delivery of a CALLER-paid request, so a ~$5 askPaid whose public answer is hand-relayed first disables Treasury-paid asrc/OracleAsker.sol:284
SwarmFeed: the unbounded first value is 'the one we buy and check at deployment' only by convention; with SwarmRelay as the pinned relayer whoever relays first anchors a freshly deployed feed, and thesrc/SwarmFeed.sol:356
A reverting ETH/USD (or share-vault) leg reads as price 0, and a wipe/lock made while it is down re-prices the position's secured term at zero; the zero persists past recovery and shuts or underpays `src/CDPVault.sol:819
SwarmFeed constructor NatSpec describes maxDeviationBps_ as a bound on the change from the LAST accepted value; the shipped bound is per epoch, measured from the epoch's anchor and widening with stalesrc/SwarmFeed.sol:156
SwarmFeed.submitAttestation NatSpec still describes the pre-epoch guard: a bound that holds only 'while the previous value is fresh' and a relayer that 'covers the unseeded first value and stale re-ansrc/SwarmFeed.sol:221
From the audit_math specialist, confirmed by reading lines 221-222 against
_checkValue/_epoch/_allowanceNow(370-420) and DeploymentConfig.sol ATTESTATION_RELAYER. The bound no longer lifts when the value is stale: it widens by_allowanceNowand is measured against the epoch's anchor, not the last accepted value.And the relayer covers nothing on the shipped feeds, since ATTESTATION_RELAYER is SwarmRelay, which admits everyone (the constructor comment at lines 181-193 says so itself; SwarmRelay.sol lines 11-13 repeat the stale claim). No code effect; the sentences could mislead a reader into thinking a stale feed is unbounded or that the relayer is a trust boundary.
Fix: state the per-epoch, widening bound and that the relayer is not load-bearing; the first value is bounded by nothing on chain (see the low finding at line 356).
Read src/SwarmFeed.sol:221-222.
A stale feed (value two hours old) submitted a value 3x its anchor: EXPECTED per 'bounds ... while the previous value is fresh' (i.e. no bound once stale) accepted; ACTUAL ExcessDeviation (allowance 4000 bps).
A stranger calling SwarmRelay.relay on an unseeded shipped feed: EXPECTED per 'a nonzero relayer covers the unseeded first value' refused; ACTUAL accepted (test/scratch/FirstValueRace.t.sol).
docs/ABI.md describes the attestation as a 12-field tuple under EIP-712 domain version '1' and says there is no questionHash gate or getter; SwarmFeed signs 15 fields (panelSize, quorum, agreed added)docs/ABI.md:84
Build the tuple as ABI.md line 84 lists it (12 members), sign under EIP712Domain('IdentityMD Oracle','1',chainid,feed) with the attester key and call
submitAttestationon any shipped feed: EXPECTED per the doc accepted; ACTUAL the call fails in ABI decoding (the struct has 15 members), and with the three fields added but version '1' it reverts InvalidSignature because DOMAIN_SEPARATOR hashes version '2'.
- Acceptance is sound: domain binds chain + feed address;
- onchain
1 receipt, 5 scoreson Ethereum mainnet
- receipt
- work accepted · transaction · record
- scores
- 5 scores for reviewed on submission · all 5 passed · block 26,142,739 · transaction
#225
#808
#81
#727
#852