# Audit report

> 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); this panel audits the code as it will deploy, so a finding of an earlier round counts only if its fix regressed or left a gap.
>
> imdUSD is a dollar-denominated CDP stablecoin borrowed against sIMD (IdentityMD's staked IMD, an ERC-4626 share with 24 decimals, about 7.95 IMD each). Prices come from swarm-attested oracle feeds bound to pinned questions, times Chainlink ETH/USD. Everything about the deployment is in src/DeploymentConfig.sol and docs/MAINNET-RUNBOOK.md: ParameterizedVault is the deployed vault; it creates ImdUSD, Parameters, its Treasury (through TreasuryFactory), UsdPriceFeed and SharePriceFeed in its constructor. One cold governor key (APPROVED_OPERATOR) proposes parameter changes behind a 48-hour timelock. Collateral pricing is per 1e18 raw units throughout. IMD's only market is a full-range Uniswap v4 pool, about $2.3M a side with a 1% fee; docs/PARAMETERS-2026-10-05.md has the numbers every economic parameter was chosen from.
>
> Answer each numbered question, including the ones where nothing is wrong:
> 1. Attestation acceptance: signature domain, replay (usedRequests), issuedAt freshness, panel floors, question binding over the window (expectedQuestionHash, WindowTooOld, WindowNotAdvancing). Can an attestation for a different question, chain, feed or window be accepted?
> 2. The per-epoch deviation bound, as committed: every value within one lifetime of an epoch's start lies within the epoch's allowance of the ANCHOR; _allowanceNow is the cap while the value is fresh and through its first hour stale, then STALE_DEVIATION_MULTIPLE x cap plus an eighth of the cap per further whole hour (STALE_GROWTH_PERIOD), capped at MAX_ALLOWANCE_BPS; an epoch opened wider than the cap records its first value (_epochFirst, uint88 packed beside _updatedAt) and holds every later value in it to the cap around that value. State the largest move N attestations relayed within one lifetime can produce, the largest a single attestation can produce after H hours of silence, and the fastest sustained rate over a run of hours (the claim is the cap per hour after the first step); find any sequence faster, any way to keep a wide epoch open for a later value, any genuine gap that can never be followed, and check the uint32/uint88 packing, the unseeded case (_updatedAt 0), SwarmWorkOracle's override of _checkValue, and the one-day NHI feed under the same hourly schedule.
> 3. OracleAsker: ask (keep-alive near stale; wideOpen = stale AND no live epoch AND allowance >= WIDE_ALLOWANCE_BPS; or an armed fall still present ARM_DELAY_BLOCKS later, never a rise), askPaid and askPaidMany (caller pays, in-flight feeds skipped uncharged), _request (a timed-out request keeps feedOf so its late delivery lands), onOracleResult (Intake-only, 200k-gas stipend, never reverts past the clearing, back-off only for the live request). Can anyone make the Treasury pay when the chain does not justify it, spend more than the daily budget, block updates for everyone, lose a paid answer, or feed a moved pool price into the trigger? The pool read is extsload of slot keccak256(IMD_POOL_ID, 6).
> 4. The walk, costed: a ramp-and-hold of IMD's v4 pool (841 ETH / 207,881 IMD, 1% fee) that moves the feed the cap per hour against LINE $1M and mat 170: what it costs, what it earns, what stops it, in both directions (over-borrowing and forced liquidation).
> 5. Price composition: UsdPriceFeed (IMD/ETH x Chainlink ETH/USD, 2-hour max age) and SharePriceFeed (exchange rate x IMD/USD per 1e18 raw units). Units, staleness propagation, what a reverting or non-standard asset leg does to every consumer.
> 6. Feed lifetimes (price and spot 1 hour, NHI 1 day, tail() derived) and the vault acting on a value older than intended; SwarmRelay bundles (relay, relayMany, relayAndBark, relayAndBite) stranding funds or skipping a check; SwarmWorkOracle rights claimed twice, for someone else, or against a stale root.
>
> Not findings: addresses in DeploymentConfig that are placeholders until deployment (INTAKE, ORACLE_ASKER, TREASURY_FACTORY, WORK_ORACLE_FACTORY); the mocks (MockIMD, MockWorkOracle, LaunchToken); script/checks/ (a separate, partly stale tree); web/ and points/; anything docs/COMPUTE-BACKING-DESIGN.md describes as future work; and findings of the earlier audits in docs/AUDIT-*.md and docs/INTERNAL-AUDIT-2026-10-04.md, unless the fix regressed. A constant set to a deliberate economic value is not a finding; an arithmetic or ordering error in how it is used is.
>
> For every finding: severity; file and function; the call sequence from an external caller; a concrete failing input or state with expected against actual; whether it is reachable with the constants as committed; and the smallest fix. Also report every place a comment or NatSpec claims a property the code does not have, and say which contracts you read in full and which you could not reach.

| | |
|---|---|
| Repository | https://github.com/fa11up/infer-protocol |
| Commit | `b73a05f0f9185bae139f46c56f044ed9c7391c4c` |
| Job | `f1346a93-e4d4-40da-9e2d-cf536b24e57a` |
| Judged | 2026-10-07 18:28 UTC |
| Findings | 1 medium · 3 low · 3 info |

Four agents audited the code as it is at `b73a05f`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: 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;
```

Merged from four specialist reports (audit_permissions, audit_flow, audit_economics, audit_math), all reproduced. The b73a05f fix for the second-half review's medium requires a whole STALE_GROWTH_PERIOD of staleness past maxAge before `_allowanceNow` returns STALE_DEVIATION_MULTIPLE x cap, but it measures that staleness from `_updatedAt`, which `_accept` (line 463) sets to the attestation's SIGNED `issuedAt`, while the epoch itself is dated from the relay block (`_anchorAt = block.timestamp`, line 455). `submitAttestation` admits an issuedAt up to maxAge old (line 233, `_tooOld(a.issuedAt)` is false at exactly one lifetime) and `_requireQuestion` admits a window that closed up to maxAge/12 = 300 blocks ago (line 309); the shipped bodies sign `validForSeconds: 86400`. So a buyer who holds each purchased attestation ~55-57 minutes before relaying it through the permissionless SwarmRelay opens every epoch with `_updatedAt` already ~an hour old. One lifetime plus a few minutes after that relay the stored epoch has expired AND `block.timestamp - _updatedAt - maxAge >= 1 hour`, so `periods == 1` and the next acceptance gets 4,000 bps at the shipped 2,000 cap. The walk then compounds at 1.4 per ~65 minutes instead of 1.2 per hour: from a fresh feed 1.2, 1.68, 2.35, 3.29x (T0+4h15m), 4.61x (T0+5h20m) against the committed 2.07x at 4h and 2.49x at 5h; 3.48x (the level docs/PARAMETERS-2026-10-05.md costs at ~$39k of pool fees against ~$500k over-borrowing at LINE $1M / mat 170) is reached in 4-5 hours of holding the pool instead of 6-7. The same hold works in the fall direction (forced liquidation) and on the one-day NHI feed (hold 23h59m; a value held that long reads 4,000 + 250 x 22 = 9,500 bps on the next step instead of the cap). It also shifts OracleAsker.wideOpen and the H-hours table one hour early relative to the actual relay. NatSpec claims the code does not have: SwarmFeed.sol lines 27-30 ('The stale base is earned by silence, never by timing ... a feed anyone keeps alive moves at most the cap per hour however it is driven'), lines 47-51 ('a run of steps compounds at no more than the cap per hour after the first'), and lines 412-414 inside `_allowanceNow`; docs/PARAMETERS-2026-10-05.md 'The walk, at the committed constants'. The regression test `test_relayingAnHourApartNeverEarnsTheStaleBase` only relays with issuedAt == block.timestamp, which is why it passes. Reachable with the constants as committed (maxAge 1h, cap 2000, spans 300..1200 / 150..1200, ATTESTATION_CHAIN_ID 1, SwarmRelay permissionless); needs no privileged role, only off-chain purchase of the attestations and a wait before relaying, which the runbook and NatSpec describe as the normal fallback. SMALLEST FIX (verified by the judge: all three specialist proofs pass and test/SwarmFeed.t.sol, OracleAsker*.t.sol, QuestionBinding, RelayBundling and SwarmRelay suites still pass): measure the silence from the later of the signed time and the epoch's open, in `_allowanceNow`: `uint256 since = _anchorAt > _updatedAt ? _anchorAt : _updatedAt; if (block.timestamp - since <= maxAge) return maxDeviationBps; uint256 periods = (block.timestamp - since - maxAge) / STALE_GROWTH_PERIOD;` — both fields are in the slot `_accept` already writes, so no gas change for the Intake's 200k stipend. (A mid-epoch late relay still cannot help the attacker: inside an epoch every value is bounded by the anchor's allowance, so the residual under-measurement from `_anchorAt` yields at most 40% per two hours, no faster than the cap per hour.) An exact alternative is a new `_acceptedAt` slot written in `_accept` (+22,100 gas on a first delivery, still inside the stipend). Then regenerate the PARAMETERS walk table and reword the three NatSpec passages.

**Reproduction**

HeldFeed (SwarmFeed with maxAge 1 hours, cap 2_000, relayer = test, attester key held by the test; the same policy as PriceFeed), chain id 1. T0: submit V = 3.55e15 with issuedAt = T0. T0+1h: submit 1.2V with issuedAt = T0+1h-55min (accepted: issuedAt >= _updatedAt and not too old; epoch opens, allowance 2000). T0+2h05m (65 minutes after that relay): EXPECTED per NatSpec 27-30 feed.epoch() allowanceBps == 2000 and a figure 1.68V (a 40% step) refused with ExcessDeviation; ACTUAL allowanceBps == 4000 and 1.68V is accepted (issuedAt = now - 55min). Repeating every 65 minutes: 2.352V at T0+3h10m, 3.293V at T0+4h15m, latestValue 11,689,440,000,000,000 against the cap-per-hour ceiling 1.2^4 V = 7,361,280,000,000,000. The attached test (test/scratch/Proof_bf1bb6b60b3d.t.sol, from the audit_math specialist) fails on the committed code with '4000 > 2000' and '11689440000000000 > 7361280000000001' and passes with the `since = max(_anchorAt, _updatedAt)` patch above. The same reproduction with a question-bound feed (span 300..1200, toBlock = head-300, issuedAt = toBlock*12+130) in test/scratch/Proof_95b1f17cb9d0.t.sol also fails on this code and passes with the fix.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {SwarmFeed} from "src/SwarmFeed.sol";

/// @dev The PriceFeed's launch policy (1-hour lifetime, 2,000 bps cap) with an attester whose key this
/// test holds and no pinned question, so only the epoch bound is exercised.
contract HeldFeed is SwarmFeed {
    constructor(address attester_, address relayer_) SwarmFeed(attester_, relayer_, 1, 3, 1 hours, 2_000) {}
}

/// @notice FINDING: the stale base is earned by the age of the signed `issuedAt`, not by silence of the
/// feed. `_allowanceNow` measures "whole periods stale" from `_updatedAt`, which `_accept` sets to the
/// attestation's signed issue time, while the epoch is measured from the block the value was relayed in.
/// `submitAttestation` accepts an issuedAt up to one lifetime old, so a buyer who holds every answer for
/// most of an hour before relaying it opens every epoch on a value the feed believes has been stale for
/// a whole hour, and takes STALE_DEVIATION_MULTIPLE x the cap (40%) at every step, one step an hour.
///
/// The NatSpec (SwarmFeed.sol:27-30, 47-49, 410-414) says the stale base is "earned by silence, never by
/// timing" and that "a feed anyone keeps alive moves at most the cap per hour however it is driven".
/// This test keeps the feed alive with one relay an hour and moves it 1.2 x 1.4^3 = 3.29x in four hours
/// where the cap per hour allows 1.2^4 = 2.07x. It fails on the committed code and passes once the
/// allowance's staleness is measured from the later of the signed time and the epoch's open (the relay).
contract StaleBaseByHeldAnswerTest is Test {
    uint256 private constant ATTESTER_KEY = 0xA11CE;
    uint256 private constant V = 3_550_000 gwei; // ~0.00355 ETH per IMD, the live figure
    uint256 private constant HOLD = 1 hours - 5 minutes; // the panel answers minutes after the window closes
    HeldFeed private feed;
    uint256 private nonce;

    function setUp() public {
        vm.chainId(1);
        vm.warp(10 days);
        vm.roll(1_000_000);
        feed = new HeldFeed(vm.addr(ATTESTER_KEY), address(this));
        // Honest seed, issued now.
        _submit(V, uint64(block.timestamp));
    }

    /// @dev One relay an hour (plus the five minutes the hold leaves), every answer held HOLD before it
    /// is relayed. Expected under the documented bound: each step is at most the cap (20%). Actual: the
    /// second step onwards opens on the 40% stale base.
    function test_aFeedKeptAliveHourlyMovesAtMostTheCapPerHour() public {
        uint256 cap = feed.maxDeviationBps();
        uint256 value = V;
        // Step 1, an hour after the seed: the epoch has run its lifetime, the allowance is the cap.
        _advance(1 hours);
        value = value * (10_000 + cap) / 10_000;
        _submit(value, uint64(block.timestamp - HOLD));
        // Steps 2-4: each relayed one hour and five minutes after the previous relay, so the feed has
        // never been silent for a whole hour past its lifetime, yet its allowance reads 2x the cap.
        for (uint256 i; i < 3; ++i) {
            _advance(1 hours + 5 minutes);
            (,, uint256 allowance) = feed.epoch();
            assertLe(allowance, cap, "an hourly-relayed feed must not open its epoch on the stale base");
            // What the code actually lets through: a 40% step.
            uint256 attempt = value * (10_000 + 2 * cap) / 10_000;
            bool accepted = _try(attempt, uint64(block.timestamp - HOLD));
            assertFalse(accepted, "a 40% step was accepted an hour after the last relay");
            // Keep the feed alive either way: the honest cap step is what an hourly relayer sends.
            value = accepted ? attempt : value * (10_000 + cap) / 10_000;
            if (!accepted) _submit(value, uint64(block.timestamp - HOLD));
        }
    }

    /// @dev The same walk expressed as a bound on the end value: four hourly relays may multiply the
    /// feed by at most 1.2^4 = 2.0736 with a 2,000 bps cap.
    function test_fourHourlyRelaysMoveTheFeedAtMostTheCapToTheFourth() public {
        uint256 value = V;
        _advance(1 hours);
        value = value * 12 / 10;
        _submit(value, uint64(block.timestamp - HOLD));
        for (uint256 i; i < 3; ++i) {
            _advance(1 hours + 5 minutes);
            uint256 attempt = value * 14 / 10;
            if (_try(attempt, uint64(block.timestamp - HOLD))) value = attempt;
        }
        (uint256 got,) = feed.latestValue();
        assertLe(got, V * 20_736 / 10_000 + 1, "four hourly relays walked the feed past 1.2^4");
    }

    function _advance(uint256 secs) private {
        vm.warp(block.timestamp + secs);
        vm.roll(block.number + secs / 12);
    }

    function _submit(uint256 figure, uint64 issuedAt) private {
        SwarmFeed.OracleAttestation memory a = _attestation(figure, issuedAt);
        feed.submitAttestation(a, _sign(a));
    }

    function _try(uint256 figure, uint64 issuedAt) private returns (bool ok) {
        SwarmFeed.OracleAttestation memory a = _attestation(figure, issuedAt);
        bytes memory sig = _sign(a);
        try feed.submitAttestation(a, sig) {
            ok = true;
        } catch (bytes memory reason) {
            assertEq(bytes4(reason), SwarmFeed.ExcessDeviation.selector, "refused for another reason");
        }
    }

    function _attestation(uint256 figure, uint64 issuedAt) private returns (SwarmFeed.OracleAttestation memory a) {
        a.requestId = keccak256(abi.encode("request", ++nonce));
        a.chainId = 1;
        a.questionHash = keccak256("question");
        a.answerType = 3;
        a.answer = abi.encode(figure);
        a.figure = figure;
        a.fromBlock = uint64(block.number - 300);
        a.toBlock = uint64(block.number);
        a.blockHash = keccak256("block");
        a.panelJobId = keccak256("panel");
        a.panelSize = 60;
        a.quorum = 20;
        a.agreed = 40;
        a.issuedAt = issuedAt;
        a.expiresAt = uint64(block.timestamp + 1 days);
    }

    function _sign(SwarmFeed.OracleAttestation memory a) private view returns (bytes memory) {
        bytes32 body = keccak256(
            bytes.concat(
                abi.encode(
                    feed.ATTESTATION_TYPEHASH(),
                    a.requestId,
                    a.chainId,
                    a.questionHash,
                    a.answerType,
                    keccak256(a.answer),
                    a.figure,
                    a.fromBlock
                ),
                abi.encode(
                    a.toBlock, a.blockHash, a.panelJobId, a.panelSize, a.quorum, a.agreed, a.issuedAt, a.expiresAt
                )
            )
        );
        (uint8 v, bytes32 r, bytes32 s) =
            vm.sign(ATTESTER_KEY, keccak256(abi.encodePacked("\x19\x01", feed.DOMAIN_SEPARATOR(), body)));
        return abi.encodePacked(r, s, v);
    }
}
```

### 2. Low: OracleAsker.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 a

`src/OracleAsker.sol:284`

```
            if (live) f.lastAsk = uint64(block.timestamp + ASK_TIMEOUT - ASK_MIN_INTERVAL);
```

Reproduced from the audit_permissions specialist. The back-off was written for a Treasury-bought answer the feed refused ('A refused answer must not be bought again ten minutes later') and the catch comment says 'askPaid is unaffected: its caller pays'. But `live` (line 265) is true for ANY request still in the feed's in-flight slot, including one bought with `askPaid`/`askPaidMany`, and `lastAsk` is the gate `ask` applies to Treasury money (`TooSoon`, lines 156-158). The plane publishes the signed attestation before the Intake's callback lands and SwarmRelay admits everyone, so the buyer (or anyone watching) relays the same bytes first; the callback's `relay` then reverts `ReplayedAttestation`, the catch writes `lastAsk = now + 2h - 10m`, and `ask` reverts `TooSoon` for two hours whatever the pool does. Repeated every two hours this costs 0.5 IMD (~$5) per cycle, about $60 a day, and removes the Treasury as a buyer: an armed 5% fall is not bought (collateral stays over-valued until the hour-old value goes stale and the vault pauses), the NHI keep-alive at 18h is not bought, a wide-open silent feed is not refreshed. Manual fallbacks (askPaid by a keeper, hand relay) remain and nothing is mispriced, so low. The NatSpec claim the code does not have: lines 281-282 'askPaid is unaffected: its caller pays' is true of the gate on askPaid but not of what a paid request's refusal does to the Treasury's own gate. Smallest fix: remember who paid and back off only on the Treasury's own refusal: add `bool treasuryPaid;` to `Feed` (the struct's second slot has room), set it in `ask` and clear it in `askPaid`/`askPaidMany` where they take the slot, and change the catch to `if (live && f.treasuryPaid) f.lastAsk = ...`. Alternatively do not back off on `ReplayedAttestation`/`WindowNotAdvancing`, which only say the answer already landed.

**Reproduction**

test/scratch/PaidRefusalBacksOffTreasury.t.sol (passes on this code: it demonstrates the state). Fixture as test/OracleAsker.t.sol: MockIntake at INTAKE, SwarmRelay at ATTESTATION_RELAYER, MockPoolManager at POOL_MANAGER, ConfigurableSwarmFeed(maxAge 1h, cap 2000) seeded at 0.001 ETH, asker holding 15 IMD, tracksPool true, keepAlive false. ATTACKER approves 0.5 IMD and calls askPaid(priceFeed, body, 0.5e18) -> R. ATTACKER calls SwarmRelay.relay(priceFeed, a, sig) with the attestation signed for R (figure 0.001 ETH, issuedAt now): accepted. The Intake completes R with the same bytes: Delivered(relayed=false) and `feeds(priceFeed).lastAsk == now + 2h - 10min` (asserted). Ten minutes later the pool slot0 is set to a 10% fall, `arm(priceFeed)` succeeds, five blocks later `ask(priceFeed, body)`: EXPECTED the Treasury pays the Intake 0.5 IMD (an armed fall still present, its documented trigger); ACTUAL reverts TooSoon(lastAsk + ASK_MIN_INTERVAL) and the asker's balance is unchanged (asserted).

### 3. Low: 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 the

`src/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.
```

Reproduced from the audit_flow specialist. `_checkValue` applies no deviation bound while `_hasValue` is false (line 372), and the NatSpec here and at lines 221-222 ('a nonzero relayer covers the unseeded first value and stale re-anchors') says the first value is the deployer's. On mainnet the pinned relayer is SwarmRelay, which forwards for anyone (constructor comment, lines 181-193), the question bodies are public (deploy/mainnet/bodies, `{{FEED}}` filled with the planned CREATE2 address) and script/DeployMainnet.s.sol deploys the feeds without seeding them: docs/MAINNET-RUNBOOK.md section 7.1 buys and relays the first attestations as a later operational act, and `verify()` checks relayer/attester/policy but never the anchor against the pool. So the first accepted value of PriceFeed, SpotFeed and NhiFeed is set by whoever relays first, and ParameterizedVault (permissionless from its constructor; 'only then open deposits' is an announcement, not an on-chain gate) prices every position off that anchor. A first value at 2x market is a signed, honest answer to the pinned question if the pool is held at 2x for 7 of the window's 13 samples (the Q4 ramp-and-hold, without a prior walk or any silence). The deployer's honest attestation (market, 0.5x the anchor) is refused ExcessDeviation for the first epoch's hour and afterwards until `_allowanceNow` reaches 5,000 bps, which is periods >= 5, six hours after the attacker's issuedAt (five with the hold of the medium finding). Reachable with the constants as committed; it is a race the attacker must win in the minutes between deployment and the deployer's relay while holding a pumped pool through a window, so low. NatSpec claims the code does not have: lines 356-357 and 221-222. Smallest fix, either: have DeployMainnet buy the three attestations for the planned addresses before the broadcast and relay them in the same transaction as the deployment (the attester signs for the consumer address in the body, which the plan already fixes), so no block exists in which the feeds are unseeded; or make the first anchor checkable before deposits are announced, e.g. `verify()` requires `|priceFeed.latestValue() - asker.poolPrice()| <= cap`. Reword 356-357 and 221-222 to say the first value is bounded by nothing on chain and belongs to whoever relays first.

**Reproduction**

test/scratch/FirstValueRace.t.sol (passes on this code: it demonstrates the state). RaceFeed(maxAge 1h, cap 2000, relayer = a fresh SwarmRelay), no value. STRANGER calls SwarmRelay.relay(feed, a, sig) with a valid attester-signed attestation, figure 2V (V = 3.55e15): EXPECTED per the NatSpec the first value is the deployer's; ACTUAL accepted, epoch() reports anchor 2V, allowance 2000. DEPLOYER relays an honest attestation with figure V: reverts ExcessDeviation (|V - 2V| = V > 0.2 x 2V). At T+5h59m the allowance is 4750 and V is still refused; at T+6h00m01s the allowance is 5000 and V is finally accepted.

### 4. Low: 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`

```
        if (principal == 0 || price == 0) return 0;
```

Reproduced from the audit_economics specialist (Q5: what a reverting or non-standard leg does to every consumer). UsdPriceFeed._ethUsd maps an aggregator that reverts or answers malformed to (0, 0, 0), so UsdPriceFeed.latestValue returns (0, 0) and SharePriceFeed.latestValue returns (0, 0) too (also when sIMD's convertToAssets reverts); ParameterizedVault._priceOrZero then returns 0. Price actions are refused (StaleFeed), the documented safe direction, but `lock`, `lockIMD` and `wipe` are deliberately ungated and each calls `_resecure(position, _priceOrZero())` (CDPVault.sol lines 378, 403, 1151); `_secured` (this line) returns 0 for a zero price, so the position's whole term leaves `securedCollateral`, and `_clampLag` (lines 849-850) treats the drop as a real decrease and clamps `laggedSecured` down with it. Nothing re-prices the term when the leg recovers: only that borrower's next lock/free/draw/wipe does, and the restored amount then counts only as it warms up over BACKING_WARMUP (a day). `_backingPerUnit` (line 677) therefore reads 0 for a single-borrower vault (or is depressed by that borrower's share in general) after the leg is back, and `cash` computes payoutScale = 0 and reverts ZeroAmount (line 634), or pays below par, while every position is as collateralised as before; the redemption channel, the peg defence, is shut or underpaying for the rest of that day because of an oracle outage that is otherwise over. A merely STALE Chainlink answer does not do this (latestValue still returns the old figure); it needs the aggregator to revert or return malformed words, or sIMD's convertToAssets to revert, both cases the code explicitly handles by reading zero. Nothing is stolen and redeemers can protect themselves with minGemOut, so low. NatSpec at lines 813-815 ('an unpriced feed counts the position for nothing') describes the write but not that it persists past recovery and feeds the lag clamp. Smallest fix: in `_resecure`, when `price == 0` leave `position.secured` and `securedCollateral` untouched (skip the re-pricing) instead of writing a zero term.

**Reproduction**

test/scratch/StaleLegZeroesSecured.t.sol (passes on this code: it demonstrates the state). WorkBackingFixture (ParameterizedVault, $1 per collateral unit, Chainlink etched at CHAINLINK_ETH_USD). BORROWER locks 200e18 and draws 100e18; two days pass, a wipe(1e18) checkpoint warms the lag: securedCollateral ~198e18, backingPerUnit() == 1e18. vm.mockCallRevert on the aggregator's latestRoundData: collateralPriceFeed.isStale() is true, draw(1e18) reverts; BORROWER wipe(1e18) succeeds and securedCollateral becomes 0. Clear the mock and refresh the answer: isStale false, collateralRatio(BORROWER) >= 200, yet backingPerUnit() == 0 (EXPECTED 1e18) and cash(1e18, 0, BORROWER) reverts ZeroAmount. BORROWER wipe(1e18) again: backingPerUnit still 0 (lag warms from zero); one day later it reads 1e18 again.

### 5. Info: 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 stale

`src/SwarmFeed.sol:156`

```
    /// @param maxDeviationBps_ Maximum change from the last accepted value, from 0 to 10,000 bps.
```

Merged from audit_permissions and audit_economics (duplicates). Since cc4103f the 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 held when the epoch opened); the allowance is the cap only for an epoch opened on a fresh value, STALE_DEVIATION_MULTIPLE x cap and growing on a stale one, and a wide epoch additionally holds later values to the cap around its first value (`_checkValue`, `_epoch`, `_allowanceNow`, `_epochFirst`). The @param line still states the pre-epoch rule, and a reader sizing the cap from it would conclude 20% means at most 20% between consecutive accepted values, which is false in both directions. Documentation only. Fix: '@param maxDeviationBps_ The per-epoch deviation cap in bps against the epoch's anchor when the epoch opens on a fresh value; wider on a stale one (see `_checkValue`, `_allowanceNow`), 0 to 10,000.'

**Reproduction**

Feed with maxAge 1h and cap 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 anchor is V and 1.44V is 44% from it. Conversely, 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'.

### 6. Info: 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-an

`src/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 its
```

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 `_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; 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).

**Reproduction**

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. Info: 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`

```
`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.
```

From the audit_permissions specialist, confirmed against src/SwarmFeed.sol: ATTESTATION_TYPEHASH (lines 117-119) covers `...bytes32 panelJobId,uint16 panelSize,uint16 quorum,uint16 agreed,uint64 issuedAt,uint64 expiresAt` and DOMAIN_SEPARATOR (166-174) hashes version "2". ABI.md, the integrator-facing reference, documents the v1 shape (12 fields, `panelJobId` followed directly by `issuedAt`, domain version `1`), omits the panel floors (MIN_PANEL_SIZE 25, MIN_AGREED 15) the v2 fields enable, and the next paragraph (line 86) still says 'there is no immutable questionHash gate or getter' although `expectedQuestionHash(fromBlock, toBlock)` and `_requireQuestion` exist. An integrator encoding `submitAttestation` from this page produces calldata that does not decode or a digest the feed rejects. Documentation only; regenerate the paragraphs from the struct, the domain and `_requireQuestion` in SwarmFeed.sol.

**Reproduction**

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 `submitAttestation` on 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'.

---

Judge's submission `5b558cf512f0f43527e77b8bfec686a4a631a2522fa1160237f59918235cef00`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
