{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"5e2f7703-9fe2-4c2b-bf7d-cbafd53fb4bf","kind":"skill:adversarial-review","nodes":[{"acceptedSubmissionHash":"ec37fbef938ad01bb21c4ceabef15c6d37dde051868332e2ce068b5ddfd88b98","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"6b037a7b6601e883cf8a906c1520c0624817d42d8310b65c2f43679204608af3","skillId":"adversarial-review","tools":[]},"key":"adversarial_review","kind":"code","role":"review","skillHash":"6b037a7b6601e883cf8a906c1520c0624817d42d8310b65c2f43679204608af3","skillId":"adversarial-review","state":"accepted"}],"objective":"The whole of src/ at this commit, for a mainnet launch: a three-panel audit has already been run and its findings fixed. Break the fixes and the seams between subsystems.\n\nimdUSD 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.\n\nWeight your time to the diff since e52a025966012ebe3af6d710152243d66655563c and to interactions that cross contracts: vault and Treasury (fees, bad-debt cover, the operator stream, reserve protection), vault and feeds (staleness, divergence, 24-decimal collateral), Treasury and OracleAsker (the daily budget), and the relay bundles. Re-check the whole price path (SwarmFeed and its leaves, SwarmRelay, OracleAsker, UsdPriceFeed, SharePriceFeed) in full: that panel's judge ran out of budget and never reproduced its specialists' findings. The fixes and every accepted item, with the reason, are in docs/AUDIT-FIX-PLAN-2026-10-05.md; an accepted item is a finding only if its stated reason is wrong. The lagged backing (CDPVault.laggedNow) is dormant at the launch wage of zero; test it with the wage raised through governance. Every finding needs a concrete failing input or state.\n\nNot 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.\n\nFor 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.","parentJobId":null,"planHash":"29dd7b2c3803710dd827c02eb9bc636f67aaf0d89108026bf367f2728e5be662","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"5e2f7703-9fe2-4c2b-bf7d-cbafd53fb4bf","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52130","feedbackHash":"ec54202c63cef0d8f5665323fa95f6a1406a3d642ecfced7d477c16786601867","nodeKey":"adversarial_review","submissionHash":"ec37fbef938ad01bb21c4ceabef15c6d37dde051868332e2ce068b5ddfd88b98","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"497d977ba02ae018fe08ef65730f4a6aefc8ebf257c1ad0b7887912be458ff80","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1c29c7c6e4cf57eb","findings":[{"citation":"resolved","description":"D1 (vault panel, medium) has two halves: borrow -> earn -> unwind (work minting) and borrow -> cash -> unwind ('a redemption took the reserve at par while backing was 0.4'). The plan and the NatSpec (CDPVault.sol:281-289, ParameterizedVault.backedDebt) keep the fix DORMANT 'until minting from work is switched on', i.e. while WAGE_WAD = 0, on the stated reason that it is about work minting. That reason is wrong for the redemption half: `cash` reads `_backingPerUnit` -> `_securedCollateralValue`, whose only defence at wage 0 is the same-TRANSACTION transient tally. Backing below par needs no work minting: any price fall that leaves positions underwater before they are liquidated, or any realized bad debt (subtracted from `prior` and outstanding until covered), puts it there. An attacker then sends two transactions in the same block (a bundle): tx1 lock + draw (fresh secured collateral and principal), tx2 `cash` — the transient tally is empty, the fresh capital counts in full, backingPerUnit jumps to 1.0 and the reserve (Treasury sIMD, which `cash` spends first) is paid at (1 - fee) of par instead of at the true backing; tx2/tx3 wipe and free. No time passes, so no stability fee and no price exposure. Loss to remaining imdUSD holders = reserve paid x (1 - true backing), repeatable while backing stays below par. With the lag applied (it is already tracked from deployment) the same sequence pays at or below the pre-existing backing.","line":110,"path":"src/ParameterizedVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Math} from \"@openzeppelin/contracts/utils/math/Math.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract DFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 public value;\n\n    constructor(uint256 v) {\n        value = v;\n    }\n\n    function set(uint256 v) external {\n        value = v;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, uint64(block.timestamp));\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract DMirror is ISwarmFeed {\n    ISwarmFeed private immutable p;\n\n    constructor(ISwarmFeed p_) {\n        p = p_;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return p.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return p.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return p.maxAge();\n    }\n}\n\ncontract DAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @notice D1's REDEMPTION half needs no work minting, yet its fix (the lagged backing) is gated on\n/// `wage() != 0` and so is off at launch (WAGE_WAD = 0). After a price fall leaves backing below par,\n/// capital brought in ONE transaction earlier (same block, no interest, no price exposure) lifts\n/// backingPerUnit to 1.0 and the next transaction redeems the Treasury's reserve at par.\n/// Each top-level call below is its own transaction (isolate), so transient storage clears between them.\ncontract DormantRedemptionLagTest is Test {\n    address private constant HONEST = address(0x40E);\n    address private constant ATTACKER = address(0xBAD);\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    DFeed private primary;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new DAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new DFeed(uint256(1 ether) * 1e18 / 2000 ether); // $1 per IMD\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(new DFeed(0.85 ether)), address(new DMirror(primary))\n        );\n        stable = vault.stablecoin();\n        assertEq(vault.parameters().wage(), 0, \"launch configuration: lag dormant\");\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(HONEST, 200 ether);\n        imd.mint(ATTACKER, 1_000 ether);\n        imd.mint(address(vault.treasury()), 10 ether); // the Treasury's reserve (liquidation cuts, dust)\n        vm.stopPrank();\n        vm.prank(HONEST);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(ATTACKER);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(HONEST);\n        vault.lock(200 ether);\n        vm.prank(HONEST);\n        vault.draw(100 ether);\n        vm.warp(block.timestamp + 1 days);\n        vm.roll(block.number + 7_200);\n        // IMD falls 70%; the honest position is underwater and not yet liquidated (grace).\n        primary.set(uint256(0.3 ether) * 1e18 / 2000 ether);\n    }\n\n    /// forge-config: default.isolate = true\n    function test_freshCapitalOneTransactionOldDoesNotLiftTheReservePayoutAtLaunch() public {\n        uint256 before = vault.backingPerUnit();\n        assertLt(before, 0.7e18, \"backing is ~0.64 after the fall\");\n\n        // Transaction 1: real capital, in the same block.\n        vm.prank(ATTACKER);\n        vault.lock(1_000 ether);\n        vm.prank(ATTACKER);\n        vault.draw(100 ether);\n\n        // Transaction 2, same block: redeem 3 imdUSD; the Treasury's reserve alone funds it.\n        uint256 feeBps = vault.redemptionFeeBps(3 ether);\n        uint256 reserveBefore = imd.balanceOf(address(vault.treasury()));\n        vm.prank(ATTACKER);\n        uint256 gemOut = vault.cash(3 ether, 0, address(0));\n        assertEq(reserveBefore - imd.balanceOf(address(vault.treasury())), gemOut, \"paid from the reserve\");\n\n        // What D1's fix promises: the payout scale is the backing that stood before this capital arrived.\n        uint256 price = uint256(0.3 ether); // USD per IMD, as the vault reads it\n        uint256 fair = Math.mulDiv(3 ether, Math.mulDiv(before, 10_000 - feeBps, 10_000), price);\n        emit log_named_decimal_uint(\"reserve IMD paid\", gemOut, 18);\n        emit log_named_decimal_uint(\"at the pre-existing backing\", fair, 18);\n        assertLe(gemOut, fair + 1e9, \"fresh capital must not let a redemption take the reserve at par\");\n\n        // Transaction 3: unwind. Only 3 imdUSD of the 100 drawn stays owed; the rest of the capital leaves.\n        vm.prank(ATTACKER);\n        vault.wipe(97 ether);\n        vm.prank(ATTACKER);\n        vault.free(950 ether);\n    }\n}","reproduction":"Launch constants (wage 0, mat 170 at NHI 0.85). MockIMD at $1; honest lock 200e18 / draw 100e18; Treasury holds 10e18 collateral (its reserve). Warp 1 day; IMD falls to $0.30: backingPerUnit() = 0.64e18. Same block, tx1: attacker lock(1000e18), draw(100e18). tx2: attacker cash(3e18, 0, address(0)) (fee 125 bps; funded wholly by the reserve). Expected per D1's fix: payout at the pre-existing backing, 3e18 x 0.64 x 0.9875 / 0.30 = 6.22e18 IMD. Actual: 9.875e18 IMD (par less fee); backingPerUnit() read 1.0e18 in tx2. tx3: wipe(97e18), free(950e18) unwinds. Raising the wage through governance first (lag on) makes the same sequence pay 3.11e18. Smallest fix: make `_lagApplies` (or at least the redemption path's use of it in `_securedCollateralValue`) unconditional — the lag is already maintained from deployment, so nothing else changes; keep the wage gate only for backedDebt/earnLine if desired.","severity":"medium","snippet":"        return parameters.wage() != 0;","title":"D1's redemption half is open at launch: the lag is gated on wage != 0, but fresh capital one transaction old (same block, zero interest) still lifts backingPerUnit to par and cash takes the Treasury r"},{"citation":"resolved","description":"Seam Treasury -> OracleAsker -> Intake -> SwarmRelay -> SwarmFeed. `feeds[feed].bodyHash` is fixed in the constructor and `ask`/`askPaid` accept only that exact body. The oracle.request body carries the question's block window (oracle/nhi-composite-quote.json, the only NHI body in the tree: \"window\":{\"fromBlock\":26099000,\"toBlock\":26100000}; docs/AUDIT-ORACLE-2026-10-05.md item 2: 'The attester signs windows the buyer pins'), and every shipped feed binds that window: SwarmFeed._requireQuestion refuses a toBlock that does not advance past lastToBlock and, since F1, a toBlock more than maxAge/12 blocks behind the head on chain 1. So with a hash-pinned body the asker can land at most ONE attestation per feed for its lifetime; after F1 it lands none once the head is maxAge/12 blocks past the body's toBlock (300 blocks for the 1h price/spot feeds, 7,200 for NHI; mainnet was already at ~26,122,901 on 2026-10-05, i.e. the in-tree NHI window is refused today). Before F4 the refusal reverted the callback; F4 now catches it and only emits Delivered(relayed=false), so the keep-alive NHI feed stays nearStale and `ask` (permissionless) re-buys every ASK_MIN_INTERVAL until the asker's balance is gone; fundOracle (permissionless) refills it daily. Result: the whole oracle budget (10 IMD/day, ~$110/day at the committed constant) is spent on attestations the feeds must refuse, `askPaid` (the documented way a borrower buys a fresh price) also buys refused answers, and the 'prices on demand' design in docs/PARAMETERS-2026-10-05.md has no working on-chain path, so marks/bites/redemptions stay paused until someone buys and relays off chain. The OracleAsker suite never pairs the asker with a bound feed (it uses unbound ConfigurableSwarmFeeds and window-less bodies), which is why it passes. Conditional on the frozen bodies carrying a literal window, as the in-tree NHI body does; if the production bodies use a service-resolved relative window this reduces to the price span check (oracle/price-oracle-quote.json's {\"hours\":24} = 7,200 blocks exceeds PriceFeed's 1,200-block maxSpan).","line":141,"path":"src/OracleAsker.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {OracleAsker} from \"src/OracleAsker.sol\";\nimport {SwarmFeed} from \"src/SwarmFeed.sol\";\nimport {SwarmRelay} from \"src/SwarmRelay.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {IIntake} from \"src/interfaces/IIntake.sol\";\nimport {APPROVED_OPERATOR, ATTESTATION_RELAYER, INTAKE, ORACLE_ACTION} from \"src/DeploymentConfig.sol\";\n\n/// @dev A feed bound to a question exactly the way PriceFeed/NhiFeed/SpotFeed are: a pinned prefix, the\n/// signed window spliced in, NhiFeed's span bounds, relayed only through SwarmRelay, data chain 1.\ncontract BoundNhiFeed is SwarmFeed {\n    constructor(address attester_) SwarmFeed(attester_, ATTESTATION_RELAYER, 1, 3, 1 days, 2_000) {}\n\n    function questionPolicy() internal pure override returns (bytes memory, uint64, uint64) {\n        return ('{\"q\":\"network health\",\"window\":{\"fromBlock\":', 150, 1_200);\n    }\n}\n\n/// @dev The Intake's request side: takes the price and records the body it was asked to answer.\ncontract RecordingIntake {\n    mapping(bytes32 => bytes) public bodyOf;\n    uint256 public nonce;\n\n    function priceOf(bytes32, address) external pure returns (uint256) {\n        return 0.4 ether;\n    }\n\n    function request(bytes32, bytes calldata body, IIntake.Callback calldata, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        IERC20(asset).transferFrom(msg.sender, address(this), amount);\n        id = keccak256(abi.encode(++nonce));\n        bodyOf[id] = body;\n    }\n}\n\n/// @notice OracleAsker pins each feed's request body by hash for its whole life, and a request body\n/// pins the question's block window (oracle/nhi-composite-quote.json: \"window\":{\"fromBlock\":26099000,\n/// \"toBlock\":26100000}; the attester \"signs windows the buyer pins\", docs/AUDIT-ORACLE-2026-10-05.md).\n/// A bound feed accepts a window only if toBlock advances and (F1) closed within maxAge/12 blocks. So\n/// every Treasury-paid `ask` after the first buys an attestation the feed must refuse, and F4's\n/// try/catch swallows the refusal: the keep-alive feed stays near-stale and is re-asked (and paid for)\n/// every ASK_MIN_INTERVAL until the daily budget is gone.\ncontract AskerPinnedWindowTest is Test {\n    uint256 private constant KEY = 0xA11CE;\n    bytes private constant BODY = '{\"q\":\"network health\",\"window\":{\"fromBlock\":26099000,\"toBlock\":26100000}}';\n    MockIMD private imd;\n    BoundNhiFeed private feed;\n    OracleAsker private asker;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.roll(26_100_050);\n        vm.warp(1_791_000_000);\n        vm.etch(ATTESTATION_RELAYER, address(new SwarmRelay()).code);\n        vm.etch(INTAKE, address(new RecordingIntake()).code);\n        imd = new MockIMD();\n        feed = new BoundNhiFeed(vm.addr(KEY));\n        address[] memory feeds = new address[](1);\n        feeds[0] = address(feed);\n        bytes32[] memory hashes = new bytes32[](1);\n        hashes[0] = keccak256(BODY);\n        bool[] memory tracks = new bool[](1);\n        bool[] memory keepAlive = new bool[](1);\n        keepAlive[0] = true; // NHI: the Treasury keeps it alive\n        asker = new OracleAsker(IERC20(address(imd)), feeds, hashes, tracks, keepAlive);\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(address(asker), 10 ether); // one day's budget, as fundOracle tops it up\n    }\n\n    /// @dev The swarm answers the window written in the body it was sold.\n    function _answer(bytes32 id, uint256 figure) private {\n        bytes memory body = RecordingIntake(INTAKE).bodyOf(id);\n        uint64 from = _number(body, '\"fromBlock\":');\n        uint64 to = _number(body, '\"toBlock\":');\n        SwarmFeed.OracleAttestation memory a = SwarmFeed.OracleAttestation({\n            requestId: id,\n            chainId: 1,\n            questionHash: feed.expectedQuestionHash(from, to),\n            answerType: 3,\n            answer: abi.encode(figure),\n            figure: figure,\n            fromBlock: from,\n            toBlock: to,\n            blockHash: bytes32(0),\n            panelJobId: bytes32(0),\n            panelSize: 25,\n            quorum: 15,\n            agreed: 25,\n            issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 hours)\n        });\n        bytes32 structHash = keccak256(\n            bytes.concat(\n                abi.encode(\n                    feed.ATTESTATION_TYPEHASH(), a.requestId, a.chainId, a.questionHash, a.answerType,\n                    keccak256(a.answer), a.figure, a.fromBlock\n                ),\n                abi.encode(a.toBlock, a.blockHash, a.panelJobId, a.panelSize, a.quorum, a.agreed, a.issuedAt, a.expiresAt)\n            )\n        );\n        (uint8 v, bytes32 r, bytes32 s) =\n            vm.sign(KEY, keccak256(abi.encodePacked(\"\\x19\\x01\", feed.DOMAIN_SEPARATOR(), structHash)));\n        vm.prank(INTAKE);\n        asker.onOracleResult(id, a, abi.encodePacked(r, s, v));\n    }\n\n    function _number(bytes memory body, bytes memory key) private pure returns (uint64 n) {\n        for (uint256 i; i + key.length <= body.length; ++i) {\n            bool hit = true;\n            for (uint256 j; j < key.length; ++j) {\n                if (body[i + j] != key[j]) {\n                    hit = false;\n                    break;\n                }\n            }\n            if (!hit) continue;\n            for (uint256 k = i + key.length; k < body.length && body[k] >= \"0\" && body[k] <= \"9\"; ++k) {\n                n = n * 10 + uint8(body[k]) - 48;\n            }\n            return n;\n        }\n    }\n\n    function test_theTreasuryPaidAskerKeepsAKeepAliveFeedAlive() public {\n        bytes32 first = asker.ask(address(feed), BODY);\n        _answer(first, 0.9 ether);\n        (uint256 value,) = feed.latestValue();\n        assertEq(value, 0.9 ether, \"the first purchase lands\");\n\n        // 20 hours later the feed is near stale (75% of maxAge) and the Treasury buys its next update.\n        vm.warp(block.timestamp + 20 hours);\n        vm.roll(block.number + 6_000);\n        uint256 spentBefore = imd.balanceOf(address(asker));\n        bytes32 second = asker.ask(address(feed), BODY);\n        _answer(second, 0.88 ether);\n        assertLt(imd.balanceOf(address(asker)), spentBefore, \"the Treasury paid for it\");\n        (value,) = feed.latestValue();\n        assertEq(value, 0.88 ether, \"and the paid-for update reached the feed\");\n    }\n}","reproduction":"Chain id 1, head 26,100,050. A SwarmFeed bound exactly like NhiFeed (prefix + spliced window, span 150..1200, maxAge 1 day, relayer SwarmRelay at ATTESTATION_RELAYER) and an OracleAsker(keepAlive) whose body is '{\"q\":\"network health\",\"window\":{\"fromBlock\":26099000,\"toBlock\":26100000}}'; the Intake records the body and the swarm answers the window written in it. ask -> delivery: accepted (0.9e18). Warp 20h, roll 6,000 blocks: the feed is nearStale, ask(feed, BODY) pays 0.4 IMD, delivery of the body's window -> SwarmFeed reverts WindowNotAdvancing(26100000, 26100000), OracleAsker catches it and emits Delivered(relayed=false). Expected: the paid update reaches the feed (0.88e18). Actual: the feed still reads 0.9e18 and the asker's IMD is spent; the same call repeats every 10 minutes. Smallest fix: pin the body's PREFIX (the window-less part, as the feeds pin the question prefix) and have `ask` build or accept a window suffix it checks itself (toBlock <= block.number, toBlock > feed.lastToBlock(), within the feed's span and maxAge/12), or pin a service-resolved relative window and test the asker against a bound feed; and stop re-asking a feed whose last paid delivery was refused (e.g. keep lastAsk-based backoff until a delivery lands).","severity":"medium","snippet":"        if (keccak256(body) != f.bodyHash) revert WrongBody();","title":"OracleAsker pins each request body (and so its block window) forever; bound feeds refuse every repeat, so Treasury-paid asks after the first buy attestations that can never land, and F4 hides it"},{"citation":"resolved","description":"CDPVault documents the D1 lag as 'An increase is credited linearly over BACKING_WARMUP ... Full credit costs real capital held, and paying the stability fee, for the whole warm-up' (CDPVault.sol:283-289, repeated in ParameterizedVault.backedDebt and _securedCollateralValue). `_approach` closes elapsed/BACKING_WARMUP of the REMAINING gap and `_advanceLag` re-bases `laggedAt` at every checkpoint, and every lock/lockIMD/free/draw/wipe/bite/cash/cover by ANY account is a checkpoint (via `_resecure`/`draw`). With activity the credit follows 1-(1-dt/1d)^(1d/dt) -> 1-1/e: a step increase held for a full day is credited 63-64%, ~86% after two days, ~95% after three. Live consequences once the wage is raised: earnLine and backingPerUnit stay understated for days after any honest inflow, so `cash` pays redeemers below par (payoutScale = backingPerUnit*(1-fee)) while the candidate's debt is cancelled at face value; and anyone can hold the credit down for one wei of collateral per poke (lock(1)). Direction is conservative (no over-credit found), so low; but the stated property is false and the test suite only checks the undisturbed case (test_heldCapitalWarmsUpOverADay never touches the vault in between).","line":812,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {MockWorkOracle} from \"src/MockWorkOracle.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract WFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n\n    constructor(uint256 v) {\n        value = v;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, uint64(block.timestamp));\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract WMirror is ISwarmFeed {\n    ISwarmFeed private immutable p;\n\n    constructor(ISwarmFeed p_) {\n        p = p_;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return p.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return p.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return p.maxAge();\n    }\n}\n\ncontract WAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @notice BACKING_WARMUP is documented as a LINEAR one-day warm-up (\"an increase is credited linearly\n/// over BACKING_WARMUP\"; \"Full credit costs real capital held ... for the whole warm-up\"). `_approach`\n/// instead closes elapsed/WARMUP of the REMAINING gap at every checkpoint, and every lock/draw/wipe by\n/// anyone is a checkpoint. With any activity the credit is exponential (time constant one day): after a\n/// full day held, 1000 imdUSD of debt is credited as ~640, not 1000.\ncontract LagWarmupTest is Test {\n    address private constant WORKER = address(0xCA);\n    address private constant POKER = address(0x90CE);\n    MockIMD private imd;\n    ParameterizedVault private vault;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new WAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        WFeed primary = new WFeed(uint256(1 ether) * 1e18 / 2000 ether); // $1 per IMD\n        WFeed health = new WFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new WMirror(primary))\n        );\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(WORKER, 2_000 ether);\n        imd.mint(POKER, 1 ether);\n        vm.stopPrank();\n        vm.prank(WORKER);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(POKER);\n        imd.approve(address(vault), type(uint256).max);\n        // Minting from work switched on through governance, as the runbook describes.\n        Parameters params = vault.parameters();\n        vm.prank(APPROVED_OPERATOR);\n        params.proposeWage(0.01 ether);\n        vm.warp(block.timestamp + params.TIMELOCK());\n        params.applyPending();\n    }\n\n    function test_debtHeldForTheWholeWarmupIsCreditedInFullDespiteOtherActivity() public {\n        vm.startPrank(WORKER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        vm.stopPrank();\n        // An unrelated account locks one wei every hour for the day (any lock/draw/wipe/bite/cash does the same).\n        for (uint256 i; i < 24; ++i) {\n            vm.warp(block.timestamp + 1 hours);\n            vm.prank(POKER);\n            vault.lock(1);\n        }\n        (uint256 lagDebt,) = vault.laggedNow();\n        // Documented: held one full BACKING_WARMUP, the debt counts in full (earnLine 250, as in\n        // test/LaggedBacking.t.sol's undisturbed case). Actual: ~1000 * (1 - (23/24)^24) = ~640.\n        assertApproxEqAbs(lagDebt, 1_000 ether, 1 ether, \"a day held is full credit\");\n        assertApproxEqAbs(vault.earnLine(), 250 ether, 0.5 ether, \"a day held earns full credit\");\n    }\n}","reproduction":"ParameterizedVault with MockIMD at $1, NHI 0.85, wage raised to 0.01e18 through Parameters.proposeWage + 48h + applyPending. WORKER lock(2000e18), draw(1000e18). Then for 24 iterations: warp +1h, an unrelated account calls lock(1). Expected (documented linear warm-up, and LaggedBacking.t.sol's undisturbed case): laggedNow().debt == 1000e18 and earnLine() == 250e18. Actual: laggedNow().debt == 639.92e18 and earnLine() ~= 160e18. Fix: keep the warm-up linear per checkpoint by tracking the un-warmed increment and its start time (e.g. store `pending` and `pendingSince`, credit pending*min(now-pendingSince,1d)/1d and merge new increments by amount-weighted start time, as draw() already does for mintedAt), or correct the NatSpec to state the exponential approach.","severity":"low","snippet":"        return lagged + Math.mulDiv(level - lagged, Math.min(elapsed, BACKING_WARMUP), BACKING_WARMUP);","title":"D1 lag warms up exponentially, not linearly: any checkpoint re-bases the warm-up, so capital held a full BACKING_WARMUP is credited ~64%, not 100%"},{"citation":"resolved","description":"F9 promised a listed source answering an extreme value 'counts for nothing' instead of taking earnLine, backingPerUnit and cash down. Treasury.reserveValueOf (Treasury.sol:231) only refuses the case where balance*price/10**decimals itself overflows; reserveValueUsd then saturates the sum at type(uint256).max. Both near-max results are returned to consumers that add to them with checked arithmetic: ParameterizedVault.earnLine (`reserveValue() + ...`, line 259), ParameterizedVault._redemptionReserveBacking (`others + Math.mulDiv(gem balance, price, 1e18)`, line 158) and CDPVault._backingPerUnit (`backing += _securedCollateralValue(price)`, CDPVault.sol:643). So the one listed source that F9 was about still bricks redemption (the peg defence), the work ceiling and the public backing view until a delisting matures 48h later. Same premise as F9 (governance-listed source misbehaving), hence low.","line":259,"path":"src/ParameterizedVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {Treasury} from \"src/Treasury.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract SFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 public value;\n\n    constructor(uint256 v) {\n        value = v;\n    }\n\n    function set(uint256 v) external {\n        value = v;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, uint64(block.timestamp));\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract SMirror is ISwarmFeed {\n    ISwarmFeed private immutable p;\n\n    constructor(ISwarmFeed p_) {\n        p = p_;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return p.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return p.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return p.maxAge();\n    }\n}\n\ncontract SAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract SToken is ERC20 {\n    constructor() ERC20(\"R\", \"R\") {}\n\n    function mint(address to, uint256 a) external {\n        _mint(to, a);\n    }\n}\n\n/// @notice F9 promised that a listed source answering an extreme value \"counts for nothing\" instead of\n/// taking earnLine, backingPerUnit and cash down. The fix only guards the single product's overflow; a\n/// value that FITS in 256 bits but sits near the top is still returned and then overflows the checked\n/// additions that consume it (earnLine's `reserveValue() + ...`, `_redemptionReserveBacking`'s\n/// `others + ...`, `_backingPerUnit`'s `backing += ...`).\ncontract ReserveSaturationTest is Test {\n    address private constant BORROWER = address(0xBA);\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    SFeed private assetPrice;\n    SToken private asset;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new SAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        SFeed primary = new SFeed(uint256(1 ether) * 1e18 / 2000 ether); // $1 per IMD\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(new SFeed(0.85 ether)), address(new SMirror(primary))\n        );\n        asset = new SToken();\n        assetPrice = new SFeed(1 ether);\n        Parameters params = vault.parameters();\n        vm.prank(APPROVED_OPERATOR);\n        params.proposeReserveAsset(IERC20(address(asset)), ISwarmFeed(address(assetPrice)), 10_000);\n        vm.warp(block.timestamp + params.TIMELOCK());\n        params.applyPending();\n        asset.mint(address(vault.treasury()), 1 ether); // one whole token in the reserve\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 2_000 ether);\n        vm.startPrank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        vm.stopPrank();\n        vm.warp(block.timestamp + 1);\n    }\n\n    function test_cashAlone() public {\n        assetPrice.set(type(uint256).max);\n        vm.prank(BORROWER);\n        vault.cash(10 ether, 0, BORROWER);\n    }\n\n    function test_backingAlone() public {\n        assetPrice.set(type(uint256).max);\n        vault.backingPerUnit();\n    }\n\n    function test_anExtremeListedPriceStillRevertsTheConsumers() public {\n        // The listed source starts answering an absurd but representable figure.\n        assetPrice.set(type(uint256).max);\n        Treasury treasury = vault.treasury();\n        assertEq(treasury.reserveValueOf(IERC20(address(asset))), type(uint256).max, \"returned, not treated as unpriced\");\n        // F9's promise: none of these revert because of a bad listed source.\n        vault.earnLine();\n        vault.backingPerUnit();\n        vm.prank(BORROWER);\n        vault.cash(10 ether, 0, BORROWER);\n    }\n}","reproduction":"ParameterizedVault (MockIMD $1), list an 18-decimal token through proposeReserveAsset(asset, feed, 10000) + 48h + applyPending; mint 1e18 of it to the Treasury (balance == unit, so the new overflow guard is skipped); a borrower lock(2000e18), draw(1000e18). The feed then answers type(uint256).max. Expected (F9): the asset counts for nothing and earnLine/backingPerUnit/cash work. Actual: reserveValueOf(asset) == type(uint256).max; earnLine(), backingPerUnit() and cash(10e18, 0, borrower) all revert with Panic(0x11). Same with a 2-asset register whose saturating sum hits max. Fix: cap what reserveValueOf/reserveValueUsd may return (e.g. treat any per-asset value above a sane bound such as type(uint128).max as unpriced), or use saturating adds in earnLine, _redemptionReserveBacking and _backingPerUnit.","severity":"low","snippet":"        return reserveValue() + Math.mulDiv(backedDebt(), parameters.earnMat(), 10_000);","title":"F9 fix incomplete: a listed reserve value that fits in 256 bits but is near the top still reverts earnLine, backingPerUnit and cash"},{"citation":"resolved","description":"`cover` now syncs the Treasury before burning (F6), setting lastSynced = B. It then burns `amount` (fees first, then principal) from the Treasury and mints `feePaid` back to it (feeRecipient() == treasury). The Treasury's balance ends at B - amount + feePaid < lastSynced, so the next `sync` takes the `balance <= counted` branch, lowers the baseline and credits 0: the reminted fees are never added to totalReceived, though `totalFeesMinted` counts them. test/Cover.t.sol::test_coverKeepsTheTreasurysReceiptsWhole asserts exactly this property (`receivedBefore + unsynced + reminted`, 'including what cover then burned') but calls cover in the same second as the drain, so `reminted == 0` and the assertion is vacuous. Bookkeeping only (no funds move wrongly), but it is the class F6 was meant to close and the Treasury's one figure.","line":535,"path":"src/CDPVault.sol","reproduction":"Cover.t.sol's fixture: _drain(); warp +90 days (refresh ETH/USD, price back to 1); bad = debtOf(BORROWER) (now includes ~0.32e18 fees); _fundTreasury(bad + 7e18); cover(BORROWER, bad); reserve.sync(imdUSD). Expected (the test's own assertion): totalReceived == receivedBefore + unsynced + reminted = 36.8114e18. Actual: 36.4921e18 (reminted 0.3193e18 missing). Smallest fix: sync the surplus account a second time between the burn and the fee mint (sync -> burn -> sync -> mint), so the baseline drops to the post-burn balance and the reminted fee arrives as unsynced revenue; and make the regression test accrue fees before covering.","severity":"low","snippet":"        stablecoin.burn(payer, amount);","title":"F6 incomplete: stability fees cover remints to the Treasury after burning from it never reach totalReceived; the regression test passes only because its fees are zero"},{"citation":"resolved","description":"Each item states the claim, then the code that contradicts it. (1) DeploymentConfig.sol:199, WORK_ORACLE_MAX_AGE: 'A stale tally grants nothing NEW' — SwarmWorkOracle.claim checks only acceptedRoots[root], the wage, the controller and the proof, never isStale(), so a root accepted months ago still credits new rights (D7 says maxAge gates nothing and F11 corrected SwarmWorkOracle/WorkOracleFactory, but not this line). (2) CDPVault.sol:283-289 'An increase is credited linearly over BACKING_WARMUP' — exponential under any activity (see the separate low finding). (3) CDPVault.sol:503-508 cover: 'only ever cancels debt that has no collateral left behind it ... Reverts on a position that still holds collateral' — since F2 it sweeps collateral below the one-wei seizure to the Treasury and proceeds. (4) ParameterizedVault.sol:257 'Parameters caps the ratio at half that cliff' — the cliff is mat-1 = 7000 bps at mat 170 and MAX_EARN_MAT_BPS is 2500, not 3500. (5) UsdPriceFeed.sol:10 'It is what the Treasury prices its IMD reserve with' — the Treasury's collateral reserve is sIMD, which F7 now forbids listing against usdPriceFeed (collateralPriceFeed only). (6) OracleAsker.sol:51 'the Treasury's daily budget, which is all this contract can ever hold' — anyone can transfer IMD to it, and a governance cut to oracleBudget leaves more than a day's budget in it with no way out. (7) SwarmRelay.sol:74 relayAndBite 'so nobody can act on the fresh price in between' — the attestations are public in the mempool; anyone can relay a copy and bite first, making the bundle revert (ReplayedAttestation/WindowNotAdvancing).","line":199,"path":"src/DeploymentConfig.sol","reproduction":"(1) Record a root at t0, warp 30 days (feed isStale() == true), raise the wage, claim(agentId, ..., root): rights are credited. (3) Cover.t.sol::test_reLockedDustNoLongerBlocksCover: cover succeeds on a position holding 1 raw unit. (4) 7000/2 = 3500 != 2500. (6) imd.transfer(asker, 50e18): asker holds 5x the budget. (7) Copy the attestations from a pending relayAndBite and call relayAndBite (or relay + bite) first: the original reverts. Fix: correct the text.","severity":"info","snippet":"// more often than it exists. A stale tally grants nothing NEW and retracts nothing already consumed.","title":"NatSpec/comments that claim properties the code does not have (left after F11)"}],"hash":"ec37fbef938ad01bb21c4ceabef15c6d37dde051868332e2ce068b5ddfd88b98","nodeId":"35bb813e-8acd-4787-abe4-2fb21e6bd693","outcome":"completed","summary":"I found two medium, three low and one info finding, and nothing critical or high. They are in `.imd-findings.json`, most severe first. I changed no tracked files; the scratch tests are deleted. Each of the four findings that carries a proof has a self-contained Foundry test, and each fails on this commit for the stated reason. The dotfiles that `git status` lists as untracked were already in the working directory and aren't mine.\n\n## Findings\n\n1. **Medium: the redemption half of D1 is open at launch.** `src/ParameterizedVault.sol:110`, `_lagApplies` returns `parameters.wage() != 0`.\n   - **Why the stated reason fails:** the plan keeps the lag dormant because it's about work minting. But `cash` can take the reserve at par with no work minting at all.\n   - **Failing state:** at launch (wage 0), a 70% price fall leaves `backingPerUnit` at 0.64. In one block, an attacker sends two transactions: lock 1000 + draw 100, then `cash(3e18)`. The Treasury's reserve pays 9.875 IMD; at 0.64 backing it should pay 6.22. Then they repay and withdraw. No time passes, so they pay no fee and carry no price risk.\n   - **Fix:** apply the lag whatever the wage. With the wage raised through governance, the same sequence pays 3.11.\n\n2. **Medium: `OracleAsker` can only ever land one attestation per feed.** `src/OracleAsker.sol:141`.\n   - **Why:** it fixes each request body by hash at construction, and the body carries a literal block window (the NHI body in `oracle/` has 26,099,000–26,100,000). The feeds refuse any window that doesn't advance, and since the F1 fix, any window that closed too long ago.\n   - **Effect:** every paid ask after the first is refused. The F4 fix (the callback no longer reverts) now swallows the refusal, so the near-stale NHI feed is re-bought every 10 minutes until the daily budget is gone. The bodies the feeds actually name aren't in the repo, so this assumes they also carry a literal window.\n   - **Failing input:** a feed bound like `NhiFeed`. The first ask lands 0.9. The second ask, 20 hours later, is paid for and refused with `WindowNotAdvancing`, and the feed still reads 0.9.\n\n3. **Low: the lag warms up exponentially, not linearly.** `src/CDPVault.sol:812`. Every checkpoint re-bases the warm-up, and any account's lock, draw or wipe is a checkpoint. With one-wei locks every hour, 1000 of debt held a full day is credited at 639.9, not 1000. `earnLine` reads about 160 instead of 250.\n\n4. **Low: the F9 fix is incomplete.** `src/ParameterizedVault.sol:259`. A listed reserve value that fits in 256 bits but sits near the top still overflows `earnLine`, `backingPerUnit` and `cash`. Failing input: one whole token listed with a price of `type(uint256).max`; all three revert.\n\n5. **Low: the F6 fix is incomplete.** `src/CDPVault.sol:535`. Stability fees that `cover` re-mints to the Treasury never reach `totalReceived`. Your own regression test passes only because no fees accrue before it calls `cover`. With 90 days of fees it fails: 36.49 recorded against 36.81 expected.\n\n6. **Info: seven comments claim properties the code doesn't have.** One example: `DeploymentConfig.sol:199` says \"a stale tally grants nothing NEW\", but `claim` never checks staleness. The full list is in the file.\n\n## What I read\n\n- **Read in full:** `CDPVault`, `ParameterizedVault`, `Treasury`, `TreasuryFactory`, `Parameters`, `Governed`, `ImdUSD`, `SwarmFeed`, `PriceFeed`, `NhiFeed`, `SpotFeed`, `SwarmRelay`, `OracleAsker`, `UsdPriceFeed`, `SharePriceFeed`, `SwarmWorkOracle`, `WorkOracleFactory`, `DeploymentConfig`, `Registry`, and the interfaces.\n- **Skipped as out of scope:** the mocks (`MockIMD`, `MockWorkOracle`, `LaunchToken`), `script/`, `web/` and `points/`.\n- **Couldn't reach:** sIMD's own contract, the Intake, and the request bodies under `whitepaper/requests/`, which are not in the repo. So I couldn't check share-price manipulation, the Intake's real behaviour, or exactly which window the production bodies carry.\n\nEvery other accepted item's stated reas","treeHash":null,"usage":{"cachedInputTokens":9305705,"inputTokens":98,"model":"claude-opus-5-5","outputTokens":104962,"runtime":"claude","turns":56,"wallClockMs":1168280}}],"verification":[]}