# Audit report

> Audit src/feeds/SwarmFeed.sol, src/feeds/SwarmFeeds.sol, src/feeds/ChainlinkFeed.sol, src/feeds/RateFeed.sol, src/feeds/ProductFeed.sol, src/feeds/LivePoolPrice.sol and src/feeds/DepthSampler.sol, src/feeds/ImdDepth.sol, at the pinned commit, for a capped mainnet beta. Read whatever else in src/ these depend on, but report on this scope.
>
> imdUSD v2 is a multi-collateral CDP stablecoin; this is its capped mainnet beta on Ethereum. Positions are NFT ids; borrowers set their own interest rate and redemptions take from the lowest rates first; liquidation is mark then bite, with a standing bid from Backstop sleeves; the peg pool is a Uniswap v4 pool with our own hook; prices come from Chainlink and from swarm-attested feeds (EIP-712 attestations bound to pinned questions). README.md gives the scope, the key invariants, the trust model and the known issues: read it first.
>
> BUILD AND TEST (required; Foundry, solc 0.8.26 via_ir per foundry.toml). Never build or test the whole project: with every suite it needs about 49 GB and the compiler will be killed. Run only this panel's tests: `forge test --match-path 'test/unit/{SwarmFeed,ChainlinkFeed,RateFeed,LiveLeg,DepthSampler,DepthMeasured,DepthRule,PanelDepth}.t.sol'` (measured: about 3.3 GB peak, 4 minutes). One build at a time on your machine. Write each proof as a new file test/unit/Proof<Name>.t.sol, add it to the glob and run it; paste it with its output into the finding, then delete it: your submission must leave the tree unchanged. With Foundry 1.9 or later, `forge test --use solar --no-match-path 'test/fork/*'` builds everything and runs all 1,179 tests in seconds under 1 GB (Solar accepts a little syntax solc refuses: confirm a proof with the solc command).
>
> This panel covers two areas whose money flows meet: give both the same depth, and answer every question.
>
> Answer each numbered question, including the ones where nothing is wrong:
> 1. ATTESTATION ACCEPTANCE (submitAttestation, the EIP-712 domain and struct, the pinned signer, s and v checks, requestId replay, questionHash binding, panel floors of 100 with 67 or 51 agreeing, the window and block rules, expiry). Get a leaf to accept an attestation for another leaf, another question or another chain, or the same one twice.
> 2. EPOCH CAP (_accept, _openEpoch, the anchor and its bound, 2x after a silence, _returnAnchor, _epochFirst). Walk a value further than the cap allows per epoch, or halt a feed so no honest value is accepted.
> 3. FRESHNESS (maxAge, isStale, updatedAt). Present a stale value as fresh, or a future one.
> 4. CHAINLINK LEGS (answer bounds, min and max answer clamps, updatedAt, decimals). A bad answer read as a price.
> 5. RATE AND PRODUCT FEEDS (RateFeed poke and checkpoint, ceiling growth; ProductFeed). Lock in a one-transaction dip, or a rate above what the source supports.
> 6. LIVE POOL PRICE (where the Pricer uses it: the payout leg and agree). What a flash move of the pool buys an attacker.
> 7. MEASURE (the tick walk, maxSteps, both sell directions, the reference price and its 400-tick guard, ManagerUnlocked). Overstate depth with a sandwich, a just-in-time LP, or a call from inside an unlock.
> 8. BUCKETS (24 hourly buckets, the 4th lowest of at least 20, MAX_RISE while a reading exists). The cheapest way to raise the reading, and the cheapest way to grief it to zero.
> 9. IMDDEPTH (day lows, four samples a day, the 7-day low, a missing day reading 0, the leg price). Raise the depth line, or close sIMD borrowing for a week, and at what cost.
> 10. For each attack, a dollar cost at the pool depths README.md reports, and whether it pays.
>
> Not findings: anything listed under README.md 'Known issues and accepted risks' (say so if you find it worse than written); src/bridge, src/dex, src/earn/LoopVault.sol and src/stocks (after the beta) unless a question names them; test/ and its mocks; gas optimisations unless they change behaviour.
>
> For every finding: severity by impact; file, line and the lines copied; the call sequence from an external caller; a concrete failing input or state with expected against actual; whether it is reachable with the values in script/DeployConfig.sol (BetaConfig); the smallest fix; and a Foundry test that fails on this commit, RUN as BUILD says and pasted in full in the finding with its output (the judge reruns it). The test/ tree has harnesses to reuse (test/utils/System.sol, the unit suites). Answer every numbered question, including those where nothing is wrong. Give a coverage row (holds, finding or unreached, with a reason) for every external function of the contracts in scope. Also report every comment or NatSpec that claims a property the code does not have.

| | |
|---|---|
| Repository | https://github.com/fa11up/imdusd-v2-audit.git |
| Commit | `2875ffb22a726f041bd6891f32f35ddc7cdb983d` |
| Job | `bd0ce553-aab0-46da-be9b-00db8049dbd7` |
| Judged | 2026-11-10 21:22 UTC |
| Findings | 1 medium · 5 low · 5 info |

Four agents audited the code as it is at `2875ffb`, 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: DepthSampler: a JIT-liquidity sandwich of the keeper's public sample() records one-block liquidity as depth; depth() doubles per day up to the posted ceiling at gas cost

`src/feeds/DepthSampler.sol:209`

```
    function sample() external returns (uint256 recorded0, uint256 recorded1) {
        if (manager.exttload(V4_UNLOCKED_SLOT) != bytes32(0)) revert ManagerUnlocked();
```

Merged from audit_math (low), audit_permissions (medium) and audit_economics (medium); reproduced with the economics specialist's proof and my own variant (same block, keeper = a separate EOA, attacker balances unchanged afterwards).

The header (lines 43-52) rests the anti-inflation argument on the keeper: 'an attacker who adds liquidity only around his own samples cannot raise a bucket, because the keeper's honest sample in the same hour sets it. To inflate the figure he must keep the liquidity in the pool whenever anyone may sample, all day'. The keeper's sample() is an ordinary public transaction. An LP who front-runs it with modifyLiquidity(+L) and back-runs it with modifyLiquidity(-L) in the same block (a bundle, or a plain mempool sandwich) has liquidity in the pool at the hour's only honest sample while holding it for one block. sample() only refuses calls made INSIDE an unlock callback (line 210); nothing in it can tell one-block liquidity from liquidity held all day (v4 exposes no position age). The first sample of the hour sets the bucket (lines 220-221) and only lower samples move it, so after 24 sandwiched hours every bucket is MAX_RISE x the resting depth and depth() reads 2x; the next day 4x; after 7 days ImdDepth's 7-day low follows, until Parameters.depthCap (BetaConfig DEPTH_IMD = 80,100e18) binds.

Call sequence (block N, every hour): attacker Router.modifyLiquidity(+L over the band) ; keeper DepthSampler.sample() ; attacker Router.modifyLiquidity(-L). Hourly ImdDepth.poke() is the keeper's own chore.

Reachable with BetaConfig: yes. All three samplers are listed at genesis with permissionless sampling, the keeper samples hourly from a public account, and the IMD position needs only ETH when placed one-sided above the price (the IMD-sell side the samplers report, soldIsToken0 = false).

Impact at the README's figures: measured depth 61,954 admits an sIMD line of 21,636; the ceiling 80,100 admits 33,733 (README 'At $80.1k that is 33,733'). The attack converts the posted ceiling from a safety cap into the effective depth: about +12,100 imdUSD of sIMD line backed by no liquidity a liquidator could sell into, and invariant 6's premise (depthIMD is measured) does not hold. Bounded by the ceiling and by the Safe's instant postLowerDepth, which is why this is medium.

Cost (Q10): two modifyLiquidity transactions per hour for 7 days, about 340 transactions, roughly $1.5k-5k of gas at 10-30 gwei; the position (about the size of the resting in-band liquidity, $60-120k of ETH for the 0xb07d pool) is exposed for one block and no fee is paid. It pays for a borrower who wants the extra line.

NatSpec claims this falsifies: lines 48-51 ('the keeper's honest sample in the same hour sets it', 'liquidity held all day IS depth').

Smallest fix: make a reading depend on liquidity that survives a block: sample() stores (measure(), block.number) as a pending reading and a later-block call records min(pending, measure now) into the bucket (a JIT position must then sit through a block anyone can trade against). Operationally, submit the keeper's sample through a private relay and alert on Sampled events whose sell1 exceeds the day's median; correct lines 43-52.

**Reproduction**

test/scratch/ProofDepthJitSandwich.t.sol (the proof below). State: local v4 PoolManager, pool X(18 dec, $2000 feed)/S (18 dec, $1 unit), five resting positions, DepthSampler(band 1500, maxDev 500, maxSteps 4096). 24 honest hourly samples read depth() = 10,301,648,335,867,592 (= measure() at rest). Then 24 hours in which each keeper sample is wrapped in +1e17/-1e17 liquidity over the X-sell side of the band in the same block. Expected (NatSpec): depth() <= honest, since measure() outside the sandwiches still equals honest (asserted). Actual: depth() = 20,603,296,671,735,184 = 2x (MAX_RISE binds; the raw bucket would be ~100x). Output: [FAIL: the window reading must not exceed liquidity the pool holds: 20603296671735184 > 10404664819226267] test_jitSandwichOfTheKeeperSampleInflatesTheWindow(). My variant with the beta's live reference (fee 10000, spacing 200, keeper 0xBEEF, attacker 1e23 liquidity over +/-2400 ticks for one block): honest 3,840.2e18 -> keeper's bucket 7,680.5e18, attacker balances equal before and after within 1e6 wei. Run as BUILD says: forge test --match-path 'test/{unit/{SwarmFeed,ChainlinkFeed,RateFeed,LiveLeg,DepthSampler,DepthMeasured,DepthRule,PanelDepth},scratch/*}.t.sol' (Foundry 1.9.0, solc 0.8.26 via_ir, isolate = true): 14 suites, 121 passed, 2 failed (the two scratch proofs, by design). The same run under --use solar gives the same result.

**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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {ModifyLiquidityParams, SwapParams} from "v4-core/src/types/PoolOperation.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {DepthSampler} from "src/feeds/DepthSampler.sol";
import {IFeed} from "src/interfaces/IFeed.sol";

contract Tok is ERC20 {
    constructor(string memory n) ERC20(n, n) {}
    function mint(address to, uint256 a) external { _mint(to, a); }
}

contract Feed is IFeed {
    uint256 public value;
    uint64 public at;
    uint256 public maxAge = 365 days;
    constructor(uint256 v) { set(v); }
    function set(uint256 v) public { value = v; at = uint64(block.timestamp); }
    function latestValue() external view returns (uint256, uint64) { return (value, at); }
    function isStale() external view returns (bool) { return value == 0 || block.timestamp - at > maxAge; }
}

contract Router is IUnlockCallback {
    IPoolManager immutable pm;
    constructor(IPoolManager p) { pm = p; }
    function addLiquidity(PoolKey memory key, ModifyLiquidityParams memory p) external {
        pm.unlock(abi.encode(true, key, abi.encode(p)));
    }
    function swap(PoolKey memory key, SwapParams memory p) external returns (BalanceDelta d) {
        d = abi.decode(pm.unlock(abi.encode(false, key, abi.encode(p))), (BalanceDelta));
    }
    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        (bool liq, PoolKey memory key, bytes memory inner) = abi.decode(data, (bool, PoolKey, bytes));
        BalanceDelta d;
        if (liq) (d,) = pm.modifyLiquidity(key, abi.decode(inner, (ModifyLiquidityParams)), "");
        else d = pm.swap(key, abi.decode(inner, (SwapParams)), "");
        _square(key.currency0, d.amount0());
        _square(key.currency1, d.amount1());
        return abi.encode(d);
    }
    function _square(Currency c, int128 delta) internal {
        if (delta < 0) {
            pm.sync(c);
            IERC20(Currency.unwrap(c)).transfer(address(pm), uint128(-delta));
            pm.settle();
        } else if (delta > 0) {
            pm.take(c, address(this), uint128(delta));
        }
    }
}

/// @notice A volatile token X ($2,000, feed) against a stablecoin S ($1, no feed), both 18 decimals, fee 0, spacing 10.
/// DepthSampler reports sales of X with band 1500 (-15%), maxDeviationTicks 500, maxSteps 4096 (the beta's shape).
contract ProofDepthJitSandwich is Test {
    using StateLibrary for IPoolManager;
    using PoolIdLibrary for PoolKey;

    IPoolManager pm;
    Router router;
    PoolKey key;
    Tok x;
    Tok s;
    Feed fx;
    DepthSampler sampler;
    bool xIs0;
    int24 constant SPACING = 10;
    uint256 constant PX = 2000e18;
    uint256 constant PS = 1e18;

    function setUp() public {
        vm.warp(1_800_000_000);
        pm = new PoolManager(address(this));
        router = new Router(pm);
        x = new Tok("X");
        s = new Tok("S");
        xIs0 = address(x) < address(s);
        (address c0, address c1) = xIs0 ? (address(x), address(s)) : (address(s), address(x));
        key = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 0, SPACING, IHooks(address(0)));
        fx = new Feed(PX);
        pm.initialize(key, _sqrtRef());
        x.mint(address(router), 1e40);
        s.mint(address(router), 1e40);
        int24 t = _tick();
        _add(t - 2400, t + 2400, 1e15);
        _add(t - 300, t + 110, 3e15);
        _add(t + 50, t + 900, 2e15);
        _add(t - 1500, t - 200, 4e15);
        _add(t + 1400, t + 1800, 5e15);
        (IFeed f0, IFeed f1) =
            xIs0 ? (IFeed(address(fx)), IFeed(address(0))) : (IFeed(address(0)), IFeed(address(fx)));
        sampler = new DepthSampler(pm, key, f0, f1, 1500, 500, 4096, xIs0);
    }

    function _sqrtRef() internal view returns (uint160) {
        (uint256 p0, uint256 p1) = xIs0 ? (PX, PS) : (PS, PX);
        return uint160(Math.sqrt(Math.mulDiv(p0, 1 << 192, p1)));
    }

    function _tick() internal view returns (int24 t) {
        (, t,,) = pm.getSlot0(key.toId());
    }

    function _align(int24 t) internal pure returns (int24) {
        int24 a = (t / SPACING) * SPACING;
        if (t < 0 && t % SPACING != 0) a -= SPACING;
        return a;
    }

    function _add(int24 lo, int24 hi, int256 l) internal {
        router.addLiquidity(key, ModifyLiquidityParams(_align(lo), _align(hi), l, bytes32(0)));
    }

    function _xSell() internal view returns (uint256) {
        (uint256 s0, uint256 s1) = sampler.measure();
        return xIs0 ? s0 : s1;
    }

    function _hour() internal {
        vm.warp(vm.getBlockTimestamp() + 1 hours);
        fx.set(PX);
    }

    /// @dev The one-sided range on the X-sell side of the band: it holds only S, never X, so it costs the attacker no X
    /// and no price exposure beyond the block of the keeper's sample.
    function _jitRange() internal view returns (int24 lo, int24 hi) {
        int24 t = _tick();
        return xIs0 ? (t - 1620, t - 10) : (t + 10, t + 1620);
    }

    /// FINDING: the keeper's `sample()` transaction is public, so an LP can add a one-sided position just before it and
    /// remove it just after (a JIT sandwich of the keeper, not of his own sample). No honest sample lands in that hour,
    /// so the hour's bucket is the inflated reading, and after a day of this the window reading doubles (MAX_RISE),
    /// although the pool never holds the liquidity outside the keeper's own transaction. The NatSpec's defence
    /// ("the keeper's honest sample in the same hour sets it"; "liquidity held all day IS depth") assumes the keeper's
    /// sample time cannot be observed.
    function test_jitSandwichOfTheKeeperSampleInflatesTheWindow() public {
        for (uint256 i; i < 24; ++i) {
            _hour();
            sampler.sample();
        }
        uint256 honest = _xSell();
        uint256 d0 = sampler.depth();
        assertApproxEqRel(d0, honest, 1e15, "24 honest hours read the honest depth");

        (int24 lo, int24 hi) = _jitRange();
        for (uint256 i; i < 24; ++i) {
            _hour();
            _add(lo, hi, 1e17); // attacker: front-run the keeper
            sampler.sample(); // keeper's public hourly sample
            _add(lo, hi, -1e17); // attacker: back-run, same block
        }
        assertApproxEqRel(_xSell(), honest, 1e15, "outside the sandwiches the pool holds only the honest liquidity");
        emit log_named_uint("honest depth (USD, 1e18)", honest);
        emit log_named_uint("window depth after 24 sandwiched samples", sampler.depth());
        // Expected (the NatSpec's claim): a reading only liquidity held through the hour can raise. Actual: 2 x honest.
        assertLe(sampler.depth(), honest * 101 / 100, "the window reading must not exceed liquidity the pool holds");
    }
}
```

### 2. Low: DepthSampler/LivePoolPrice NatSpec is false for the beta wiring: the live pool price is the band reference, so the 400-tick guard never fires and a push-sample-unwind fits in ONE transaction (depth gr

`src/feeds/DepthSampler.sol:41`

```
/// zero-capital form of a deflating sample. A push now needs real capital held across separate transactions.
```

Merged from audit_math (low + info), audit_permissions (low), audit_economics (low + its NatSpec item) and audit_flow (low): one mechanism, reproduced.

Three claims in scope are not properties of the shipped system. (1) DepthSampler lines 27-37: 'ANCHORED ON THE FEEDS, NOT ON THE POOL'S PRICE ... a price push before a sample does not change the reading: only positions do ... A sample is refused when the pool's tick is more than maxDeviationTicks from the reference tick ... Wire attested/Chainlink feeds, never a LivePoolPrice'. script/DeployConfig.sol:399 wires feed1 = F_IMD_USD_LIVE = ProductFeed(LivePoolPrice(0xb07d pool), ETH/USD) for all three samplers (the reason is given at :371-376 and in the README). For the two ETH/IMD samplers p0/p1 = ETH_USD / (live x ETH_USD) is the pool's own price, so refTick == tick, PoolOffReference can never fire, and the band follows any push; for the third (v1 imdUSD/IMD pool) the reference is the OTHER pool's live price. (2) Line 41: 'A push now needs real capital held across separate transactions'. sample() reads v4's transient unlock slot, which is cleared when unlock() returns, so a contract can unlock+swap+settle, call sample() with the manager locked, then unlock+swap back, all in one transaction: the capital is returned in the same transaction (flash-borrowable), and the attacker pays the LP fee twice (1% each way on 0xb07d) plus gas. (3) LivePoolPrice lines 12-13: 'THE REDEMPTION PAYOUT LEG ONLY ... never used to measure a borrower': it is the band reference of the samplers that size the sIMD line.

Relation to README 'Known issues: Depth griefing': the lowering of the sIMD line is accepted and measured there ('a 25% push costs about $9,998 of IMD'). That figure is the IMD notional sold; in the atomic form the attacker recovers it in the unwind, and the real cost is about 2% of it (~$200 for 25%, ~$450 for 50%) plus gas, with no capital held across blocks and no arbitrage exposure, i.e. about 50x cheaper than written. The impact is unchanged (it can only lower the line: 21,636 -> 10,500 for four sandwiched hours, or -> 4,765 at 50%, for about 8 days). The live reference also lets a push RAISE a reading by sqrt(price ratio) at constant liquidity, but that needs the pool held pushed at the keeper's sample (a sandwich of the keeper, finding 1) and is bounded by MAX_RISE; at the 1% fee it does not pay. Reported as low: a documentation and cost-model defect over an accepted risk, with the reading extreme being a bucket of 0 when the push leaves the band empty.

Call sequence (one transaction from any EOA via a helper): PoolManager.unlock -> swap IMD->ETH to sqrtP x sqrt(1.25) -> settle ; DepthSampler.sample() ; PoolManager.unlock -> swap back to the original sqrtP -> settle. Repeat in 4 UTC hours of one day; the keeper's own ImdDepth.poke() records it. Reachable with BetaConfig: yes (all three samplers use F_IMD_USD_LIVE).

Smallest fix: refuse a sample whose pool tick is more than maxDeviationTicks from the tick seen by the last sample taken in an EARLIER block (store (lastTick, lastBlock)); a push then has to be held across blocks with a sample in each, exposed to arbitrage, which restores the capital-at-risk the NatSpec describes. And rewrite DepthSampler lines 27-41 and LivePoolPrice 12-13 to state the live-reference wiring and what it gives up; correct the README cost figure.

**Reproduction**

test/scratch/JudgeRepro.t.sol, test_B_oneTransactionPushSampleUnwind_liveReference and test_B_poolOffReferenceNeverFiresWithLiveReference (both PASS = the defect is present). State: local v4 PoolManager, pool ETH/IMD fee 10000 spacing 200 at tick 55200, liquidity 1e21 over +/-600 ticks and 1e19 over +/-6000, feed0 = $2000 ETH feed, feed1 = ProductFeed(LivePoolPrice(this pool, base token1), ETH feed), band 1500, maxDeviationTicks 400, 24 honest keeper samples: depth() = 3,840,247,342,878,025,306,000. Input: ONE call to a helper that swaps token1 until sqrtP x 1.118 (IMD -20% in ETH), calls sample() (the manager is locked: no ManagerUnlocked), swaps back to the original sqrtP. Expected per the NatSpec: the sample reverts or the bucket keeps ~3,840e18, and a push needs capital across transactions. Actual: the hour's bucket reads 88,375,181,210,844,228,000 (-97.7%), the tick is back at 55200 in the same transaction, and the attacker's round trip cost 0.01957 ETH + 5.05 IMD (about $80 = 2% of the ~$4,045 pushed). Second test: after a 3,000-tick push, measure() returns (85.0e18, 85.0e18) instead of reverting PoolOffReference, because the reference moved with the pool. Run as BUILD says: forge test --match-path 'test/{unit/{SwarmFeed,ChainlinkFeed,RateFeed,LiveLeg,DepthSampler,DepthMeasured,DepthRule,PanelDepth},scratch/*}.t.sol' (Foundry 1.9.0, solc 0.8.26 via_ir, isolate = true): 14 suites, 121 passed, 2 failed (the two scratch proofs, by design). The same run under --use solar gives the same result.

### 3. Low: RateFeed.poke() during a transient dip of the source rate re-bases the checkpoint at the dip; the return to a level already accepted is then rate-limited (10% dip = ~111 days at BetaConfig's 0.1%/day)

`src/feeds/RateFeed.sol:73`

```
        checkpointRate = uint192(r);
```

Merged from audit_math (low) and audit_permissions (info); reproduced.

rate() = min(live, checkpoint x (1 + maxGrowthPerSecond x dt)) and the permissionless poke() writes rate() as the new checkpoint (line 73). The NatSpec (lines 11-12) describes poke as moving the checkpoint UP to the capped rate and says a fall is taken in full at once so that a loss is never hidden; but a poke landed in a block where the source reads low also moves the checkpoint DOWN, and from then the ceiling grows from the dipped value: with BetaConfig CAP = 0.001e18 / 1 days, a 10% dip is read for ~111 days after the source has recovered, a 1% dip for 10 days. Every position on the type is valued at the dipped rate meanwhile: for sIMD (three RateFeed legs on the same source) 'low' falls by the dip, so positions healthy at market become liquidatable (mat 170%: CR 185% reads 166.5%), and borrowing room shrinks. Nothing on chain can correct it (no admin, no re-anchor).

Reachability with BetaConfig: the mechanism is deterministic but no shipped source was identified whose rate an attacker can dip on demand: wstETH.stEthPerToken and sUSDS move by accrual/governance, ERC-4626 conversions round in the vault's favour, and a MetaMorpho or sIMD dip needs a realised loss or a misreport that is later reversed. Hence low: not attacker-triggerable as far as I can tell, but when it happens the consequence is a months-long mispricing the design did not intend.

Call sequence: [source reads 0.9x for one block] -> anyone: RateFeed.poke() -> source back at 1.0x -> rate()/latestValue() read 0.9x growing at the cap.

Smallest fix: keep a high-water mark of accepted rates (uint192 highRate, set in the constructor, raised in poke when rate() exceeds it) and let the ceiling be max(checkpoint grown at the cap, highRate): a return to a level already accepted is not new value, while a donation above the high-water mark is still capped. Alternatively refuse to lower the checkpoint unless the live rate reads low in a later block as well (two-step fall).

**Reproduction**

test/scratch/JudgeRepro.t.sol test_C_rateFeedDipPokedIsReadForMonths (PASS = defect present). State: RateFeed over a mock ERC-4626 whose convertToAssets(1e18) returns 1e18, underlying feed 1e18 fresh, maxGrowthPerSecond = 0.001e18 / 1 days. Input: source returns 0.9e18; poke(); source returns 1e18 again; warp 30 days. Expected: rate() follows the source back to 1e18 (a level the feed had already accepted). Actual: rate() = 926,999,999,999,827,200 (0.9 x (1 + 0.001 x 30)); 1e18 only after ~111 days. Run as BUILD says: forge test --match-path 'test/{unit/{SwarmFeed,ChainlinkFeed,RateFeed,LiveLeg,DepthSampler,DepthMeasured,DepthRule,PanelDepth},scratch/*}.t.sol' (Foundry 1.9.0, solc 0.8.26 via_ir, isolate = true): 14 suites, 121 passed, 2 failed (the two scratch proofs, by design). The same run under --use solar gives the same result.

### 4. Low: ImdDepth.poke records a sampler's 'too few buckets' zero as the day's reading: a 7-hour sampling gap becomes about 8 days of zero depth (sIMD line 0) after sampling has resumed

`src/feeds/ImdDepth.sol:119`

```
        uint256 v = sampled();
```

From audit_economics (low); reproduced with a real DepthSampler and ImdDepth.

DepthSampler.depth() returns 0 while fewer than MIN_BUCKETS (20) of its 24 slots are inside the 26-hour window. Six missed hours are tolerated; a seventh makes it 0, and because slots alias by hour % 24 the count stays at 17-19 for most of a day after sampling resumes (the missed hours' slots refill only when their hour comes round). ImdDepth.poke() (line 119) folds sampled() into the UTC hour's reading without distinguishing 'measured 0' from 'not enough buckets'. Four pokes in four of those hours (the keeper's own hourly chore, or anyone for ~4 x 100k gas) make the day's 4th-lowest hour 0, depth() is the minimum over the last 7 day figures, so the sIMD line is 0 for the following 7 days while the pool, the sampler and the pokes are healthy again within a day. The header (lines 28-29) states only the fresh-deployment 6-day zero and 'a week of silence must not read as depth'; it does not state that 7 hours of silence reads as a week of zero.

Causes of a 7-hour sampling gap in the beta: the keeper's sampler down with nobody else sampling, or ETH/USD (ChainlinkFeed, maxAge 70 min) stale for 7 hours so measure() reverts StaleFeed. Not attacker-triggerable on the beta pools (PoolOffReference is inert on the live-referenced pools, finding 2; WalkTooLong needs ~4,096 steps and the band spans at most ~330 initialised ticks on the spacing-10 pool). So: a liveness amplification plus a permissionless four-poke trigger during any natural gap. Cost: the gas of four pokes; pays only as griefing (no new sIMD debt for a week).

Call sequence: 8 days of hourly sample()+poke() ; 7 hours of poke() only ; sampling resumes with hourly sample()+poke().

Reachable with BetaConfig: yes (ImdDepth listed at genesis with the three samplers).

Smallest fix: in poke(), do not record a reading when sampled() == 0 (depth() still fails closed live while sampled() is 0; a listed pool with literally no liquidity in the band is not a case the day memory needs). Better: expose DepthSampler.hasWindow() (filledBuckets() >= MIN_BUCKETS) and skip the record while any listed sampler lacks a window.

**Reproduction**

test/scratch/JudgeRepro.t.sol test_E_sevenHourGapZeroesImdDepthForAWeek (PASS = defect present). State: the finding-2 pool and sampler, ImdDepth(params = test) with the sampler listed; 192 hours of hourly sample()+poke(): depth() = 3,840,247,342,878,025,306,000. Input: 7 hours with poke() only (sampler.depth() reads 0, filledBuckets 19), then 48 hours of sample()+poke(). Expected: ImdDepth.depth() returns to the full reading once the sampler does. Actual: sampler.depth() = 3,840.2e18 two days later but ImdDepth.depth() = 0; lowOf(1) = 0, lowOf(2) = 0, lowOf(3) = 3,840.2e18; it stays 0 until those days leave the 7-day window. Run as BUILD says: forge test --match-path 'test/{unit/{SwarmFeed,ChainlinkFeed,RateFeed,LiveLeg,DepthSampler,DepthMeasured,DepthRule,PanelDepth},scratch/*}.t.sol' (Foundry 1.9.0, solc 0.8.26 via_ir, isolate = true): 14 suites, 121 passed, 2 failed (the two scratch proofs, by design). The same run under --use solar gives the same result.

### 5. Low: SwarmFeed remote time clock bounds a window only from below: on a consumer chain other than the data chain, one validly signed future-dated window moves lastToBlock past every real block and halts the

`src/feeds/SwarmFeed.sol:429`

```
        if (clockAt != 0 && uint256(toBlock) + maxAge / 12 < projected) revert WindowBehindClock(toBlock, projected);
```

From audit_flow (reported medium); reproduced with its proof. Lowered to low because the beta deploys every feed on the data chain (attestationChainId == block.chainid == 1), where the local branch at line 401 enforces toBlock <= block.number, and because it needs the pinned attester to sign a window that did not exist when it was signed (an attester fault or compromise).

On a feed whose data chain is not the consumer chain and not the block-clock chain (every consumer except Ethereum and Robinhood Chain: Base, Sepolia), _requireQuestion applies only _tickClock. _tickClock refuses a window BEHIND the projected head (line 429) but never one AHEAD of it, then stores clockBlock = max(toBlock, projected) and lastToBlock = toBlock. The header (lines 80-87) states the premise 'the window existed when it was signed, so toBlock <= head(issuedAt)' but the code never checks it. A signed attestation with toBlock = 1e12 is accepted, after which every honest window reverts WindowNotAdvancing (or WindowBehindClock) forever: the feed, and every type pinned to it, is halted (no borrowing, no liquidation at a price), outside the stated trust model 'a compromised attester can move those feeds within the cap per epoch', and contradicting 'a run of laggy windows cannot walk it into the past' (the future is unguarded).

Smallest fix: in the remote branch, once clockAt != 0, revert WindowInFuture when a.toBlock > projectedHead(a.issuedAt) + PARENT_SLACK (the first value still sets the clock).

**Reproduction**

test/scratch/ProofSwarmClock.t.sol (the proof below). Chain id 8453, SwarmPricePolicy leaf with a test attester, maxAge 1h, cap 2000. (1) first attestation toBlock 26,000,000: accepted, clockBlock 26,000,000. (2) 60 s later a validly signed attestation with fromBlock 999,999,999,400, toBlock 1,000,000,000,000, questionHash from expectedQuestionHash, panel 100/67. Expected: refused as a window in the future. Actual: accepted; lastToBlock = 1e12 and all later honest windows revert WindowNotAdvancing. Output: [FAIL: next call did not revert as expected] test_futureWindowIsAcceptedAndBricksTheClock(). Run as BUILD says: forge test --match-path 'test/{unit/{SwarmFeed,ChainlinkFeed,RateFeed,LiveLeg,DepthSampler,DepthMeasured,DepthRule,PanelDepth},scratch/*}.t.sol' (Foundry 1.9.0, solc 0.8.26 via_ir, isolate = true): 14 suites, 121 passed, 2 failed (the two scratch proofs, by design). The same run under --use solar gives the same result.

**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/feeds/SwarmFeed.sol";
import {SwarmPricePolicy} from "src/feeds/SwarmFeeds.sol";

/// @dev The production question binding with a test signer.
contract ProofClockFeed is SwarmPricePolicy {
    constructor(address attester_) SwarmFeed(attester_, address(0), 1, 3, 1 hours, 2_000) {}
}

contract ProofSwarmClock is Test {
    uint256 constant KEY = 0xA11CE;
    address attester;
    uint256 nonce;

    function setUp() public {
        attester = vm.addr(KEY);
        vm.chainId(8453);
        vm.roll(1);
        vm.warp(1_800_000_000);
    }

    function _att(SwarmFeed f, uint64 toBlock, uint256 value) internal returns (SwarmFeed.OracleAttestation memory a) {
        a.requestId = bytes32(++nonce);
        a.chainId = 1;
        a.answerType = 3;
        a.answer = abi.encode(value);
        a.fromBlock = toBlock - 600;
        a.toBlock = toBlock;
        a.questionHash = f.expectedQuestionHash(a.fromBlock, a.toBlock);
        a.panelSize = 100;
        a.quorum = 67;
        a.agreed = 67;
        a.issuedAt = uint64(vm.getBlockTimestamp());
        a.expiresAt = uint64(vm.getBlockTimestamp() + 1 days);
    }

    function _digest(SwarmFeed f, SwarmFeed.OracleAttestation memory a) internal view returns (bytes32) {
        bytes32 h = keccak256(
            bytes.concat(
                abi.encode(
                    f.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)
            )
        );
        return keccak256(abi.encodePacked("\x19\x01", f.DOMAIN_SEPARATOR(), h));
    }

    function _submit(SwarmFeed f, SwarmFeed.OracleAttestation memory a) internal {
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, _digest(f, a));
        f.submitAttestation(a, abi.encodePacked(r, s, v));
    }

    /// @dev FAILS on the audited commit: the future window is accepted (no revert). Passes once the time
    /// clock also bounds `toBlock` from above (e.g. `toBlock <= projectedHead(issuedAt) + PARENT_SLACK`).
    function test_futureWindowIsAcceptedAndBricksTheClock() public {
        ProofClockFeed f = new ProofClockFeed(attester);
        _submit(f, _att(f, 26_000_000, 3e15));
        assertEq(f.clockBlock(), 26_000_000);

        vm.warp(vm.getBlockTimestamp() + 1 minutes);
        SwarmFeed.OracleAttestation memory future = _att(f, 1_000_000_000_000, 3e15);
        vm.expectRevert();
        this.submitAs(f, future);
    }

    function submitAs(SwarmFeed f, SwarmFeed.OracleAttestation memory a) external {
        _submit(f, a);
    }
}
```

### 6. Low: SwarmFeed._returnAnchor admits a one-step move of (1+cap)/(1-cap)-1 = 50% from the last accepted value on a fresh feed, against the README's 'at most the cap per epoch, 2x after a silence'

`src/feeds/SwarmFeed.sol:529`

```
        level = _epochFirst != 0 ? uint256(_epochFirst) : (_anchorValue == 0 ? _value : _anchorValue);
```

From audit_permissions (low); reproduced.

The way back accepts any value within the cap of the expired epoch's LEVEL regardless of where the feed's last accepted value is. Epoch anchored at A; at minute 59 an honest 0.8A is accepted (within the cap of the anchor); at minute 60 the epoch has expired and 1.2A is submitted: _fitsEpoch fails (anchor 0.8A, fresh allowance admits at most 0.96A) but _returnAnchor passes (level = _anchorValue = A, |1.2A - A| <= 20%). The feed moves +50% in one accepted attestation while fresh, and the new epoch is anchored at A with the plain cap, so the pushed level persists for a lifetime and the next epoch opens from 1.2A. The NatSpec at line 519 ('reaches no level the expired epoch did not already allow') is true; the README trust model ('a value moves at most a fixed cap per epoch, widened at most 2x after an hour of staleness') and the known issue ('up to 40% after a silence and 20% per epoch') are not. Harm is bounded: 2.5x the per-epoch cap once, in the direction opposite to a genuine move, with the attestation bought at the cost the README's 'wrong pair' item describes (holding the pool pushed for >= 7 of 13 median samples over >= 300 blocks, exposed to arbitrage, plus 0.5 IMD). Reachable with BetaConfig (1h lifetime, 2,000 bps).

Smallest fix: in _returnAnchor also require the value to lie between the last accepted value and level (a return TOWARD the level is what the way back exists for): if (value > max(level, _value) || value < min(level, _value)) return 0;

**Reproduction**

test/scratch/JudgeRepro.t.sol test_F_wayBackAdmitsFiftyPercentStep (PASS = defect present). Chain 1, SwarmPricePolicy leaf with a test attester, maxAge 1h, cap 2000, windows at the current block. t0: accept A = 4e15. t0+59 min: accept 0.8A. t0+60 min: isStale() false, epoch() = (anchor 3.2e15, allowance 2000). Submit 1.2A = 4.8e15. Expected: ExcessDeviation (50% from the last value on a fresh feed). Actual: accepts(1.2A) is true and latestValue() = 4.8e15 after submission. Run as BUILD says: forge test --match-path 'test/{unit/{SwarmFeed,ChainlinkFeed,RateFeed,LiveLeg,DepthSampler,DepthMeasured,DepthRule,PanelDepth},scratch/*}.t.sol' (Foundry 1.9.0, solc 0.8.26 via_ir, isolate = true): 14 suites, 121 passed, 2 failed (the two scratch proofs, by design). The same run under --use solar gives the same result.

### 7. Info: SwarmFeed NatSpec claims guards that do not exist in this tree: DeployMainnet.verifySeeded, OracleAsker/WIDE_ALLOWANCE_BPS, the v1 leaf names, MAX_ALLOWANCE_BPS 'never exceeds', '_epoch' 'Public'

`src/feeds/SwarmFeed.sol:477`

```
    /// and relays it, and DeployMainnet.verifySeeded refuses a price or spot anchor more than a quarter of
```

Merged from audit_permissions and audit_economics (NatSpec items). Verified by grep: 'verifySeeded', 'OracleAsker', 'WIDE_ALLOWANCE_BPS' and 'SwarmWorkOracle' occur nowhere in src/, script/ or test/ except in src/feeds/SwarmFeed.sol.
(1) Lines 300-302 and 476-479: 'DeployMainnet.verifySeeded refuses a price or spot anchor more than a quarter of the cap from the pool ... from a salt no one else knows'. No such check exists; DeployConfig says the swarm feeds are deployed EMPTY and the relay is permissionless, so the FIRST value of every price, spot, NHI, seat and depth leaf is unbounded on chain and whoever relays first anchors it. This matters operationally: the runbook, not the code, stops a raced first relay.
(2) Lines 553-554: 'the Treasury does, through OracleAsker, once the allowance reaches WIDE_ALLOWANCE_BPS': neither exists.
(3) Lines 255-256 and 591-592 name PriceFeed, NhiFeed, SpotFeed, SwarmWorkOracle; the leaves here are SwarmPriceFeed, SwarmSpotFeed, SwarmNhiFeed, SwarmSeatMedianFeed, SwarmSeatSpotFeed, SwarmImdDepthFeed (the stated property, no path to _accept without an attestation, does hold for them).
(4) Lines 157-159: MAX_ALLOWANCE_BPS 'The allowance never exceeds this' is never referenced; the bound holds only through maxDeviationBps <= 10_000 and STALE_DEVIATION_MULTIPLE = 2.
(5) Lines 534-537 describe _epoch as 'Public so a buyer can see' on a private function (the public one is epoch()).
(6) Line 63 cites test/SwarmFeed.t.sol (here test/unit/SwarmFeed.t.sol).
(7) Lines 286-287 say the deterministic chain-read feeds demand two thirds; SwarmImdDepthFeed (also a deterministic read of pool 0xb07d) keeps 51. It can only lower depth, and the leg is unset in the beta.
(8) DepthSampler line 19 'read as the minimum over the last day' vs the code's 4th-lowest bucket over 26 hours.
Fix: amend the comments; for (1) either add the seed check to the deploy script or state that the first relay is the deployer's to win.

**Reproduction**

grep -rn 'verifySeeded\|WIDE_ALLOWANCE_BPS\|SwarmWorkOracle\|OracleAsker' src script test --include=*.sol -> only src/feeds/SwarmFeed.sol. Deploy any SwarmXFeed(maxAge, cap): _hasValue is false, so _checkValue bounds nothing and the first submitAttestation by any caller sets _value and anchors the first epoch (SwarmFeed.t.sol test_acceptsAndReadsTheSignedAnswer shows the first value accepted unbounded).

### 8. Info: ImdDepth 'FAIL CLOSED AND NEVER REVERTS' is false for an oversized attested leg: attested() multiplies with checked arithmetic and depth() reverts for direct readers

`src/feeds/ImdDepth.sol:185`

```
        return wei_ * usdPerEth / 1e18;
```

From audit_flow (info); reproduced. The header (line 31) promises readers never revert through this contract. attested() multiplies the leg's wei value by the ETH/USD price with a plain checked multiply; the leg is a SwarmFeed whose FIRST value has no on-chain magnitude bound (finding 7, item 1), so an absurd first value makes depth() revert with a panic. Parameters.depthIMD() wraps the call in try/catch and reads 0, so the depth-gated line still fails closed; the keeper, the Safe and integrators calling depth() directly revert. Reachable only with an attester-signed oversized first depth value, and the beta leaves the leg unset at genesis. Smallest fix: return 0 when usdPerEth != 0 && wei_ > type(uint256).max / usdPerEth (Math.mulDiv alone still reverts when the 256-bit result overflows).

**Reproduction**

test/scratch/JudgeRepro.t.sol test_I_imdDepthAttestedOverflowReverts (PASS = defect present): ImdDepth with a leg feed reading 2^200 and a price feed reading 2^100, both fresh. Expected per the header: attested()/depth() return (0 or a clamp). Actual: attested() reverts (arithmetic overflow), so depth() does too. Run as BUILD says: forge test --match-path 'test/{unit/{SwarmFeed,ChainlinkFeed,RateFeed,LiveLeg,DepthSampler,DepthMeasured,DepthRule,PanelDepth},scratch/*}.t.sol' (Foundry 1.9.0, solc 0.8.26 via_ir, isolate = true): 14 suites, 121 passed, 2 failed (the two scratch proofs, by design). The same run under --use solar gives the same result.

### 9. Info: ChainlinkFeed 'malformed data reads as zero and stale' is false for an ABI-dirty narrow word: abi.decode reverts and the revert propagates to every Pricer.quote on that leg

`src/feeds/ChainlinkFeed.sol:41`

```
        (, int256 answer,, uint256 at,) = abi.decode(data, (uint80, int256, uint256, uint256, uint80));
```

From audit_flow (info); reproduced. Lines 9-11 state 'a feed that reverts or answers malformed data reads as zero and stale'. latestValue only checks the returndata length and then abi.decode's two uint80 fields (and _clamped decodes an address): Solidity validates narrow encodings and reverts when the upper bits are non-zero, so an aggregator returning 160 bytes with a roundId above 2^80-1 makes the view revert instead of returning (0, 0). Reachable only with a broken or non-Chainlink aggregator (the beta's are real Chainlink proxies, immutable). Smallest fix: decode those fields as uint256 and return (0, 0) when they exceed type(uint80).max; range-check the address in _clamped.

**Reproduction**

test/scratch/JudgeRepro.t.sol test_I_chainlinkDirtyWordReverts (PASS = defect present): an aggregator whose decimals() is 8 and whose latestRoundData() returns (2^80, 2000e8, now, now, 1). Expected: latestValue() = (0, 0), isStale() true. Actual: latestValue() reverts. Run as BUILD says: forge test --match-path 'test/{unit/{SwarmFeed,ChainlinkFeed,RateFeed,LiveLeg,DepthSampler,DepthMeasured,DepthRule,PanelDepth},scratch/*}.t.sol' (Foundry 1.9.0, solc 0.8.26 via_ir, isolate = true): 14 suites, 121 passed, 2 failed (the two scratch proofs, by design). The same run under --use solar gives the same result.

### 10. Info: Config: USDC/USD and USDS/USD ChainlinkFeed maxAge equals the 24h heartbeat, so those legs read stale in the gap between the heartbeat elapsing and the round landing (USDT and the peg quote allow +1h)

`script/DeployConfig.sol:444`

```
        c.feeds[F_USDC] = _cl(CL_USDC_USD, 6, 1 days);
```

From audit_math (info); checked against the code. ChainlinkFeed.isStale() is block.timestamp - at > maxAge. MainnetConfig (inherited by BetaConfig) sets _cl(CL_USDC_USD, 6, 1 days) and _cl(CL_USDS_USD, 18, 1 days), while CL_USDT_USD and pegQuoteFeedMaxAge use 1 days + 1 hours for the round landing after the heartbeat. In quiet markets (no 0.25% deviation) these two legs read stale for the seconds-to-minutes after each 24h mark, during which USDC, S-USDC-STEAKHOUSE, S-USDS and the PEG quote read priced = false: no borrow, withdraw, liquidation or redemption on them and the blue sleeve's standing bid stands aside. Minor, self-healing, strictly in script/, reached through ChainlinkFeed.isStale. Fix: 1 days + 1 hours for both.

**Reproduction**

State: CL_USDC_USD last updated at T with no deviation since (heartbeat 86,400 s). Input: ChainlinkFeed(CL_USDC_USD, 6, 1 days).isStale() at T + 86,401 s before the new round lands. Expected: fresh until the round lands (as the USDT leg and the peg quote are). Actual: 86,401 > 86,400 -> isStale() true, Pricer.quote('USDC').priced == false until the round lands.

### 11. Info: Panel answers Q1-Q10, per-function coverage rows and the NatSpec review for the feeds and measured-depth scope (no further defects found)

`src/feeds/SwarmFeed.sol:307`

```
    function submitAttestation(OracleAttestation calldata a, bytes calldata sig) external {
```

SCOPE READ: SwarmFeed, SwarmFeeds, ChainlinkFeed, RateFeed, ProductFeed, LivePoolPrice, DepthSampler, ImdDepth; dependencies Pricer (quote/_applyLive), ParametersBase.depthRule/depthIMD/depthLine, IFeed, v4 Lock/StateLibrary/TickBitmap semantics, script/DeployConfig.sol (BetaConfig/MainnetConfig), README. Specialists' 20 findings were each re-read against the code; 9 mechanisms survive (merged above), none was dropped as non-reproducing, three were lowered in severity (clock: not reachable in the beta; two JIT lows raised to the merged medium). Tests: the panel glob (121 pass) plus my scratch reproductions, deleted afterwards.

Q1 ATTESTATION ACCEPTANCE: holds. Domain (IdentityMD Oracle, 2, block.chainid, address(this)) binds consumer chain and leaf, so a signature for another leaf, feed address or chain recovers to a different address -> InvalidSignature; the signed chainId must equal the pinned data chain (1); questionHash is recomputed from the pinned prefix + signed fromBlock/toBlock, so another question or span -> WrongQuestion; spans bounded per leaf (300-1200 price, 150-1200 spot/NHI, 45k-55k seat median, 6.5k-8k seat spot/depth); requestId consumed before the question check -> ReplayedAttestation on a second submission; WindowNotAdvancing on a second window at or before lastToBlock; s <= n/2, v in {27,28}, ecrecover(0) != attester; floors panel >= 100, agreed >= 67 (price, spot) / 51 (NHI, seats, depth), agreed <= panelSize, quorum unchecked by design; now <= expiresAt, issuedAt <= now, issuedAt <= expiresAt, issuedAt within maxAge and >= last accepted issuedAt (equal allowed: README known issue); mainnet window: toBlock <= block.number and within maxAge/12 blocks. Nothing found on the beta. Off the beta (remote consumer chains) see finding 5.

Q2 EPOCH CAP: holds as documented except finding 6. _fitsEpoch measures from the epoch ANCHOR, so n attestations inside one epoch move at most the allowance once; two steps straddling a boundary give (1+cap)^2-1 = 44% once; the stale base (2x) needs maxAge + 1h of silence from max(acceptedAt, updatedAt), so a held attestation cannot earn it early; a wide epoch stores its first value (uint80: all shipped magnitudes fit) and holds later values within the cap of it; _returnAnchor reaches no level the expired epoch did not allow, but allows a 50% step from the LAST value (finding 6). Halt: after a 2h silence the first relayer at 1.4A (or 0.6A) locks the honest A out for about 3h for one attestation plus the pushed-pool cost (README 'wrong pair', not worse); a single-step gap beyond 2x the cap halts the feed until the market returns (documented).

Q3 FRESHNESS: holds. SwarmFeed stamps the SIGNED issuedAt (future refused, delayed relay cannot extend life); isStale = !hasValue || now - updatedAt > maxAge. ChainlinkFeed: updatedAt 0 or future -> unpriced. ProductFeed: older stamp, stale if either leg. RateFeed: underlying's stamp, stale if rate() == 0. LivePoolPrice: never stale by design (current block). A 'fresh' median may describe a window closing up to 300 blocks ago: the design's window rule.

Q4 CHAINLINK LEGS: holds. answer <= 0, updatedAt 0/future, answer > 10^(18+dec), answer at the current aggregator's minAnswer/maxAnswer -> (0, 0) unpriced and stale; scaling checked for 8-decimal BTC/USD with tokenDecimals 8 and for 18-decimal ratio feeds. Info: a dirty narrow word reverts instead of (0, 0) (finding 9); the 'int192 maximum' remark at line 56 has no corresponding check (harmless).

Q5 RATE AND PRODUCT FEEDS: finding 3 (a poked transient dip is read for months). Ceiling growth: ck x (1 + cap x dt) from the last poke, so headroom accrues while nobody pokes (README known issue); a donation cannot lift the rate above the ceiling whether or not poked (view-side cap). A rate above what the source supports: not found (rate() = min(live, ceiling)). ProductFeed: a x b / 1e18 with checked arithmetic; both legs bounded on the beta (Chainlink 1e36 cap; swarm legs bounded after the first

**Reproduction**

Informational: carries the numbered answers and coverage the task requires; each defect has its own entry with its reproduction. Tests run: Run as BUILD says: forge test --match-path 'test/{unit/{SwarmFeed,ChainlinkFeed,RateFeed,LiveLeg,DepthSampler,DepthMeasured,DepthRule,PanelDepth},scratch/*}.t.sol' (Foundry 1.9.0, solc 0.8.26 via_ir, isolate = true): 14 suites, 121 passed, 2 failed (the two scratch proofs, by design). The same run under --use solar gives the same result.

---

Judge's submission `9a177f71fb2ce9ff5e7c1bb3f6ea5b37c6e418a971cd34eac322fcaf1ee2985a`, 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.
