{"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":"e4a6f30e-93df-464e-ba53-e341e01d1ee8","kind":"audit","nodes":[{"acceptedSubmissionHash":"f7e3c960cfc8dcb62032eca6b81adebf0d24d8cc42125bfaa0afa47c818ab490","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"2eac1c3268b1dc448377ee96134935823431f9b861759f97e5991dbcbb2b4da3","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"9625d86b29fc176da53200acc3c0dca31c77d8d0d3970552c8441d086e3229cb","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"917cfdb273986547c8656d61d16fcc7ba88b7c5148acff979a9289d0d93f9a1a","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"996c5d758cac9ce0088829f7119b0bf5b810f167bde116a2d279761c5d393fb0","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit governance and the Treasury: src/Parameters.sol, src/Governed.sol, src/Treasury.sol, src/TreasuryFactory.sol, src/WorkOracleFactory.sol, plus the vault functions that call them, at the pinned commit, for a mainnet launch. Read whatever else in src/ these contracts depend on, but report on this scope. Three audit rounds and their fixes are already in (docs/AUDIT-*.md, newest docs/AUDIT-FINAL-2-2026-10-07.md and the fix commit after it); this panel audits the code as it will deploy, so a finding of an earlier round counts only if its fix regressed or left a gap.\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\nAnswer each numbered question, including the ones where nothing is wrong:\n1. Timelock: can any change (economics, reserve listing, wage, gap, earnMat, oracleBudget, redemptionDivisor, work oracle, the stream's payee and daily cap) apply sooner than 48 hours, outside its bounds, or by anyone other than the documented route? Can a pending proposal be blocked indefinitely or applied at a chosen moment to harm borrowers?\n2. Treasury exits: withdraw, withdrawNative, payStream, fundOracle (sIMD unwrapped to IMD for OracleAsker, at most oracleBudget per UTC day), redeemIMD, cover. Enumerate every way value leaves and show each is bounded as documented, across day boundaries, rate changes and rounding.\n3. fundOracle on day one: the Treasury holds no sIMD until the first liquidation cut (docs/MAINNET-RUNBOOK.md 7.4: the keeper is the budget, the asker is seeded with IMD at deploy). Is there a state in which the oracle path is dead and nothing in the runbook revives it?\n4. Accounting: sync, lastSynced, totalReceived across ERC-20 and native ETH, including tokens arriving between calls and the share-unwrapping path. Double-counted or lost revenue?\n5. Reserve valuation: reserveValueUsd and reserveValueOf across assets with different decimals and feeds, the vault's own 24-decimal collateral per 1e18 raw units. Can a listed feed or token make the sum revert, inflate, or misvalue?\n6. proposeWorkOracle: applies only while wage is 0; the successor must answer vault(), mintingRights() and, once anything was minted, predecessor() == the current oracle (SwarmWorkOracle has no predecessor(), by documented decision). Can a hostile or broken oracle be installed, can the ordering of wage and oracle proposals bypass the rule, can WORK_ORACLE_SENTINEL or WorkOracleFactory hand a vault an oracle it did not create, and are claimed-but-unconsumed rights stranded or doubled across a replacement?\n7. TreasuryFactory and the launch fee hand-off: can anyone obtain a Treasury a vault trusts, a vault whose Treasury another controls, or redirect anything but future fees?\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":"48258ddcf085a348ea382924fffcb4f5fc55f9de2c19717917ef19ceb81ad4d2","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"e4a6f30e-93df-464e-ba53-e341e01d1ee8","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":"52164","feedbackHash":"5d89753b077a37edb174751e0321b71ea941296db0b503e078d6b2f009ef2b35","nodeKey":"audit_economics","submissionHash":"f7e3c960cfc8dcb62032eca6b81adebf0d24d8cc42125bfaa0afa47c818ab490","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51158","feedbackHash":"a2a410e76bf7d586db976a40295b18dea4a0cc2940d853d81ac18c7ffde489bd","nodeKey":"audit_flow","submissionHash":"2eac1c3268b1dc448377ee96134935823431f9b861759f97e5991dbcbb2b4da3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52169","feedbackHash":"f3268b6bc6e033f0ef5a552e6341ccb237d65b4e0c189762a826ee4baddd4022","nodeKey":"audit_judge","submissionHash":"9625d86b29fc176da53200acc3c0dca31c77d8d0d3970552c8441d086e3229cb","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51514","feedbackHash":"bbbadb9030cda691059565d55ccabb693ab1ef4a663897a9455254035e78b83f","nodeKey":"audit_math","submissionHash":"917cfdb273986547c8656d61d16fcc7ba88b7c5148acff979a9289d0d93f9a1a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51428","feedbackHash":"3ae8b1cdd8a9280e697206a2cebe5d1eb2d2ebd0dd8c89e8ac9f9f6dd2fa4b13","nodeKey":"audit_permissions","submissionHash":"996c5d758cac9ce0088829f7119b0bf5b810f167bde116a2d279761c5d393fb0","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"c108aaf5845e7a4876079f264f418a686411c555b6133ffe52fd1e1e6965a72a","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3ac34bfb544bbc10","findings":[],"hash":"08fa0f8dc45aec96e93b8cae096d60b60a84922771e5ba15d71973f22cb93b28","nodeId":"26f73075-0423-47b1-b035-9ee5caf956b8","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":5,"wallClockMs":624206}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"393ade31b99d5d5b","findings":[],"hash":"13d501b1d18393a8268fb1241c4d073c8ef3101b92fa6f1d66f55aa05f327a2e","nodeId":"6ac23d06-f01f-4a48-8e93-c7b40c35f872","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":5,"wallClockMs":834954}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4f6165fcd0deb045","findings":[{"citation":"resolved","description":"ParameterizedVault._lagApplies treats wage == 0 as proof that work minting is off, but CDPVault.earn and SwarmWorkOracle.consumeRights still accept rights credited before the wage cut. An ordinary timelocked shutdown therefore reopens the adjacent-transaction borrow/earn/repay gap that D1 was intended to close. A rights holder can temporarily draw debt, use its full 25% contribution to earnLine in the next transaction in the same block, then repay and free every share, leaving the work supply unbacked. This does not require malicious governance or a hostile oracle. It also contradicts the comments at ParameterizedVault.sol:109-110 and CDPVault.sol:864-865 describing the wage predicate as equivalent to whether minting is on. Reachability: dormant on a never-enabled launch at committed WAGE_WAD=0; reachable using the committed contracts/constants after governance enables work and later returns wage to zero through two valid 48-hour proposals. Smallest fix preserving earned rights: always apply the backing lag in backedDebt, including at wage zero. Alternatively explicitly suspend earn at zero wage, retaining credits for resumption, if governance intends that behavior.","line":112,"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 {CDPVault} from \"src/CDPVault.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {SwarmWorkOracle} from \"src/SwarmWorkOracle.sol\";\nimport {WorkOracleFactory} from \"src/WorkOracleFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {\n    APPROVED_OPERATOR, TREASURY_FACTORY, WORK_ORACLE_FACTORY,\n    WORK_ORACLE_SENTINEL, CHAINLINK_ETH_USD, ERC8004_ADAPTER\n} from \"src/DeploymentConfig.sol\";\n\ncontract AuditIMD is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n    function mint(address to, uint256 amount) external { _mint(to, amount); }\n}\n\ncontract AuditShare is ERC20 {\n    IERC20 public immutable asset;\n    constructor(IERC20 token) ERC20(\"sIMD\", \"sIMD\") { asset = token; }\n    function decimals() public pure override returns (uint8) { return 24; }\n    function convertToAssets(uint256 shares) public pure returns (uint256) {\n        return shares * 7.95e12 / 1e18;\n    }\n    function deposit(uint256 amount, address to) external returns (uint256 shares) {\n        asset.transferFrom(msg.sender, address(this), amount);\n        shares = amount * 1e18 / 7.95e12;\n        _mint(to, shares);\n    }\n}\n\ncontract AuditFreshFeed is ISwarmFeed {\n    uint256 private immutable value;\n    constructor(uint256 v) { value = v; }\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, uint64(block.timestamp));\n    }\n    function maxAge() external pure returns (uint256) { return 1 days; }\n    function isStale() external pure returns (bool) { return false; }\n}\n\ncontract AuditUsd {\n    function decimals() external pure returns (uint8) { return 8; }\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n// Only the attestation fixture is substituted: claim/rights/consume/wage remain production code.\ncontract AuditSeededWork is SwarmWorkOracle {\n    constructor(address v, uint256 age) SwarmWorkOracle(v, age) {}\n    function seed(bytes32 root) external { _accept(uint256(root), uint64(block.timestamp)); }\n}\n\ncontract AuditWorkFactory {\n    function create(uint256 age) external returns (SwarmWorkOracle) {\n        return new AuditSeededWork(msg.sender, age);\n    }\n}\n\ncontract GovernanceResidualRightsTest is Test {\n    ParameterizedVault private vault;\n    Parameters private params;\n    AuditSeededWork private work;\n    AuditIMD private imd;\n    AuditShare private share;\n    address private constant WORKER = address(0xCA);\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(WORK_ORACLE_FACTORY, address(new AuditWorkFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new AuditUsd()).code);\n        imd = new AuditIMD();\n        share = new AuditShare(IERC20(address(imd)));\n        vault = new ParameterizedVault(\n            address(share), address(0), WORK_ORACLE_SENTINEL,\n            address(new AuditFreshFeed(5e14)), address(new AuditFreshFeed(0.85e18)),\n            address(new AuditFreshFeed(5e14))\n        );\n        params = vault.parameters();\n        work = AuditSeededWork(address(vault.oracle()));\n        _wage(1e18);\n        bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(uint256(7), uint32(500), uint64(500)))));\n        work.seed(root);\n        work.recordRoot();\n        vm.mockCall(ERC8004_ADAPTER, abi.encodeWithSignature(\"isController(uint256,address)\", 7, WORKER), abi.encode(true));\n        vm.prank(WORKER);\n        work.claim(7, 500, 500, new bytes32[](0), root);\n        assertEq(work.mintingRights(WORKER), 500e18);\n        _wage(0);\n    }\n\n    function _wage(uint256 rate) private {\n        vm.prank(APPROVED_OPERATOR);\n        params.proposeWage(rate);\n        vm.warp(params.pendingEta());\n        params.applyPending();\n    }\n\n    function test_zeroWageMustNotDisableBackingLagForOutstandingRights() public {\n        assertEq(params.wage(), 0);\n        assertEq(vault.totalEarned(), 0);\n        imd.mint(WORKER, 2000e18);\n        vm.startPrank(WORKER);\n        imd.approve(address(vault), 2000e18);\n        vault.lockIMD(2000e18);\n        vault.draw(1000e18);\n        vm.stopPrank();\n        (uint256 lagDebt,) = vault.laggedNow();\n        assertEq(lagDebt, 0, \"fresh debt has not warmed up\");\n\n        // isolate=true gives each top-level call its own transaction, in the SAME block.\n        // Allow either a zero-wage mint guard or an unconditional backing lag as the fix.\n        vm.prank(WORKER);\n        (bool minted,) = address(vault).call(abi.encodeCall(CDPVault.earn, (250e18)));\n        minted;\n        vm.startPrank(WORKER);\n        vault.wipe(1000e18);\n        (uint256 locked,) = vault.positions(WORKER);\n        vault.free(locked);\n        vm.stopPrank();\n        assertEq(vault.totalDebt(), 0);\n        assertEq(share.balanceOf(address(vault)), 0);\n        assertEq(share.balanceOf(address(vault.treasury())), 0);\n        assertEq(vault.stablecoin().totalSupply(), 0, \"fresh temporary debt must not leave unbacked work supply\");\n    }\n\n}","reproduction":"Foundry (offline, isolate=true): deploy ParameterizedVault with a 24-decimal sIMD fixture, convertToAssets(1e18)=7.95e12, IMD=$1, NHI=0.85. Apply wage=1e18 after 48h; an agent proves cumulative=500 and claims 500e18 rights through production SwarmWorkOracle.claim. Apply wage=0 after another 48h, with totalEarned=0. At one timestamp in separate transactions: worker lockIMD(2000e18), draw(1000e18); laggedNow().debt=0 but earnLine()=250e18. Worker earn(250e18), wipe(1000e18), free(all shares). Expected: fresh debt cannot authorize work minting, so this round trip leaves zero supply. Actual: totalDebt=0, vault/Treasury collateral=0, totalSupply=totalEarned=250e18 held by worker. forge test --offline --out /tmp/imd-audit-out --cache-path /tmp/imd-audit-cache --match-path test/scratch/GovernanceResidualRights.t.sol fails test_zeroWageMustNotDisableBackingLagForOutstandingRights with 250000000000000000000 != 0. The seedable oracle only substitutes an accepted attestation; all claim, wage, and consumption logic is production code.","severity":"medium","snippet":"        return parameters.wage() != 0;","title":"Setting wage to zero disables the work-backing lag while previously claimed rights remain mintable"},{"citation":"resolved","description":"Treasury._readBool, _readValue and _readBalance capture the entire bytes returned by an external staticcall before inspecting its size or encoding. A previously valid listed feed/token that returns very large data can make the Treasury itself run out of gas expanding memory and copying returndata; the success/length checks are never reached. This leaves a resource-exhaustion gap in the earlier failure-isolation fixes, contradicting reserveValueUsd NatSpec at lines 206-208 and reserveValueOf at 219-221. External route: anyone calls reserveValueUsd (or vault.earn/backingPerUnit/cash, which read it), then _reservePrice -> _readBool -> listed feed. No governor misbehavior is needed once a listed dependency changes behavior, but this requires such a dependency: it is not reachable with only the shipped sIMD/SharePriceFeed listing behaving as implemented, and no permissionless listing bypass exists. The impact is conditional valuation/consumer liveness failure, not theft or unbounded valuation. Smallest fix: perform fixed-output-size staticcalls (32/64 bytes) without copying arbitrary returndata, and impose a reasonable per-read gas limit so a callee cannot exhaust the whole reserve traversal.","line":261,"path":"src/Treasury.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\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 {Treasury} from \"src/Treasury.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\n\ncontract ReturnDataReserve is ERC20 {\n    constructor() ERC20(\"Reserve\", \"RSV\") { _mint(msg.sender, 100e18); }\n}\n\ncontract ReturnDataFeed {\n    bool public broken;\n    function fail() external { broken = true; }\n    function isStale() external view returns (bool) {\n        if (broken) {\n            assembly {\n                mstore(0, 0)\n                return(0, 0x200000)\n            }\n        }\n        return false;\n    }\n    function latestValue() external view returns (uint256, uint64) {\n        return (1e18, uint64(block.timestamp));\n    }\n}\n\ncontract ReserveReturnDataTest is Test {\n    function parameters() external view returns (address) { return address(this); }\n    function gem() external pure returns (address) { return address(0x1234); }\n    function stablecoin() external pure returns (address) { return address(0x5678); }\n\n    function test_brokenFeedMustNotRevertTheEntireReserveRead() public {\n        Treasury treasury = new Treasury(address(this));\n        ReturnDataReserve token = new ReturnDataReserve();\n        ReturnDataFeed feed = new ReturnDataFeed();\n        token.transfer(address(treasury), 100e18);\n        treasury.setReserveAsset(IERC20(address(token)), ISwarmFeed(address(feed)), 10_000);\n        assertEq(treasury.reserveValueUsd(), 100e18);\n        feed.fail();\n        (bool ok,) = address(treasury).staticcall{gas: 16_000_000}(abi.encodeCall(Treasury.reserveValueUsd, ()));\n        assertTrue(ok, \"a broken feed must count for zero instead of exhausting the caller's memory-copy gas\");\n    }\n}","reproduction":"List an ordinary 18-decimal ERC20 holding 100e18 at the Treasury, haircut=10000, using a feed whose isStale() returns false and latestValue() returns (1e18,uint64(block.timestamp)); valuation returns 100e18. After listing, switch that feed to return(0,0x200000) from isStale(), with its first word zero (a valid ABI false). Call Treasury.reserveValueUsd with 16,000,000 gas. Expected: a problematic dependency is isolated, and the Treasury call completes rather than making all valuation unavailable. Actual: the callee returns 2,097,152 bytes successfully but the Treasury runs out of gas copying them; the outer staticcall returns false. forge test --offline --out /tmp/imd-audit-out --cache-path /tmp/imd-audit-cache --match-path test/scratch/ReserveReturnData.t.sol fails test_brokenFeedMustNotRevertTheEntireReserveRead. The fixture makes the test contract the registrar solely to stage an otherwise valid listing; the same entry is permitted by Parameters.proposeReserveAsset and applyPending. A smaller caller gas budget may instead exhaust the callee and return zero: this is a gas-dependent failure, not a claim that every call is permanently blocked.","severity":"low","snippet":"        (bool success, bytes memory data) = address(feed).staticcall(call);","title":"Reserve read isolation still copies unbounded return data and can revert the entire valuation"},{"citation":"resolved","description":"Section 7.4 says the Treasury takes over oracle funding after either launch-pool IMD fees or liquidation sIMD cuts arrive, and then the paid keeper fallback can be switched off. With the deployed sIMD collateral, Treasury.fundOracle always selects the share branch: available = IShareVault(gem).maxWithdraw(treasury) at Treasury.sol:494, and _withdrawUnderlying only unwraps those shares. It never uses IMD already held by the Treasury. Thus the day-one fix (seed the asker and fund the keeper) still has an incorrect exit condition: following this step after ordinary IMD fee receipts can remove the only recurring funding route before any liquidation occurs. Once the 15 IMD seed is spent, oracle purchases stop despite an IMD-funded Treasury. Reachable with committed economic constants and the documented production sIMD collateral; no wage change or malicious actor is needed. It is recoverable, not permanent: the operator can withdraw unlisted IMD directly to the asker, anyone can top up the asker, or the keeper fallback can resume. Smallest fix: allow switching the fallback off only after confirming sufficient withdrawable sIMD and an actual successful top-up, and explicitly describe the operator-funded route for plain IMD revenue. Alternatively add direct-underlying spending to fundOracle under the existing shared daily counter, which is a code change beyond the current documented share-only design.","line":336,"path":"docs/MAINNET-RUNBOOK.md","reproduction":"After deploying and seeding ORACLE_ASKER with 15e18 IMD as section 7.4 prescribes, assume no liquidations have occurred (Treasury sIMD balance=0). A launch-fee payout transfers 100e18 plain IMD to the Treasury. Following lines 336-337, disable KEEPER_ORACLE_FALLBACK. After the asker spends its seed, its IMD balance=0, Parameters.oracleBudget=15e18 and oracleSpent can be zero on a new UTC day. Anyone calls Treasury.fundOracle(): want=15e18, room=15e18, maxWithdraw(Treasury)=0, so sent=0; Treasury still holds 100e18 IMD. Expected from the runbook: automatic Treasury funding replaces the keeper and supplies up to 15 IMD. Actual: the recurring path is unfunded; NHI eventually goes stale and price actions halt until someone re-enables paid asks or manually transfers funds. test_IMDRevenueDoesNotFundAskerForShareCollateral in test/scratch/GovernanceResidualRights.t.sol passes by asserting this exact zero-sent, 100-IMD-held state with a 24-decimal share fixture.","severity":"low","snippet":"   pay. Once revenue lands (launch-pool fees in IMD arrive through `handOffLaunchFees`; liquidation\n   cuts in sIMD) the Treasury takes over and the fallback can be switched off. An unattended protocol","title":"The runbook permits disabling keeper funding when only plain IMD revenue has arrived, which fundOracle cannot spend"},{"citation":"resolved","description":"proposeWorkOracle requires successor.vault() to equal the governed vault, but WorkOracleFactory.create always binds the new oracle to msg.sender. ParameterizedVault has no external function that calls this factory after construction. Consequently the newly added NatSpec and MAINNET-RUNBOOK section 7b recommend a route that always fails, even before the first work mint. This is a documentation defect in the follow-up to AUDIT-FINAL-2 item 6, distinct from the accepted absence of predecessor(). Reachable before first mint with the committed wage-zero default once the factory is deployed. Smallest fix: document deploying new SwarmWorkOracle(address(existingVault), WORK_ORACLE_MAX_AGE) directly, which the current constructor permits, rather than calling WorkOracleFactory.create.","line":249,"path":"src/Parameters.sol","reproduction":"With an existing ParameterizedVault V, V.parameters().wage()==0 and V.totalEarned()==0, APPROVED_OPERATOR calls a genuine WorkOracleFactory.create(1 days), obtaining N. N.vault()==APPROVED_OPERATOR. The same operator calls V.parameters().proposeWorkOracle(address(N)). Expected per the NatSpec: the fresh factory-created oracle qualifies. Actual: InvalidWorkOracle at Parameters.sol:392 because N.vault()!=V. test_factoryReplacementDocumentedRouteIsRejected in test/scratch/GovernanceResidualRights.t.sol passes by asserting this rejection. An unrelated caller has the same failure; the sentinel constructor path remains correct.","severity":"info","snippet":"    /// the first mint a fresh `SwarmWorkOracle` (through `WorkOracleFactory.create`) qualifies.","title":"The newly documented WorkOracleFactory route cannot create a replacement bound to an existing vault"}],"hash":"2eac1c3268b1dc448377ee96134935823431f9b861759f97e5991dbcbb2b4da3","nodeId":"a7539e86-e397-4ba2-834a-d2ec7f586bca","outcome":"completed","summary":"Saved [`.imd-findings.json`](/home/imd-worker/.identitymd/work/e4a6f30e-93df-464e-ba53-e341e01d1ee8/a7539e86-e397-4ba2-834a-d2ec7f586bca/.imd-findings.json) with **four findings: one medium, two low, one informational**. Each includes exact source citations, concrete reproduction, reachability, and the smallest fix. Two reproduced failing Foundry tests are embedded.\n\n| Severity | Location | Finding and concrete failure | Smallest fix |\n|---|---|---|---|\n| Medium | `src/ParameterizedVault.sol:112`, `_lagApplies` | After workers claim rights at positive wage, a legitimate timelocked wage reduction to zero disables the backing lag while those rights remain spendable. In separate transactions at one timestamp, a worker borrows 1,000 imdUSD, earns 250, repays and withdraws everything. Actual result: 250 imdUSD remains with zero debt or collateral. Reachable after governance enables and subsequently disables wages; dormant at the never-enabled launch configuration. | Apply the backing lag regardless of wage, preserving previously earned rights. |\n| Low | `src/Treasury.sol:261`, reserve-read helpers | A previously valid listed feed returns 2,097,152 bytes from `isStale()`. A valuation call supplied 16,000,000 gas fails while copying returndata instead of isolating the dependency. This requires a problematic listed dependency; the shipped collateral/feed combination does not produce that response. | Use fixed-size staticcall output buffers and bounded gas per dependency read. |\n| Low | `docs/MAINNET-RUNBOOK.md:336`, §7.4 | The runbook permits stopping keeper funding after plain IMD fees arrive. With Treasury holding 100 IMD but zero sIMD, an empty asker and a fresh 15-IMD daily allowance, `fundOracle()` sends zero: it only unwraps sIMD. | Keep fallback funding until withdrawable sIMD and successful Treasury funding are demonstrated; document manual funding from plain IMD. |\n| Info | `src/Parameters.sol:249`, `proposeWorkOracle` NatSpec | Calling `WorkOracleFactory.create()` from the governor produces an oracle bound to the governor. Proposing it for an existing vault reverts `InvalidWorkOracle`, contrary to the newly documented replacement route. | Document direct construction of `SwarmWorkOracle(existingVault, maxAge)`. |\n\nThe seven requested conclusions follow.\n\n1. **Timelock and proposal execution**\n\n   I found no route around the 48-hour delay, proposer authorization, or enforced bounds. All parameter categories share one pending slot. Only `APPROVED_OPERATOR` proposes or cancels; anyone may apply after maturity. Validation runs at proposal and application. A reverted application restores the pending proposal atomically.\n\n   Enforced bounds include duty ≤1,000 bps, skew 100–2,000 bps, chip plus cut ≤10,000 bps, earnMat ≤2,500 bps, wage ≤1 imdUSD/task, gap 25–100, oracle budget ≤100 IMD/day, redemption divisor 1–8, and stream ≤500 imdUSD/day. A positive stream requires a nonzero payee. The debt ceiling must be positive but has no additional maximum. Reserve and work-oracle checks also run through this route. Economics application checkpoints accrued fees before changing the rate.\n\n   **Chosen-moment application remains possible:** proposals have no expiry. Permissionless execution does not force execution at maturity. This is an explicitly accepted earlier finding, not a new finding here.\n\n   Live-state validation can also prevent application indefinitely until cancellation: for example, a proposed reserve dependency stops answering, or an outstanding-rights holder performs the first work mint while a fresh, non-predecessor oracle replacement is pending. The former debt-ceiling hostage condition is removed: outstanding debt no longer blocks a ceiling proposal.\n\n2. **Every Treasury exit**\n\n   | Exit | Caller and effective bound |\n   |---|---|\n   | `withdraw` | Operator only. Rejects collateral and listed reserves. imdUSD withdrawal must leave at least `totalBadDebt`; other unlisted tokens are withdrawable up to balance. No daily lim","treeHash":null,"usage":{"cachedInputTokens":4794368,"inputTokens":290228,"model":"gpt-6-astra","outputTokens":30465,"runtime":"codex","turns":9,"wallClockMs":1896252}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"4ebee02fc506f27c","findings":[{"citation":"resolved","description":"Q6 (ordering of wage and oracle proposals) and Q1. ParameterizedVault._lagApplies() returns parameters.wage() != 0, and backedDebt() reads the lagged debt (CDPVault.laggedNow, the fix for D1, launch audit 2026-10-05 vault panel, medium) only when it is true. The NatSpec at lines 109-110 says the lag 'applies exactly while minting from work is on', and DeploymentConfig.sol:168-172 / runbook 7b equate 'off' with WAGE_WAD == 0. But the wage gates only SwarmWorkOracle.claim (line 157): rights are priced AT CLAIM and stored in creditedRights, consumeRights has no wage check, and CDPVault.earn (line 476) checks mintingRights and earnLine only. So every right claimed while a wage was in force survives the wage being proposed back to zero, and in that state earn still mints while the ceiling reads the LIVE debt (min(totalDebt, debtAtTransactionStart) - bad), not the warmed-up figure. That state is not exotic: Parameters.proposeWorkOracle is refused unless the wage is zero (line 385), so replacing the oracle (the documented upstream-integration path) REQUIRES governance to pass through wage 0 with the old oracle's rights intact, for at least 48 hours of the oracle proposal plus 48 hours of the wage re-proposal. Any account holding unconsumed rights can then run the D1 sequence one block apart: lock + draw in block N, earn a quarter of that debt in block N+1, wipe + free in block N+2, leaving work-minted imdUSD with no debt behind it (backed only by the reserve term). Call sequence (rights holder R, unprivileged after the wage cycle): [governance: proposeWage(w) -> applyPending; R claims; governance: proposeWage(0) -> applyPending] then R: lock(2000e18); draw(1000e18) | next block: earn(250e18) | wipe(debtOf(R)); free(2000e18). Expected (ParameterizedVault.backedDebt NatSpec and test/LaggedBacking.t.sol: debt one block old earns about 0.014% of its credit): earn(250e18) reverts WorkCeilingReached. Actual: earnLine() == 250e18 and the earn succeeds; after the unwind totalDebt == 0 and totalEarned == 250e18. Reachable with the constants as committed once governance has ever set a nonzero wage (the launch wage is zero, so not on day one). Bounded by earnMat (25% of debt that existed at transaction start) and by LINE, i.e. up to $250k of unbacked imdUSD per round trip at the committed $1M line, repeatable while rights last. Also, because earn works at wage 0, a rights holder earning 1 wei during the 48-hour window of a pending proposeWorkOracle(address(0)) or proposeWorkOracle(fresh SwarmWorkOracle) makes the application revert InvalidWorkOracle for good (minted == true; test_applyRechecksTheRules pins this as intended), so the shipped-contract replacement path can be closed by anyone with dust rights. Smallest fix: make the lag unconditional (return true from _lagApplies, which the redemption half already is), or have earn revert while parameters.wage() == 0 so 'minting from work is off' means what the NatSpec says; update the NatSpec at ParameterizedVault.sol:109 and Parameters.sol:239-243 either way.","line":112,"path":"src/ParameterizedVault.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 {CDPVault} from \"src/CDPVault.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 WZFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract WZMirror is ISwarmFeed {\n    ISwarmFeed private immutable primary;\n\n    constructor(ISwarmFeed p) {\n        primary = p;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return primary.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return primary.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return primary.maxAge();\n    }\n}\n\ncontract WZAggregator {\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 The lagged work ceiling (the D1 fix) is read only while `parameters.wage() != 0`, but\n/// `earn` consumes rights whatever the wage is. Rights claimed while a wage was in force survive the\n/// wage being set back to zero (the state `proposeWorkOracle` requires), and in that state the D1\n/// round trip is open again: borrow in one block, earn against a quarter of it in the next, unwind.\ncontract WageZeroLagOffTest is Test {\n    address private constant WORKER = address(0xCA);\n    address private constant HELPER = address(0x4E1);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    MockWorkOracle private oracle;\n    Parameters private params;\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 WZAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        // 1 IMD = 1/2000 ETH and 1 ETH = $2000: the vault prices IMD at exactly $1.\n        WZFeed primary = new WZFeed(uint256(1 ether) * 1e18 / 2000 ether);\n        WZFeed health = new WZFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new WZMirror(primary))\n        );\n        stable = vault.stablecoin();\n        oracle = MockWorkOracle(address(vault.oracle()));\n        params = vault.parameters();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(WORKER, 2_000 ether);\n        imd.mint(HELPER, 200 ether);\n        vm.stopPrank();\n        vm.prank(WORKER);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(HELPER);\n        imd.approve(address(vault), type(uint256).max);\n    }\n\n    function _setWage(uint256 wad) private {\n        vm.prank(APPROVED_OPERATOR);\n        params.proposeWage(wad);\n        vm.warp(block.timestamp + params.TIMELOCK());\n        params.applyPending();\n        assertEq(params.wage(), wad);\n    }\n\n    function _nextBlock() private {\n        vm.roll(block.number + 1);\n        vm.warp(block.timestamp + 12);\n    }\n\n    function test_rightsClaimedUnderAWageAreMintedUnlaggedOnceTheWageIsZero() public {\n        // Minting from work was on; an agent claimed rights (priced at claim, kept in the oracle).\n        _setWage(0.01 ether);\n        vm.prank(APPROVED_OPERATOR);\n        oracle.grantRights(WORKER, 1_000 ether);\n        // Governance turns the wage back to zero (required before any proposeWorkOracle). Rights stay.\n        _setWage(0);\n        assertEq(oracle.mintingRights(WORKER), 1_000 ether, \"rights survive the wage going to zero\");\n\n        // The D1 round trip, one block apart: borrow, then earn against a quarter of that debt.\n        vm.startPrank(WORKER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        vm.stopPrank();\n        _nextBlock();\n\n        // Expected, per ParameterizedVault.backedDebt / test/LaggedBacking.t.sol: debt one block old\n        // earns about 0.014% of its credit, so a 250 imdUSD earn is refused (WorkCeilingReached).\n        // Actual: `_lagApplies()` is false at wage 0, earnLine reads the live 1_000 * 25% = 250, and the\n        // earn succeeds.\n        vm.prank(WORKER);\n        vm.expectRevert();\n        vault.earn(250 ether);\n        assertEq(vault.totalEarned(), 0, \"no work-minted imdUSD against one-block-old debt\");\n\n        // Unwind to show what the lag was protecting: work-minted imdUSD outliving the debt behind it.\n        // (A helper position supplies the 12 seconds of stability fee the worker owes.)\n        vm.startPrank(HELPER);\n        vault.lock(200 ether);\n        vault.draw(50 ether);\n        stable.transfer(WORKER, 50 ether);\n        vm.stopPrank();\n        vm.startPrank(WORKER);\n        vault.wipe(vault.debtOf(WORKER));\n        vault.free(2_000 ether);\n        vm.stopPrank();\n        assertEq(vault.totalDebt(), 50 ether, \"only the helper's debt remains\");\n        assertEq(vault.totalEarned(), 0, \"nothing minted from work outlives the debt that authorised it\");\n    }\n}","reproduction":"test/scratch/WageZeroLagOff.t.sol (FAILS on this code: 'next call did not revert as expected'). ParameterizedVault over MockIMD priced at $1 (IMD/ETH 5e14, ETH/USD 2000e8), MockWorkOracle (the faucet stands in for a claim made while the wage was nonzero). Governance: proposeWage(0.01e18) -> +48h -> applyPending; operator grants WORKER 1000e18 rights; proposeWage(0) -> +48h -> applyPending; mintingRights(WORKER) == 1000e18. WORKER: lock(2000e18); draw(1000e18). Roll one block (+12 s). Expected: earn(250e18) reverts (WorkCeilingReached; lagged debt about 0.14e18 so earnLine < 0.1e18). Actual: earn(250e18) succeeds, totalEarned == 250e18; WORKER then wipe(debtOf) and free(2000e18): totalDebt == 0, totalEarned == 250e18.","severity":"medium","snippet":"        return parameters.wage() != 0;","title":"The lagged work ceiling (D1 fix) switches off whenever the wage is zero, but rights claimed under an earlier wage stay consumable through earn, so the borrow / earn / unwind round trip is open again i"},{"citation":"resolved","description":"Q3 (day one) and Q2. fundOracle reads token = gem() (sIMD on mainnet), sets share = true, and sizes the transfer by IShareVault(sIMD).maxWithdraw(treasury) alone, sending through _withdrawUnderlying. IMD held directly by the Treasury is read only as the ASKER's balance (line 491) and is never a source. docs/MAINNET-RUNBOOK.md:336-337 says 'Once revenue lands (launch-pool fees in IMD arrive through handOffLaunchFees; liquidation cuts in sIMD) the Treasury takes over and the fallback can be switched off', and Treasury.sol:33-36 explains the Treasury accepts both pool currencies for that reason. For the IMD half that is not what the code does: a Treasury holding any amount of IMD and no sIMD sends 0 from fundOracle, every day, and the keyless oracle path stays dead until the first liquidation cut lands as sIMD (or someone donates sIMD). The state Q3 asks about therefore exists: asker drained, keeper budget spent, Treasury rich in IMD from LP fees, fundOracle returns 0. What revives it is not in the runbook: the operator's withdraw(IMD, ORACLE_ASKER, amount) (IMD is neither gem nor listed, so it is allowed), a manual, keyed step for a path documented as keyless. Smallest fix: in fundOracle, when share is true, spend IMD held directly first (IERC20(imd).balanceOf(this), via _withdraw to ORACLE_ASKER) and unwrap shares only for the remainder; or correct the runbook sentence so the operator knows to move LP-fee IMD by hand. Also, if IMD is ever LISTED as a reserve asset, withdraw(IMD) is refused too (ReserveProtected) and the IMD is unreachable for the oracle by any route.","line":494,"path":"src/Treasury.sol","reproduction":"test/scratch/TreasuryChecks.t.sol test_fundOracleIgnoresIMDHeldDirectly (PASSES: it demonstrates the state). ParameterizedVault over an sIMD-shaped share (24 decimals, convertToAssets(1e18) = 7.95e12); ORACLE_ASKER etched with code; oracleBudget 15e18. Mint 100e18 IMD straight to the Treasury; sIMD balance 0. Expected per runbook 7.4: fundOracle() sends 15e18 IMD to the asker. Actual: fundOracle() returns 0 and the asker holds 0; only APPROVED_OPERATOR's treasury.withdraw(IMD, ORACLE_ASKER, 15e18) moves it.","severity":"low","snippet":"        uint256 available = share ? IShareVault(token).maxWithdraw(address(this)) : IERC20(token).balanceOf(address(this));","title":"fundOracle unwraps only the collateral share: IMD the Treasury holds directly (the launch-pool fee half the runbook says lets 'the Treasury take over' the oracle budget) never reaches the asker withou"},{"citation":"resolved","description":"Q6. SwarmWorkOracle.claim (lines 149-168) checks acceptedRoots, wage() != 0, the ERC-8004 controller, the proof and cumulative > creditedTasks. It never checks that it is still the vault's oracle (vault.oracle() == address(this)); wage() reads the vault's Parameters, which is the same for the old and the new oracle. After governance replaces oracle A with B and sets a wage, both A and B accept claims for the same (agentId, cumulative) leaves, and an agent who claims in A (a stale front end, an old address) credits its tasks there, where the vault never reads them; it can still claim the same cumulative in B, because creditedTasks is per contract. No doubling of CONSUMABLE rights follows: the vault reads one oracle at a time, and switching back to A (proposeWorkOracle(address(0))) is refused once anything was minted, so at most one of the two credits is ever consumed. The claim at lines 239-241 and the matching sentence in docs/MAINNET-RUNBOOK.md 7b are nevertheless false as written, and the related 'minting from work is OFF while the wage is zero' reading is false too (see the medium finding: earn consumes already-claimed rights at any wage). Smallest fix: reword to 'no rights are ever CONSUMABLE in two oracles at once', or add `if (IVault(vault).oracle() != address(this)) revert` to SwarmWorkOracle.claim so a superseded oracle refuses claims (a raw staticcall, so a plain CDPVault without the view still works).","line":240,"path":"src/Parameters.sol","reproduction":"State: ParameterizedVault with created SwarmWorkOracle A; governance applies proposeWorkOracle(B) (B = new SwarmWorkOracle(vault, 1 days), wage 0, nothing minted) and then proposeWage(1e18). Agent controller C with leaf (agentId, accepted, cumulative = 100) under a root both feeds have accepted. Expected per the NatSpec: A refuses the claim (rights claimable in one oracle only). Actual: A.claim(...) succeeds (A.wage() reads the vault's parameters and is 1e18), creditedRights[C] in A = 100e18, vault.oracle() == B still reads 0 for C; C then claims the same leaf in B and earns there.","severity":"info","snippet":"    /// from work is ON — at proposal and again at application — so no rights are ever claimable in two","title":"NatSpec: 'no rights are ever claimable in two oracles at once' is not a property the code has: a replaced SwarmWorkOracle keeps accepting claims whenever the vault's wage is nonzero"}],"hash":"917cfdb273986547c8656d61d16fcc7ba88b7c5148acff979a9289d0d93f9a1a","nodeId":"e91dbc27-afac-4467-8203-c12840f14c90","outcome":"completed","summary":"Review complete. No source, script, config or doc file was changed; the additions are `.imd-findings.json` and three files under `test/scratch/`.\n\n## Result\n\nThree findings, one medium. The governance, Treasury and factory code is otherwise tight at this commit: every bound I tried to cross held, and the three earlier rounds' fixes have not regressed. The medium is a gap the D1 fix left open rather than a regression of it.\n\n| # | Severity | Where | Finding |\n|---|---|---|---|\n| 1 | medium | `src/ParameterizedVault.sol:112` | The lagged work ceiling is read only while the wage is nonzero, but rights claimed under an earlier wage stay consumable through `earn`. Replacing the oracle requires passing through wage zero, and in that state the borrow, earn, unwind round trip mints unbacked imdUSD one block apart again. Proof test fails on this code. |\n| 2 | low | `src/Treasury.sol:494` | `fundOracle` unwraps only sIMD. IMD the Treasury holds directly, which the runbook says lets the Treasury take over the oracle budget once launch-pool fees land, never reaches the asker without the operator key. |\n| 3 | info | `src/Parameters.sol:240` | NatSpec says no rights are ever claimable in two oracles at once. A replaced SwarmWorkOracle keeps accepting claims whenever the vault's wage is nonzero, because `claim` never checks it is still the vault's oracle. |\n\n## Answers to the numbered questions\n\n**1. Timelock.** Nothing applies sooner than 48 hours, outside its bounds, or by anyone but the governor. Every `propose*` routes through `_propose`, which is governor-only, validates, and sets the eta at exactly `TIMELOCK`. `applyPending` validates again and is open to anyone. The late-application window is the known note in `Governed.sol`. A pending change can be stalled by a third party in one new way: a rights holder earning one wei during a pending `proposeWorkOracle` makes its application revert for good (finding 1). That is tested as intended, but it means anyone with dust rights can close the shipped-contract replacement path.\n\n**2. Treasury exits.** Value leaves by `withdraw` and `withdrawNative` (operator key, reserve and bad-debt floors hold), `payStream` (per UTC day, capped at the governed figure, hard cap 500, bad debt first), `fundOracle` (per UTC day, capped at the governed budget, topped up to one day's worth, sIMD only), `redeemIMD` (vault only, from `cash`), `cover` (anyone, only a drained or dust position's recorded loss), and `handOffLaunchFees` (operator, future fees only per the pinned interface, which I could not verify against the live factory). Day boundaries allow two adjacent days' worth within minutes, which is what \"per UTC day\" means. A mid-day rate rise pays the difference the same day and a cut pays nothing more. No rounding moves value the wrong way.\n\n**3. fundOracle on day one.** The dead state exists: asker drained, keeper budget spent, Treasury holding IMD from launch-pool fees but no sIMD. `fundOracle` returns zero and the runbook's \"the Treasury takes over\" sentence is wrong for that half. The operator's `withdraw` to the asker revives it, but that is a keyed step the runbook does not name (finding 2).\n\n**4. Accounting.** Sound. Every exit credits unsynced arrivals before moving the baseline, and the baseline moves before the external call, so a re-entered `sync` or `syncNative` credits nothing twice. `cover`'s double sync correctly records the reminted fees. Nothing is lost for exact tokens, and sIMD, imdUSD and IMD are exact.\n\n**5. Reserve valuation.** No listed feed or token can revert the sum: all three reads are raw staticcalls with shape checks, the overflow guard is exact, and the per-asset cap and saturating add hold. The vault's collateral is forced to 18 decimals and to `collateralPriceFeed`, which values sIMD correctly per 1e18 raw units. Misvaluation now takes a governance listing error visible for 48 hours.\n\n**6. proposeWorkOracle.** The wage rule cannot be bypassed by ordering, since one slot holds","treeHash":null,"usage":{"cachedInputTokens":4108137,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":59739,"runtime":"claude","turns":63,"wallClockMs":1021185}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e8816d4386532a66","findings":[{"citation":"resolved","description":"Q1 and Q6; a gap left by the D1 fix (launch audit 2026-10-05, vault panel, medium; adversarial review 2026-10-05, finding 1, which said 'keep the wage gate only for backedDebt/earnLine if desired'). The gate was kept on the premise, stated at ParameterizedVault.sol:109 ('applies exactly while minting from work is on'), CDPVault.sol:864-865, DeploymentConfig.sol:168-172 and docs/LAUNCH-READINESS.md:84 ('only while the wage is zero (so no rights are ever outstanding in the old oracle...)'), that wage == 0 means minting from work is off. The code does not have that property. SwarmWorkOracle prices rights AT CLAIM (creditedRights, lines 164-166); the wage gates only `claim` (line 157). Neither SwarmWorkOracle.consumeRights (line 178) nor CDPVault.earn (line 476) reads the wage, so every right claimed while a wage was in force survives governance setting the wage back to 0 and is mintable through earn at wage 0 — and in that state `_lagApplies()` is false, so backedDebt() reads min(totalDebt, debtAtTransactionStart) with no warm-up and debt opened in the PREVIOUS transaction (same or next block) counts in full. That is exactly the adjacent-transaction round trip D1 described: lock+draw in one transaction, earn 25% of that debt in the next, wipe+free in a third, leaving work-minted imdUSD with no debt behind it. The state 'rights > 0 and wage == 0' is not exotic: Parameters.proposeWorkOracle is refused unless the wage is zero (line 385), so the documented upstream-integration path (runbook 7b.3) REQUIRES governance to pass through wage 0 with the old oracle's rights intact for at least 96 hours; an emergency wage-to-0 after a bad tally produces the same state. Not reachable on day one (WAGE_WAD = 0, no rights exist), reachable with the constants as committed after one ordinary wage cycle. Bounded by earnMat (25% of debt that existed at transaction start) and LINE: up to $250k of unbacked imdUSD per round trip at the $1M line, repeatable while rights last, collateral exposed for one block. Who loses: every imdUSD holder (backingPerUnit falls, redemption pays less). Also a liveness consequence: setting the wage to 0 is NOT an emergency stop for minting from work. SECOND CONSEQUENCE (Q1, 'can a pending proposal be blocked'): because earn works at wage 0, any account holding dust rights can call earn(1) during the 48-hour window of a pending proposeWorkOracle (fresh SwarmWorkOracle or address(0)); applyPending then re-validates with totalEarned != 0, the typed predecessor() call reverts (SwarmWorkOracle has none), the slot stays occupied until the governor cancels, and no shipped oracle nor address(0) can ever be proposed again — a third party closes the documented replacement path permanently (test/scratch/OracleReplacementGriefed.t.sol, passes as demonstration). Smallest fix: make `_lagApplies` return true (the lag is tracked from deployment; at launch no rights exist so nothing changes), AND, so the documented switch is true and the griefing closes, have ParameterizedVault refuse earn while parameters.wage() == 0 (an `_earnAllowed()` hook CDPVault.earn checks, or `if (wage() == 0) revert WorkMintingOff()` in SwarmWorkOracle.consumeRights). Correct the NatSpec at ParameterizedVault.sol:109-110/243-249, CDPVault.sol:285-287/864-865, Parameters.sol:239-243 and runbook 7b either way. Merged from audit_economics, audit_math and audit_flow (one finding, three proofs; all three fail on this code and pass with `_lagApplies` returning true).","line":112,"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 {CDPVault} from \"src/CDPVault.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {SwarmWorkOracle} from \"src/SwarmWorkOracle.sol\";\nimport {WorkOracleFactory} from \"src/WorkOracleFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {\n    APPROVED_OPERATOR, TREASURY_FACTORY, WORK_ORACLE_FACTORY,\n    WORK_ORACLE_SENTINEL, CHAINLINK_ETH_USD, ERC8004_ADAPTER\n} from \"src/DeploymentConfig.sol\";\n\ncontract AuditIMD is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n    function mint(address to, uint256 amount) external { _mint(to, amount); }\n}\n\ncontract AuditShare is ERC20 {\n    IERC20 public immutable asset;\n    constructor(IERC20 token) ERC20(\"sIMD\", \"sIMD\") { asset = token; }\n    function decimals() public pure override returns (uint8) { return 24; }\n    function convertToAssets(uint256 shares) public pure returns (uint256) {\n        return shares * 7.95e12 / 1e18;\n    }\n    function deposit(uint256 amount, address to) external returns (uint256 shares) {\n        asset.transferFrom(msg.sender, address(this), amount);\n        shares = amount * 1e18 / 7.95e12;\n        _mint(to, shares);\n    }\n}\n\ncontract AuditFreshFeed is ISwarmFeed {\n    uint256 private immutable value;\n    constructor(uint256 v) { value = v; }\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, uint64(block.timestamp));\n    }\n    function maxAge() external pure returns (uint256) { return 1 days; }\n    function isStale() external pure returns (bool) { return false; }\n}\n\ncontract AuditUsd {\n    function decimals() external pure returns (uint8) { return 8; }\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n// Only the attestation fixture is substituted: claim/rights/consume/wage remain production code.\ncontract AuditSeededWork is SwarmWorkOracle {\n    constructor(address v, uint256 age) SwarmWorkOracle(v, age) {}\n    function seed(bytes32 root) external { _accept(uint256(root), uint64(block.timestamp)); }\n}\n\ncontract AuditWorkFactory {\n    function create(uint256 age) external returns (SwarmWorkOracle) {\n        return new AuditSeededWork(msg.sender, age);\n    }\n}\n\ncontract GovernanceResidualRightsTest is Test {\n    ParameterizedVault private vault;\n    Parameters private params;\n    AuditSeededWork private work;\n    AuditIMD private imd;\n    AuditShare private share;\n    address private constant WORKER = address(0xCA);\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(WORK_ORACLE_FACTORY, address(new AuditWorkFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new AuditUsd()).code);\n        imd = new AuditIMD();\n        share = new AuditShare(IERC20(address(imd)));\n        vault = new ParameterizedVault(\n            address(share), address(0), WORK_ORACLE_SENTINEL,\n            address(new AuditFreshFeed(5e14)), address(new AuditFreshFeed(0.85e18)),\n            address(new AuditFreshFeed(5e14))\n        );\n        params = vault.parameters();\n        work = AuditSeededWork(address(vault.oracle()));\n        _wage(1e18);\n        bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(uint256(7), uint32(500), uint64(500)))));\n        work.seed(root);\n        work.recordRoot();\n        vm.mockCall(ERC8004_ADAPTER, abi.encodeWithSignature(\"isController(uint256,address)\", 7, WORKER), abi.encode(true));\n        vm.prank(WORKER);\n        work.claim(7, 500, 500, new bytes32[](0), root);\n        assertEq(work.mintingRights(WORKER), 500e18);\n        _wage(0);\n    }\n\n    function _wage(uint256 rate) private {\n        vm.prank(APPROVED_OPERATOR);\n        params.proposeWage(rate);\n        vm.warp(params.pendingEta());\n        params.applyPending();\n    }\n\n    function test_zeroWageMustNotDisableBackingLagForOutstandingRights() public {\n        assertEq(params.wage(), 0);\n        assertEq(vault.totalEarned(), 0);\n        imd.mint(WORKER, 2000e18);\n        vm.startPrank(WORKER);\n        imd.approve(address(vault), 2000e18);\n        vault.lockIMD(2000e18);\n        vault.draw(1000e18);\n        vm.stopPrank();\n        (uint256 lagDebt,) = vault.laggedNow();\n        assertEq(lagDebt, 0, \"fresh debt has not warmed up\");\n\n        // isolate=true gives each top-level call its own transaction, in the SAME block.\n        // Allow either a zero-wage mint guard or an unconditional backing lag as the fix.\n        vm.prank(WORKER);\n        (bool minted,) = address(vault).call(abi.encodeCall(CDPVault.earn, (250e18)));\n        minted;\n        vm.startPrank(WORKER);\n        vault.wipe(1000e18);\n        (uint256 locked,) = vault.positions(WORKER);\n        vault.free(locked);\n        vm.stopPrank();\n        assertEq(vault.totalDebt(), 0);\n        assertEq(share.balanceOf(address(vault)), 0);\n        assertEq(share.balanceOf(address(vault.treasury())), 0);\n        assertEq(vault.stablecoin().totalSupply(), 0, \"fresh temporary debt must not leave unbacked work supply\");\n    }\n\n}","reproduction":"Attached proof (test/scratch/GovernanceResidualRights.t.sol shape; FAILS on this code with '250000000000000000000 != 0'). ParameterizedVault over a 24-decimal share of IMD (rate 7.95e12), IMD $1 (primary 5e14 x ETH/USD 2000), NHI 0.85, WORK_ORACLE_SENTINEL with the factory etched to build a SwarmWorkOracle subclass whose only addition is seeding an accepted root; claim/rights/consume/wage are production code. Governance: proposeWage(1e18), +48h, applyPending; WORKER proves leaf (7, 500, 500) and claims through SwarmWorkOracle.claim: mintingRights(WORKER) == 500e18; proposeWage(0), +48h, applyPending. State: wage 0, totalEarned 0. WORKER: lockIMD(2000e18), draw(1000e18) (laggedNow().debt == 0). Separate transaction: earn(250e18). EXPECTED: refused (WorkCeilingReached: the lagged debt is 0 so earnLine == 0; or the wage-0 switch refuses minting from work). ACTUAL: earnLine() == 250e18, earn succeeds. WORKER: wipe(1000e18), free(all). End state: totalDebt 0, vault and Treasury hold 0 collateral, imdUSD totalSupply == totalEarned == 250e18 held by WORKER. Second shape (audit_economics proof, MockIMD at $1, faucet rights): lock(400e18)+draw(200e18); next block +12 s; earn(50e18) succeeds where the lag would give earnLine 6.94e15. Griefing: test/scratch/OracleReplacementGriefed.t.sol — WorkBackingFixture (wage 0, faucet rights), _openDebt(100e18), governor proposes new SwarmWorkOracle(vault, 1 days); +1 day WORKER earn(1); at eta applyPending reverts, pendingEta still set; after cancel, proposeWorkOracle(fresh) and proposeWorkOracle(address(0)) both revert.","severity":"medium","snippet":"        return parameters.wage() != 0;","title":"Wage 0 switches the D1 lagged work ceiling off while rights claimed under an earlier wage stay consumable through earn, reopening the borrow / earn / unwind round trip and letting any rights holder bl"},{"citation":"resolved","description":"Q3 (the day-one dead state) and Q2. With sIMD as the gem, `share` is true, `available` is IShareVault(sIMD).maxWithdraw(this) alone and the only transfer is `_withdrawUnderlying` (unwrapping shares). Plain IMD held by the Treasury is read only as the ASKER's balance (line 491) and is never a source, even though `imd` (the asset) is resolved two lines above and the asker is paid in exactly that token. The Treasury's only sIMD income is the liquidation cut; its documented non-liquidation revenue (Treasury.sol:33-36, handOffLaunchFees) is plain IMD and ETH. docs/MAINNET-RUNBOOK.md:336-337 tells the operator the opposite: 'Once revenue lands (launch-pool fees in IMD arrive through handOffLaunchFees; liquidation cuts in sIMD) the Treasury takes over and the fallback can be switched off.' An operator who follows that and switches KEEPER_ORACLE_FALLBACK off on the strength of IMD revenue leaves NHI with no buyer once the asker's 15 IMD seed is spent: fundOracle() returns 0 every day whatever IMD the Treasury holds, NhiFeed goes stale 24 h later and ParameterizedVault._pricingStale() refuses draw, free-with-debt, bark, bite, cash and cover's dust path until someone buys an answer with their own IMD. The runbook has no step for this state; the revival is a keyed action it does not name: APPROVED_OPERATOR calling Treasury.withdraw(IMD, ORACLE_ASKER, x), allowed because IMD is neither the gem nor listed — the same fact that lets the operator take that revenue outright. If IMD is ever LISTED as a reserve asset (ParameterizedVault.sol:42-43 names usdPriceFeed as 'the price source the Treasury's register is expected to hold for IMD'), withdraw(IMD) reverts ReserveProtected too, fundOracle still sends none, and the IMD counts in reserveValueUsd but can fund the oracle only after a 48-hour delisting. Not a loss of funds; a liveness gap between the runbook's claim and the code, reachable with the constants as committed the moment ORACLE_ASKER has code. fundOracle's own NatSpec (lines 463-472) is consistent with the code; the runbook sentence is the claim without the property. Smallest fix: in fundOracle, when `share`, pay plain IMD first — `uint256 idle = IERC20(imd).balanceOf(address(this)); uint256 fromIdle = Math.min(want, idle); if (fromIdle != 0) _withdraw(IERC20(imd), ORACLE_ASKER, fromIdle); want -= fromIdle;` — then unwrap only the remainder from maxWithdraw (all under the same daily counter); and/or correct runbook 7.4 to say only sIMD (liquidation cuts, donations) funds the asker and that LP-fee IMD must be moved by the operator's withdraw. Merged from audit_economics, audit_math, audit_permissions and audit_flow (the runbook-line variant is the same defect seen from the doc side).","line":494,"path":"src/Treasury.sol","reproduction":"test/scratch/JudgeChecks.t.sol test_fundOracleIgnoresPlainIMD (PASSES: demonstrates the state). ParameterizedVault over a 24-decimal share of IMD (convertToAssets(1e18) = 7.95e12), ORACLE_ASKER etched with code, default oracleBudget 15e18. 100e18 IMD minted straight to the Treasury (the shape of a launch-pool fee payout); share.balanceOf(treasury) == 0. EXPECTED per runbook 7.4: fundOracle() sends 15e18 IMD to the asker. ACTUAL: fundOracle() returns 0, imd.balanceOf(ORACLE_ASKER) == 0, oracleSpent == 0; APPROVED_OPERATOR then withdraws the full 100e18 to an arbitrary address (not ReserveProtected). Control (test_fundOraclePaysFromShares): 100e18 IMD deposited as shares to the Treasury -> fundOracle() sends 15e18. test_listedIMDIsUnreachableForTheOracle: list IMD (proposeReserveAsset(IMD, vault.usdPriceFeed(), 10000), +48h, apply): reserveValueUsd() > 0, treasury.withdraw(IMD, ORACLE_ASKER, 1e18) reverts ReserveProtected(IMD), fundOracle() still returns 0.","severity":"low","snippet":"        uint256 available = share ? IShareVault(token).maxWithdraw(address(this)) : IERC20(token).balanceOf(address(this));","title":"fundOracle with a share collateral pays only from sIMD (maxWithdraw) and never from IMD the Treasury holds directly, so the runbook's 'once launch-pool fees in IMD land the Treasury takes over' is fal"},{"citation":"resolved","description":"Q5 ('can a listed feed or token make the sum revert'). _readBool, _readValue and _readBalance each use the `(bool, bytes memory)` staticcall pattern, which copies the callee's ENTIRE returndata into memory before the `success` and `data.length` checks run. A listed feed (or token) that answers with a few megabytes of returndata therefore makes the Treasury's own frame pay the quadratic memory-expansion cost; the callee can size its answer to roughly half the available gas (it can read gasleft()), so the caller cannot afford the copy at any gas limit, and the outer call reverts with out-of-gas before the length check that was meant to count the source for nothing. That contradicts reserveValueUsd's NatSpec (206-208: 'it never makes this view revert'), reserveValueOf's (219-221) and the AUDIT FIX note at 244-251 (job da7d5b1c), and it is not caught by validateReserveAsset, which probes the same three reads with the same copying pattern (a feed that answers well-formed words at listing and bombs later passes). Reach: it needs a listed dependency that misbehaves after listing — a mutable/upgradeable feed or token the governor listed, 48 hours visible — so it is not reachable with the mainnet configuration (sIMD through SharePriceFeed) behaving as implemented and there is no permissionless listing. Impact is liveness only: while the feed bombs, earnLine()/earn, reserveValue(), _redemptionReserveBacking (so backingPerUnit and every cash) revert until a 48-hour delisting matures (a delisting proposal does not re-probe the feed — validateReserveAsset returns early for a zero price source, lines 149-153 — so the register itself is not stuck; the vault's ceiling, backing and redemption are, for the two days). Smallest fix: make the three reads fixed-size — `staticcall(gas(), target, add(call, 32), mload(call), out, 32)` (64 for latestValue) in assembly, then check `returndatasize() >= 32/64` and read the words from `out`, never calling returndatacopy with the callee's length; optionally cap the gas forwarded per read (e.g. 100k) so one source cannot consume the traversal's gas either. Verified: with _readBool rewritten that way the attached test passes.","line":261,"path":"src/Treasury.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\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 {Treasury} from \"src/Treasury.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\n\ncontract ReturnDataReserve is ERC20 {\n    constructor() ERC20(\"Reserve\", \"RSV\") { _mint(msg.sender, 100e18); }\n}\n\ncontract ReturnDataFeed {\n    bool public broken;\n    function fail() external { broken = true; }\n    function isStale() external view returns (bool) {\n        if (broken) {\n            assembly {\n                mstore(0, 0)\n                return(0, 0x200000)\n            }\n        }\n        return false;\n    }\n    function latestValue() external view returns (uint256, uint64) {\n        return (1e18, uint64(block.timestamp));\n    }\n}\n\ncontract ReserveReturnDataTest is Test {\n    function parameters() external view returns (address) { return address(this); }\n    function gem() external pure returns (address) { return address(0x1234); }\n    function stablecoin() external pure returns (address) { return address(0x5678); }\n\n    function test_brokenFeedMustNotRevertTheEntireReserveRead() public {\n        Treasury treasury = new Treasury(address(this));\n        ReturnDataReserve token = new ReturnDataReserve();\n        ReturnDataFeed feed = new ReturnDataFeed();\n        token.transfer(address(treasury), 100e18);\n        treasury.setReserveAsset(IERC20(address(token)), ISwarmFeed(address(feed)), 10_000);\n        assertEq(treasury.reserveValueUsd(), 100e18);\n        feed.fail();\n        (bool ok,) = address(treasury).staticcall{gas: 16_000_000}(abi.encodeCall(Treasury.reserveValueUsd, ()));\n        assertTrue(ok, \"a broken feed must count for zero instead of exhausting the caller's memory-copy gas\");\n    }\n}","reproduction":"Attached proof (test/scratch/ReserveReturnData.t.sol; FAILS on this code). A standalone Treasury whose `vault` is the test contract (answering parameters() == test, gem()/stablecoin() == unrelated addresses, so the test is the registrar and the listing is identical to one Parameters.applyPending would make). List an 18-decimal ERC-20 holding 100e18 at the Treasury, haircut 10000, with a feed whose isStale() returns false and latestValue() returns (1e18, now): reserveValueUsd() == 100e18. Flip the feed so isStale() does `return(0, 0x200000)` (first word 0, a valid ABI false, 2 MiB long). Call treasury.reserveValueUsd() with 16,000,000 gas. EXPECTED (NatSpec 206-208): the call completes and the asset counts for zero. ACTUAL: the callee returns successfully, the Treasury runs out of gas copying 2,097,152 bytes, the outer staticcall returns ok == false. With _readBool patched to a 32-byte fixed-output staticcall the same test passes (reserveValueUsd completes).","severity":"low","snippet":"        (bool success, bytes memory data) = address(feed).staticcall(call);","title":"Treasury's isolated reserve reads copy unbounded returndata, so a listed feed or token can still make reserveValueUsd (and with it earnLine, earn, backingPerUnit and cash) run out of gas, contrary to "},{"citation":"resolved","description":"Q6, a documentation claim without the property, added by the fix commit b73a05f for the second-half review's info finding 6. WorkOracleFactory.create(maxAge) does `new SwarmWorkOracle(msg.sender, maxAge_)` (WorkOracleFactory.sol:36): the oracle's `vault` is whoever called the factory. A ParameterizedVault calls the factory only inside its own constructor; it has no later entry point that does. So any post-deployment call by the governor (or anyone) yields an oracle whose vault() is the caller, and Parameters._validate (line 392) refuses it because `successor.vault() != address(vault)`. docs/MAINNET-RUNBOOK.md:376 repeats the sentence. The working route is direct construction, `new SwarmWorkOracle(address(vault), WORK_ORACLE_MAX_AGE)` from any account (the vault has code, so the constructor's codeless-vault check passes), which carries the same bytecode-pinned question and is accepted. No security consequence: the rule (vault() must be this vault, wage must be 0, predecessor once anything was minted) holds and no wrong-vault oracle can be installed; a governor following the NatSpec simply has the proposal refused. Smallest fix: reword both sentences to name direct construction, or add `createFor(address vault_, uint256 maxAge_)` to WorkOracleFactory requiring `vault_.code.length != 0`. Merged from audit_economics, audit_permissions and audit_flow.","line":249,"path":"src/Parameters.sol","reproduction":"test/scratch/JudgeChecks.t.sol test_factorySuccessorRefused (PASSES: demonstration). ParameterizedVault with wage 0 and totalEarned 0. `WorkOracleFactory f = new WorkOracleFactory(); vm.prank(APPROVED_OPERATOR); SwarmWorkOracle o = f.create(1 days);` -> o.vault() == APPROVED_OPERATOR. EXPECTED per the NatSpec: parameters.proposeWorkOracle(address(o)) is queued. ACTUAL: reverts Parameters.InvalidWorkOracle. `new SwarmWorkOracle(address(vault), 1 days)` is then proposed successfully: pendingChange() == (WorkOracle, eta > 0).","severity":"info","snippet":"    /// the first mint a fresh `SwarmWorkOracle` (through `WorkOracleFactory.create`) qualifies.","title":"NatSpec (b73a05f) and runbook 7b.3 say a fresh SwarmWorkOracle 'through WorkOracleFactory.create' qualifies as a successor; the factory binds the oracle to its CALLER, so anything the governor obtains"},{"citation":"resolved","description":"Q5 and Q6 together, reported as a privileged-power trust assumption with actor and preconditions stated, not as a bypass: every step is the governor's own, visible for 48 hours, and both powers are the intended design. The NatSpec claims otherwise in two places. Parameters.sol:42-52 says the governed values are ones whose worst case is to 'price the protocol badly', that the sources that could be 'a theft' are out of reach, and that 'the governor cannot widen its own authority'; line 244 says a replacement oracle 'Adds no trust' because the governor 'can already raise the wage'. Raising the wage lets agents mint for attested work; it does not let the governor mint. But proposeReserveAsset accepts ANY token with ANY ISwarmFeed that answers in shape (Treasury.validateReserveAsset checks only the stablecoin, code presence, the gem's feed, well-formed isStale/latestValue and decimals <= 77), reserveValueOf counts it at up to MAX_RESERVE_VALUE ($1e18) per asset, and earnLine = reserveValueUsd + backedDebt x earnMat / 10000. And proposeWorkOracle accepts, while the wage is zero (the launch state), any contract whose vault() is the vault and that answers mintingRights(): one the governor controls. Together: mint with no collateral, no debt and no attested work, bounded only by $1e18 per listed asset. imdUSD holders are diluted and backingPerUnit (the redemption payout) falls. Reachable with the constants as committed (WAGE_WAD = 0); needs the APPROVED_OPERATOR key and 96 hours of public proposals. Smallest fix, if the NatSpec is to be true: restrict a reserve asset's price source to feeds the vault itself exposes (usdPriceFeed, collateralPriceFeed) or to a pinned feed factory's creations, and/or require a successor oracle's `codehash` to equal SwarmWorkOracle's runtime hash. Otherwise correct lines 42-52 and 244 to state the power plainly: the governor sets the reserve term of the work ceiling and the identity of the minter, a trust assumption on APPROVED_OPERATOR. From audit_permissions; reproduced.","line":244,"path":"src/Parameters.sol","reproduction":"test/scratch/JudgeChecks.t.sol GovernorMintsAtWillTest (PASSES: demonstrates the path). WorkBackingFixture (wage 0, empty register, no positions). (1) ReserveTestToken `junk` with 1e18 units in the Treasury and a TestSwarmFeed answering 1e30; APPROVED_OPERATOR proposeReserveAsset(junk, feed, 10000), +48h applyPending: reserve.reserveValueUsd() == 1e30, backedVault.earnLine() == 1e30. (2) APPROVED_OPERATOR proposeWorkOracle(new MockWorkOracle(vault)), +48h applyPending: vault.oracle() is the governor's contract; grantRights(APPROVED_OPERATOR, 2^128-1). (3) APPROVED_OPERATOR backedVault.earn(1_000_000_000e18). EXPECTED per Parameters.sol:42-52/244: no governor action can mint or widen authority. ACTUAL: the governor holds 1e9 imdUSD, totalSupply rose by 1e9 imdUSD, totalDebt == 0.","severity":"info","snippet":"    /// @dev Adds no trust: a governor who could mint through a hostile oracle can already raise the wage.","title":"Trust assumption the NatSpec denies: Parameters says the governor 'cannot widen its own authority' and a replacement oracle 'adds no trust', but a reserve listing against any shape-valid feed sets ear"},{"citation":"resolved","description":"Q6 (stranded or doubled rights across a replacement). SwarmWorkOracle.claim (lines 149-168) checks acceptedRoots, wage() != 0, the ERC-8004 controller, the proof and cumulative > creditedTasks; it never checks that it is still the vault's oracle, and wage() reads the vault's Parameters, which the old and the new oracle share. After governance replaces oracle A with B and sets a wage, both A and B accept claims for the same (agentId, cumulative) leaves: an agent who claims in A (stale front end, old address) credits tasks there, where the vault never reads them, and can claim the same cumulative again in B because creditedTasks is per contract. No doubling of CONSUMABLE rights follows — the vault reads one oracle at a time and switching back is refused once anything was minted — so at most one credit is ever consumed; rights left in A are stranded, as AUDIT-FINAL-2 finding 6 already records. The sentence at 239-241, docs/LAUNCH-READINESS.md:84 ('no rights are ever outstanding in the old oracle, which makes double-claiming impossible by construction') and the matching runbook 7b text are nevertheless false as written, and the 'minting from work is OFF while the wage is zero' reading is false too (see the medium finding). Smallest fix: reword to 'no rights are ever CONSUMABLE in two oracles at once', or add a raw-staticcall check `vault.oracle() == address(this)` to SwarmWorkOracle.claim so a superseded oracle refuses claims. From audit_math; reproduced.","line":240,"path":"src/Parameters.sol","reproduction":"test/scratch/JudgeChecks.t.sol test_replacedOracleStillReadsWage (PASSES: demonstration). ParameterizedVault; A = new SwarmWorkOracle(vault, 1 days), B likewise; proposeWorkOracle(A), +48h apply; proposeWorkOracle(B), +48h apply (wage 0, nothing minted): vault.oracle() == B. proposeWage(1e18), +48h apply. EXPECTED per the NatSpec: A refuses claims (rights claimable in one oracle only). ACTUAL: A.wage() == 1e18 == B.wage(), so A.claim(...) passes its wage gate exactly as B does; with a root both have accepted and a controller the adapter confirms, the same leaf credits creditedRights in A and in B (creditedTasks is per contract).","severity":"info","snippet":"    /// from work is ON — at proposal and again at application — so no rights are ever claimable in two","title":"NatSpec: 'no rights are ever claimable in two oracles at once' is not a property the code has — a superseded SwarmWorkOracle keeps accepting claims whenever the vault's wage is nonzero"},{"citation":"resolved","description":"Q5. setReserveAsset (lines 197-199) pins decimals = 18 for the creating vault's gem and reads `decimals()` for everything else, and reserveValueOf divides by 10**decimals, which is right for a source quoting USD per whole token (the ReserveAsset NatSpec, 46-47). SharePriceFeed (SharePriceFeed.sol:15-26, 56) quotes USD per 1e18 RAW share units, which coincides with per-whole-token only for an 18-decimal share. Its NatSpec (12-13) says 'any ERC-4626 over any asset this protocol can already price works, which is also how a diversified reserve gets priced', and the runbook's open-decisions section contemplates a diversified reserve. Listing a 24-decimal share that is not the gem through a SharePriceFeed therefore values it at balance x (USD per 1e18 raw) / 1e24, a factor 1e6 below its worth. The direction is safe (the ceiling only tightens) and the mainnet configuration (sIMD, the gem, pinned at 18 and required to use collateralPriceFeed) is unaffected. Reported because the register's and SharePriceFeed's NatSpec disagree about the convention and a future listing would silently follow the wrong one. Smallest fix: document in SharePriceFeed that its output is per 1e18 raw units and may be listed in the register only for the gem or an 18-decimal share; or have setReserveAsset pin decimals = 18 for any asset whose price source answers `shareVault()` == the asset. From audit_permissions; reproduced.","line":232,"path":"src/Treasury.sol","reproduction":"test/scratch/JudgeChecks.t.sol test_nonGemShareMisvalued (PASSES: demonstrates the valuation). A 24-decimal share `other` over an 18-decimal token worth $1 (feed 1e18), rate 1.25e12, wrapped in `new SharePriceFeed(other, usdPerOther)`: feed.latestValue() == 1.25e12. 1.25 tokens deposited for the Treasury: other.balanceOf(treasury) == 1e24 (one whole share, worth $1.25). APPROVED_OPERATOR lists (other, feed, 10000), +48h applyPending. EXPECTED: treasury.reserveValueOf(other) == 1.25e18. ACTUAL: 1.25e12.","severity":"info","snippet":"        uint256 unit = 10 ** entry.decimals;","title":"A non-gem ERC-4626 share listed through a SharePriceFeed is valued by its own decimals while SharePriceFeed quotes per 1e18 raw units, so a 24-decimal share counts a million times too low (the inverse"},{"citation":"resolved","description":"A comment claiming numbers the code does not have, not an arithmetic error in how the constant is used. CDPVault._mat (1253-1257) returns 170 at or above NHI 0.85 and 200 at or below 0.60, so the loosest mat is 170 and the cliff where backing with an empty reserve touches one is mat - 1 = 7000 bps, which src/Parameters.sol:82-83 states correctly ('7000 is the cliff ... at 2500 it is 136% with an empty reserve': 1.70 / 1.25 = 1.36). src/DeploymentConfig.sol:146-147 says the cliff 'is 5000 at the loosest NHI. 2500 is half that cliff, 120% worst-case backing', which is docs/COMPUTE-BACKING-DESIGN.md section 3 (lines 102-111) written when mat was 150 at NHI 0.9. The bound (MAX_EARN_MAT_BPS = 2500) is conservative either way — 2500 is about 36% of the real cliff, not half — so nothing in the code is wrong; two source comments disagree and one is stale. Smallest fix: restate DeploymentConfig.sol:146-147 as 'which is 7000 at the loosest NHI (mat 170); 2500 is about a third of that cliff, 136% worst-case backing with an empty reserve', and note in the design doc's table that it predates the 170 floor. From audit_permissions; checked by arithmetic.","line":147,"path":"src/DeploymentConfig.sol","reproduction":"Read-only arithmetic against the committed code. mat at NHI >= 0.85: CDPVault._mat returns 170 (src/CDPVault.sol:1254). Backing with an empty reserve at ratio r, per docs/COMPUTE-BACKING-DESIGN.md section 3: B = mat / (1 + r). At r = 0.25: 1.70 / 1.25 = 1.36 (136%), not 1.20; B = 1 at r = 0.70 (7000 bps), not 5000. EXPECTED: DeploymentConfig.sol:146-147 and Parameters.sol:82-83 state the same cliff and worst case. ACTUAL: 5000 / 120% against 7000 / 136%.","severity":"info","snippet":"/// cliff, 120% worst-case backing with an empty reserve. Parameters refuses any proposal above","title":"DeploymentConfig's EARN_MAT_BPS comment carries the figures for a mat floor of 150 (cliff 5000, 120% worst case); the code's floor is 170, so the cliff is 7000 and the worst case 136%, as Parameters.s"}],"hash":"9625d86b29fc176da53200acc3c0dca31c77d8d0d3970552c8441d086e3229cb","nodeId":"c9ce4b64-eb27-4f12-89cc-1738ef719859","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 8 findings (1 medium, 2 low, 5 info), every snippet verified against the tree, two with proofs that fail on this code and pass with the stated fix. No tracked file was changed (the two temporary patches to `ParameterizedVault.sol` and `Treasury.sol` used to validate the proofs were reverted with `git checkout`; the full existing suite is green at 556/556).\n\n## Judgement of the specialist findings\n\n| Specialist claim | Verdict |\n|---|---|\n| audit_economics / audit_math / audit_flow: wage 0 turns the D1 lag off while claimed rights remain mintable (3 × medium) | **Confirmed, merged into one medium.** All three proofs fail here and pass with `_lagApplies` → `true`. I added the Q1 consequence: a rights holder calling `earn(1)` during a pending `proposeWorkOracle` makes it unapplyable and closes replacement for good (`test/scratch/OracleReplacementGriefed.t.sol`). The fix must include refusing `earn` at wage 0, not just the lag — the lag alone doesn't close the griefing. |\n| fundOracle ignores plain IMD (4 × low, incl. the runbook-line variant) | **Confirmed, merged into one low** at `Treasury.sol:494`; reproduced with a control showing the share path does pay. |\n| audit_flow: returndata bomb reverts `reserveValueUsd` (low) | **Confirmed.** Proof fails here; passes with a fixed-size assembly staticcall. I corrected one claim in it — a delisting does not re-probe the feed. |\n| Factory-successor NatSpec (3 × info) | **Confirmed, merged.** |\n| Governor mint-at-will trust assumption (info) | **Confirmed** by my own test. Kept as a trust assumption contradicting NatSpec, not a bypass. |\n| \"No rights claimable in two oracles\" NatSpec (info) | **Confirmed** (superseded oracle still reads wage 1e18). |\n| Non-gem share misvalued 1e6 low (info) | **Confirmed** (1.25e12 vs 1.25e18). |\n| EARN_MAT comment 5000/120% (info) | **Confirmed** by arithmetic. |\n\nNothing was dropped; nothing the specialists reported failed to reproduce.\n\n## Answers to the numbered questions\n\n1. **Timelock** — No change applies sooner than 48 h or outside its bounds: one slot, `onlyGovernor` on propose/cancel, `_validate` at both ends, every bound a constant. The one third-party block is the WorkOracle proposal via `earn(1)` at wage 0 (folded into the medium). The no-expiry-after-eta is the already-accepted audit note in `Governed.sol:73-79`.\n2. **Treasury exits** — `withdraw` (operator; refuses gem and listed assets; stablecoin floored at `totalBadDebt`), `withdrawNative` (operator, any amount), `payStream` (≤ `streamPerDay` ≤ 500/UTC day, bad-debt floor), `fundOracle` (≤ `oracleBudget` ≤ 100/UTC day, top-up-only, share path feasible via `maxWithdraw`), `redeemIMD` (vault only, sized by `cash`), `cover` (≤ the drained position's debt). Day boundaries, rate changes and rounding all bound as documented. The only gap is that `fundOracle` never spends plain IMD (low).\n3. **Day one** — Yes: asker seed spent, no liquidation yet, Treasury rich in LP-fee IMD → `fundOracle` returns 0 forever. Revival exists (operator `withdraw(IMD, ORACLE_ASKER)`, anyone donating sIMD or IMD to the asker) but the runbook's \"Treasury takes over\" is false (low).\n4. **Accounting** — No double count or loss found: `sync`/`_withdraw`/`_withdrawUnderlying`/`withdrawNative` all credit arrivals before moving the baseline and clamp afterwards; `cover` syncs around the burn; `NATIVE` cannot be listed or `sync`ed through the ERC-20 path.\n5. **Reserve valuation** — Overflow and saturation guards are correct; a listed source can only tighten, except: a returndata bomb reverts the sum (low), and a non-gem 24-decimal share via `SharePriceFeed` is undervalued 1e6× (info). The governor can inflate `earnLine` at will through any shape-valid feed (trust assumption, info).\n6. **proposeWorkOracle** — The wage/predecessor rule holds; the factory and sentinel can only produce an oracle bound to its caller. But wage 0 does not stop minting (medium), the factory route ","treeHash":null,"usage":{"cachedInputTokens":5804598,"inputTokens":68,"model":"claude-fable-5-1","outputTokens":47537,"runtime":"claude","turns":42,"wallClockMs":666089}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"bc1e515ca5c78ac4","findings":[{"citation":"resolved","description":"Q3 (the day-one oracle path), a gap left by the fix for docs/AUDIT-FINAL-2026-10-07.md finding 3. fundOracle resolves `token = gem()` (sIMD on mainnet), and when the gem is a share it measures what it can send by `maxWithdraw(address(this))` on the share vault alone, then unwraps shares through `_withdrawUnderlying`. Plain IMD held by the Treasury is never read and never sent. The Treasury's documented non-liquidation revenue is exactly plain IMD (and ETH): Treasury.sol lines 33-36 and `handOffLaunchFees` exist because a launch pool's LP fees are paid to the requester in IMD and in ETH, and docs/MAINNET-RUNBOOK.md:336-337 tells the operator that 'Once revenue lands (launch-pool fees in IMD arrive through handOffLaunchFees; liquidation cuts in sIMD) the Treasury takes over and the fallback can be switched off.' With the code as committed only the liquidation cut (sIMD) and cover's dust sweep fund the oracle. In a market with no liquidations the Treasury can hold any amount of IMD from LP fees while fundOracle returns 0 every day; an operator who follows 7.4 and switches the keeper fallback off on the strength of IMD revenue leaves NHI with no buyer, NhiFeed goes stale 24 hours later and ParameterizedVault._pricingStale() refuses draw, free-with-debt, bark, bite, cash and cover's dust path until someone buys an NHI answer with their own IMD (askPaid). The runbook has no step for this state. It is revivable only by a keyed action the runbook does not name: APPROVED_OPERATOR calling Treasury.withdraw(IMD, ORACLE_ASKER, amount), which is allowed because IMD is neither the gem nor a listed reserve asset (the same fact that lets the operator take that revenue outright). Reachable with the constants as committed once ORACLE_ASKER has code. Not a loss of funds; a liveness gap between the runbook's claim and the code. Smallest fix: in fundOracle, when the gem is a share, spend plain IMD first — `uint256 idle = IERC20(imd).balanceOf(address(this)); uint256 fromIdle = min(want, idle); _withdraw(IERC20(imd), ORACLE_ASKER, fromIdle); want -= fromIdle;` then unwrap shares for the remainder — and say in 7.4 which revenue funds the oracle; or correct 7.4 to say only sIMD does and keep the keeper fallback on until liquidation revenue exists.","line":494,"path":"src/Treasury.sol","reproduction":"test/scratch/GovTreasuryReview.t.sol, contract FundOracleIgnoresPlainIMD (PASSES on this code: it demonstrates the state). ParameterizedVault over a 24-decimal MockShareVault (rate 1.25e12) with ORACLE_ASKER etched with code and the default oracleBudget (15 IMD). 100 IMD (the underlying, not shares) is minted to the Treasury; share.balanceOf(treasury) == 0. treasury.fundOracle(): EXPECTED per docs/MAINNET-RUNBOOK.md:336-337 — 15 IMD reaches ORACLE_ASKER (`sent == 15e18`). ACTUAL — returns 0, imd.balanceOf(ORACLE_ASKER) == 0, and the IMD remains in the Treasury; APPROVED_OPERATOR then withdraws the full 100 IMD to an arbitrary address with Treasury.withdraw (not ReserveProtected). Repeating fundOracle on any later day sends 0 as long as the Treasury holds no sIMD.","severity":"low","snippet":"        uint256 available = share ? IShareVault(token).maxWithdraw(address(this)) : IERC20(token).balanceOf(address(this));","title":"fundOracle spends only the gem (sIMD): IMD revenue in the Treasury never reaches the asker, yet runbook 7.4 says the Treasury 'takes over' once launch-pool fees in IMD land and the keeper fallback 'ca"},{"citation":"resolved","description":"Q6, the documentation added by the latest fix commit (b73a05f) for the second-half review's info finding. WorkOracleFactory.create (src/WorkOracleFactory.sol:35-36) does `new SwarmWorkOracle(msg.sender, maxAge_)`: the oracle's `vault` is whoever called the factory. A ParameterizedVault calls it only once, inside its own constructor. Any later call by the governor (or anyone) yields an oracle whose `vault()` is the caller, and Parameters._validate (line 392) refuses it with InvalidWorkOracle because `successor.vault() != address(vault)`. The same sentence appears in docs/MAINNET-RUNBOOK.md:376 ('a fresh SwarmWorkOracle from WorkOracleFactory.create is proposable'). The documented path does exist, but not through the factory: `new SwarmWorkOracle(address(vault), maxAge)` deployed directly is accepted (the constructor allows a codeful vault named by anyone), and it is the only way to obtain a qualifying successor with the shipped code. Not a bypass and not exploitable; a governor following the NatSpec will have the proposal refused and may conclude the oracle cannot be replaced at all. Smallest fix: reword the NatSpec and runbook 7b.3 to 'a fresh SwarmWorkOracle deployed directly against this vault (new SwarmWorkOracle(vault, maxAge))', or give WorkOracleFactory a second entry point `createFor(address vault_, uint256 maxAge_)` that requires `vault_.code.length != 0`.","line":249,"path":"src/Parameters.sol","reproduction":"test/scratch/GovTreasuryReview.t.sol, contract FactorySuccessorRefused (PASSES on this code: it demonstrates the state). WorkBackingFixture (wage 0, nothing minted). APPROVED_OPERATOR calls WorkOracleFactory.create(1 days): the returned oracle's vault() == APPROVED_OPERATOR. APPROVED_OPERATOR calls parameters.proposeWorkOracle(thatOracle). EXPECTED per Parameters.sol:249 and runbook 7b.3: the proposal is queued. ACTUAL: reverts InvalidWorkOracle. `new SwarmWorkOracle(address(backedVault), 1 days)` proposed by the governor is accepted and, 48 hours later, applyPending makes backedVault.oracle() return it.","severity":"info","snippet":"    /// the first mint a fresh `SwarmWorkOracle` (through `WorkOracleFactory.create`) qualifies.","title":"NatSpec (b73a05f) and runbook 7b.3 say a fresh SwarmWorkOracle 'through WorkOracleFactory.create' qualifies as a successor; the factory names its caller as the vault, so anything the governor obtains "},{"citation":"resolved","description":"Q6 and Q5 together, reported as a privileged-power trust assumption with the actor and preconditions stated, not as a bypass: every step is the governor's own, each is visible for 48 hours and both are the intentional design. The NatSpec, however, claims the opposite in two places. Parameters.sol:42-52 says the governed values are ones whose worst case is to 'price the protocol badly', that the price sources that could be 'a theft' are kept out of reach, and that 'the governor cannot widen its own authority'; line 244 says a replacement oracle 'Adds no trust' because the governor 'can already raise the wage'. Raising the wage lets agents mint for attested work up to earnLine; it does not let the governor mint. But `proposeReserveAsset` accepts ANY token with ANY ISwarmFeed that answers in shape (Treasury.validateReserveAsset checks only the stablecoin, code presence, the gem's feed, a well-formed isStale/latestValue and decimals <= 77), and `reserveValueOf` counts it at up to MAX_RESERVE_VALUE ($1e18) per asset; `earnLine` is reserveValueUsd + backedDebt x earnMat / 10000. And `proposeWorkOracle` accepts, while the wage is zero (the launch state), any contract whose vault() is the vault and that answers mintingRights(): the governor's own. The combination is mint-at-will with no collateral, no debt and no work, bounded only by $1e18 per listed asset: imdUSD holders are diluted and backingPerUnit (so the redemption payout) falls. Reachable with the constants as committed (WAGE_WAD = 0); needs the APPROVED_OPERATOR key and 96 hours of public proposals. Smallest fix if the design wants the NatSpec to be true rather than the other way round: restrict a reserve asset's price source to feeds the vault itself exposes (usdPriceFeed, collateralPriceFeed) or to contracts created by a pinned feed factory, and/or restrict a successor oracle to one whose code hash is SwarmWorkOracle's (type(SwarmWorkOracle).creationCode is not available in Parameters, but `successor.codehash` against a pinned runtime hash is). Otherwise correct lines 42-52 and 244 to state the power plainly: the governor can set the reserve term of the work ceiling and the identity of the minter, and that is a trust assumption on APPROVED_OPERATOR.","line":244,"path":"src/Parameters.sol","reproduction":"test/scratch/GovTreasuryReview.t.sol, contract GovernorMintsAtWill (PASSES on this code: it demonstrates the path). WorkBackingFixture (wage 0, empty register, no positions). (1) A ReserveTestToken `junk` with 1e18 units in the Treasury and a TestSwarmFeed answering 1e30 (one trillion dollars per token). APPROVED_OPERATOR: proposeReserveAsset(junk, feed, 10000); +48h applyPending. reserve.reserveValueUsd() == 1e30 and backedVault.earnLine() == 1e30. (2) APPROVED_OPERATOR: proposeWorkOracle(new MockWorkOracle(vault)) — any contract answering vault() and mintingRights() serves; +48h applyPending; grantRights(APPROVED_OPERATOR, 2^128-1). (3) APPROVED_OPERATOR: backedVault.earn(1_000_000_000e18). EXPECTED per Parameters.sol:42-52/244: no governor action can mint or widen authority. ACTUAL: the governor holds 1e9 imdUSD, totalSupply rose by 1e9 imdUSD and totalDebt is 0.","severity":"info","snippet":"    /// @dev Adds no trust: a governor who could mint through a hostile oracle can already raise the wage.","title":"Trust assumption the NatSpec denies: a reserve listing against a feed the governor chooses sets earnLine, and a work oracle the governor chooses sets who may mint against it, so two serial 48-hour pro"},{"citation":"resolved","description":"Q5. Treasury.reserveValueOf normalises every asset except the creating vault's gem by the token's own decimals (setReserveAsset, line 197-199), which is right for a source quoting USD per whole token (the ReserveAsset NatSpec, line 46-47). SharePriceFeed (src/SharePriceFeed.sol:15-26, 56) quotes USD per 1e18 RAW share units, which coincides with per-whole-token only for an 18-decimal share. Its contract NatSpec (lines 12-13) says 'any ERC-4626 over any asset this protocol can already price works, which is also how a diversified reserve gets priced', and the runbook's open-decisions section contemplates a diversified reserve. Listing a 24-decimal share that is not the gem through a SharePriceFeed therefore values it at balance x (USD per 1e18 raw) / 1e24, a factor 1e6 below its worth (the inverse of the earlier mis-fed-gem finding). The direction is safe — the ceiling only tightens — and the gem itself is handled by the decimals-18 pin plus the collateralPriceFeed requirement, so the mainnet configuration (sIMD only) is unaffected. Reported because the register's NatSpec and SharePriceFeed's NatSpec disagree about the convention and a future listing would silently follow the wrong one. Smallest fix: document in SharePriceFeed that its output is per 1e18 raw units and may be listed in the register only for the gem or for an 18-decimal share; or let the register pin decimals = 18 for any asset whose price source is a SharePriceFeed (probe `shareVault()` on the feed and compare to the asset).","line":232,"path":"src/Treasury.sol","reproduction":"test/scratch/GovTreasuryReview.t.sol, contract NonGemShareMisvalued (PASSES on this code: it demonstrates the valuation). WorkBackingFixture. A 24-decimal MockShareVault `other` over an 18-decimal token worth $1 (TestSwarmFeed 1e18), rate 1.25e12, wrapped in `new SharePriceFeed(other, usdPerUnderlying)`: feed.latestValue() == 1.25e12 (USD per 1e18 raw share units). 1.25 tokens are deposited for the Treasury: other.balanceOf(treasury) == 1e24 (one whole share, worth $1.25). APPROVED_OPERATOR lists (other, feed, 10000); +48h applyPending. EXPECTED: reserve.reserveValueOf(other) == 1.25e18. ACTUAL: 1.25e12.","severity":"info","snippet":"        uint256 unit = 10 ** entry.decimals;","title":"A second share token (not the gem) listed through a SharePriceFeed is valued a million times too low: the register divides a non-gem asset by its own decimals while SharePriceFeed quotes per 1e18 raw "},{"citation":"resolved","description":"A comment that claims a property the code does not have (two numbers), not an arithmetic error in how the constant is used. CDPVault._mat (line 1253-1257) returns 170 at or above NHI 0.85 and 200 at or below 0.60, so the loosest mat is 170 and the cliff where backing with an empty reserve touches one is mat - 1 = 7000 bps, exactly what src/Parameters.sol:82-83 says ('7000 is the cliff ... at 2500 it is 136% with an empty reserve': 1.70 / 1.25 = 1.36). src/DeploymentConfig.sol:146-147 says the cliff 'is 5000 at the loosest NHI. 2500 is half that cliff, 120% worst-case backing', which is the arithmetic of docs/COMPUTE-BACKING-DESIGN.md section 3 (lines 102-111) written when mat was 150 at NHI 0.9 (1.50 / 1.25 = 1.20). The bound itself (MAX_EARN_MAT_BPS = 2500) is conservative either way — 2500 is 36% of the real cliff, not half — so nothing in the code is wrong; the two source comments disagree and one is stale. Smallest fix: restate DeploymentConfig.sol:146-147 as 'which is 7000 at the loosest NHI (mat 170). 2500 is about a third of that cliff, 136% worst-case backing with an empty reserve', and add a note to the design doc's table that it predates the 170 floor.","line":147,"path":"src/DeploymentConfig.sol","reproduction":"Read-only arithmetic against the committed code. mat at NHI >= 0.85: CDPVault._mat returns 170 (src/CDPVault.sol:1254). Backing with an empty reserve and ratio r, per docs/COMPUTE-BACKING-DESIGN.md section 3: B = mat / (1 + r). At r = 0.25: 1.70 / 1.25 = 1.36 (136%), not 1.20; B = 1 at r = 0.70 (7000 bps), not 5000. EXPECTED: DeploymentConfig.sol:146-147 and Parameters.sol:82-83 state the same cliff and worst case. ACTUAL: 5000 / 120% against 7000 / 136%.","severity":"info","snippet":"/// cliff, 120% worst-case backing with an empty reserve. Parameters refuses any proposal above","title":"DeploymentConfig's EARN_MAT_BPS comment carries the figures for a mat floor of 150 (cliff 5000, 120% worst-case backing); the code's floor is 170, so the cliff is 7000 and the worst case 136%, as Para"}],"hash":"996c5d758cac9ce0088829f7119b0bf5b810f167bde116a2d279761c5d393fb0","nodeId":"6ac23d06-f01f-4a48-8e93-c7b40c35f872","outcome":"completed","summary":"Review complete. Findings are in `.imd-findings.json` (one low, four info), each verified by a scratch test under `test/scratch/GovTreasuryReview.t.sol` that passes on the committed code as a demonstration of the state. No source, config or doc file was changed. The project's governance and Treasury suites run green (96 tests across 8 suites).\n\n## Result\n\nThe governed path and the Treasury are sound as committed. Nothing applies early, outside its bounds, or by anyone but the operator; every exit is bounded as documented; the accounting double-counts nothing and loses nothing; the register cannot be made to revert or inflate from outside; the factories hand no one a Treasury or oracle a vault trusts. What remains is one liveness gap left by the last fix round and four places where comments promise more than the code does.\n\n| # | Severity | Where | Finding |\n|---|---|---|---|\n| 1 | low | `src/Treasury.sol:494` | fundOracle spends only sIMD. IMD revenue from launch-pool fees never funds the asker, yet runbook 7.4 says the Treasury \"takes over\" when it lands and the keeper fallback can be switched off. |\n| 2 | info | `src/Parameters.sol:249` | The NatSpec added in b73a05f says a successor \"through WorkOracleFactory.create\" qualifies. The factory names its caller, so the governor's result is refused. Only a direct deployment against the vault qualifies. |\n| 3 | info | `src/Parameters.sol:244` | Trust assumption the NatSpec denies: a reserve listing against any feed plus a governor-chosen oracle mints unbacked imdUSD at will after two 48-hour proposals while the wage is zero. |\n| 4 | info | `src/Treasury.sol:232` | A non-gem 24-decimal share listed through a SharePriceFeed is valued a million times too low, against SharePriceFeed's \"diversified reserve\" claim. Safe direction. |\n| 5 | info | `src/DeploymentConfig.sol:147` | The EARN_MAT_BPS comment says cliff 5000 and 120% worst case. With the code's mat floor of 170 those are 7000 and 136%, as Parameters.sol:83 states. |\n\n## Answers to the numbered questions\n\n**1. Timelock.** No change can land before 48 hours: `pendingEta` is set from `block.timestamp + TIMELOCK` and `applyPending` refuses earlier. Every bound is re-checked at application, and no bound reads third-party state except the reserve listing, whose inputs are governor-chosen. Only APPROVED_OPERATOR can propose or cancel, and only the vault's Parameters can reach the register. A proposal can be stalled only by its own inputs failing validation, which the governor can cancel. The late-application window is the previously recorded note in Governed.sol, not new.\n\n**2. Treasury exits.** Seven routes, all bounded: `withdraw` and `withdrawNative` by the operator, never the gem or a listed asset, imdUSD only above `totalBadDebt`; `payStream` to the governed payee at most `streamPerDay` per UTC day, capped at 500 in source, never below the bad-debt floor; `fundOracle` at most `oracleBudget` per UTC day, topped up to one day's worth, capped at 100 in source; `redeemIMD` only from the vault and only up to the gem balance; `cover` only for a drained position's recorded debt; `handOffLaunchFees` by the operator, future fees only. Day boundaries reset the counters by design, a mid-day cap raise can send only the difference, and rounding is in the Treasury's favour throughout.\n\n**3. fundOracle on day one.** The sIMD-empty state is covered by the runbook's keeper fallback and asker seed. The state the runbook does not cover is finding 1: a Treasury holding IMD and ETH revenue but no sIMD sends nothing, and the only revival is an undocumented operator withdrawal to the asker.\n\n**4. Accounting.** Every outflow the Treasury performs credits unsynced arrivals first and moves the baseline before the external call. `cover` syncs before and after the burn, so the fee re-mint is credited. Reentrant syncs during a native, ERC-20 or share-unwrapping withdrawal credit nothing twice. Nothing is lost or double-counted.\n\n**5. Reserve valuation.** Ev","treeHash":null,"usage":{"cachedInputTokens":2898460,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":63393,"runtime":"claude","turns":46,"wallClockMs":944085}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"63458a1bd2b2d376","findings":[{"citation":"resolved","description":"Q1/Q6, and a gap left by the D1 fix (launch audit 2026-10-05, vault panel, medium). The wage is documented as the on/off switch for minting from work (DeploymentConfig.sol:168-172, ParameterizedVault.sol:109 'The lagged WORK CEILING applies exactly while minting from work is on', CDPVault.sol:864-865 'turns it on exactly when minting from work is on (a nonzero wage)', docs/LAUNCH-READINESS.md:84 'only while the wage is zero (so no rights are ever outstanding in the old oracle...)'). The code does not have that property. SwarmWorkOracle prices rights AT CLAIM (creditedRights, line 85-86) and neither SwarmWorkOracle.consumeRights (line 178) nor CDPVault.earn (line 476) reads the wage, so every right claimed during a period with a nonzero wage survives governance setting the wage back to 0 and can be minted with earn at wage 0. (SwarmWorkOracle.claim refuses at wage 0, but that only stops NEW claims.) ParameterizedVault._lagApplies() returns parameters.wage() != 0, so at wage 0 backedDebt() reads min(totalDebt, debtAtTransactionStart) with no warm-up: debt opened in the PREVIOUS block counts in full. That is exactly the adjacent-transaction round trip D1 described ('borrow in one, earn in the next, repay and withdraw in a third, and work-minted imdUSD outlived the debt that authorised it'), which the lag was built to close and which is now closed only while the wage happens to be nonzero. Reachable with the constants as committed, without any code change: WAGE_WAD is 0 at launch, but the state 'rights > 0 and wage == 0' arises from an ordinary governance sequence the runbook itself prescribes (section 7b.3: set the wage to 0 to replace the work oracle) or from an emergency wage-to-0 after a bad tally; each step is a 48-hour proposal. Who loses: every imdUSD holder, since the work-minted supply has nothing behind it after the unwind; the attacker (any agent holding claimed rights, or anyone who buys them: rights are per address but an agent can claim to any controller) nets min(rights, 25% of the capital they can borrow against) in unbacked imdUSD per round trip, with their collateral exposed for one block. Also a liveness consequence for governance: setting the wage to 0 is NOT an emergency stop for minting from work. Smallest fix: make the lag unconditional for ParameterizedVault (`_lagApplies` returns true; it is tracked from deployment so nothing changes at launch where no rights exist and earn is impossible anyway), and, to make the documented switch true, have ParameterizedVault refuse earn while parameters.wage() == 0 (e.g. an `_earnAllowed()` hook CDPVault.earn checks) or have SwarmWorkOracle.consumeRights revert while wage() == 0.","line":112,"path":"src/ParameterizedVault.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 {CDPVault} from \"src/CDPVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {MockWorkOracle} from \"src/MockWorkOracle.sol\";\nimport {Parameters} from \"src/Parameters.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\n/// @dev A never-stale swarm feed with a settable value.\ncontract ProofFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract ProofMirror is ISwarmFeed {\n    ISwarmFeed private immutable primary;\n\n    constructor(ISwarmFeed p) {\n        primary = p;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return primary.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return primary.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return primary.maxAge();\n    }\n}\n\n/// @dev ETH/USD = $1.00 (8 decimals), always dated now: etched code carries no storage, so constants only.\ncontract ProofAggregator {\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, 1e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\n/// @notice Minting from work at wage 0. The wage is documented as the on/off switch for minting from\n/// work, and `ParameterizedVault._lagApplies` turns the D1 lagged work ceiling on only while it is\n/// nonzero. But rights credited while the wage was on survive it being set back to 0 (SwarmWorkOracle\n/// prices rights at claim and `consumeRights`/`earn` never read the wage), so a rights holder can mint\n/// against debt opened one block earlier with the lag off: exactly the D1 round trip the lag closes.\ncontract EarnAtZeroWageSkipsLagTest is Test {\n    address private constant BORROWER = address(0xB0);\n    address private constant WORKER = address(0xC0);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    Parameters private parameters;\n    ImdUSD private stable;\n    MockWorkOracle private workOracle;\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new ProofAggregator()).code);\n        imd = new MockIMD();\n        ProofFeed primary = new ProofFeed(1 ether); // 1 ETH per IMD, ETH = $1: one dollar per IMD\n        ProofFeed nhi = new ProofFeed(0.85 ether); // mat 170\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(nhi), address(new ProofMirror(primary))\n        );\n        parameters = vault.parameters();\n        stable = vault.stablecoin();\n        workOracle = MockWorkOracle(address(vault.oracle()));\n\n        // Governance turns minting from work on (48 h), rights are credited while it is on, then\n        // governance turns it off again (48 h). The rights stay where they are.\n        _setWage(1 ether);\n        vm.prank(APPROVED_OPERATOR);\n        workOracle.grantRights(WORKER, 1_000 ether); // stands in for SwarmWorkOracle.claim at wage 1\n        _setWage(0);\n        assertEq(parameters.wage(), 0, \"minting from work is documented OFF\");\n        assertEq(vault.totalEarned(), 0);\n        assertEq(vault.totalDebt(), 0);\n    }\n\n    function _setWage(uint256 wad) private {\n        vm.prank(APPROVED_OPERATOR);\n        parameters.proposeWage(wad);\n        vm.warp(parameters.pendingEta());\n        parameters.applyPending();\n    }\n\n    function test_earnAtZeroWageAgainstDebtOpenedOneBlockEarlierIsRefused() public {\n        // tx 1: a fresh position opens 200 imdUSD of debt. Nothing else backs the vault.\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 400 ether);\n        vm.startPrank(BORROWER);\n        imd.approve(address(vault), 400 ether);\n        vault.lock(400 ether);\n        vault.draw(200 ether);\n        vm.stopPrank();\n\n        // One block later.\n        vm.roll(block.number + 1);\n        vm.warp(block.timestamp + 12);\n\n        // tx 2: the rights holder mints a quarter of that debt from work.\n        // EXPECTED: refused. Either the wage-0 \"off\" switch refuses minting from work, or the D1 lag\n        // applies and the ceiling is reserve (0) + 25% of the WARMED debt (200e18 * 12 s / 1 day), so\n        // 50e18 is far above it and `earn` reverts WorkCeilingReached.\n        // ACTUAL on the committed code: `_lagApplies()` is false at wage 0, backedDebt() counts the\n        // whole 200e18 opened in the previous block, earnLine() is exactly 50e18 and earn succeeds.\n        vm.prank(WORKER);\n        (bool ok,) = address(vault).call(abi.encodeCall(CDPVault.earn, (50 ether)));\n        assertFalse(ok, \"earn at wage 0 against one-block-old debt must be refused (wage switch or D1 lag)\");\n    }\n}","reproduction":"test/scratch/EarnAtZeroWageSkipsLag.t.sol (attached as proof; FAILS on the committed code). ParameterizedVault over MockIMD, $1 per IMD (primary 1e18, ETH/USD $1), NHI 0.85 (mat 170), empty register, TreasuryFactory etched. Governance: proposeWage(1e18) + 48h apply; operator grants WORKER 1000e18 rights (the created MockWorkOracle, standing in for SwarmWorkOracle.claim at wage 1); proposeWage(0) + 48h apply. parameters.wage() == 0, totalEarned == 0, totalDebt == 0. tx1 (BORROWER): lock(400e18), draw(200e18). Roll one block, warp +12 s. tx2 (WORKER): earn(50e18). EXPECTED: refused. Either the documented wage-0 switch refuses minting from work, or the D1 lag applies and earnLine = 0 + 25% of the warmed debt = 25% of 200e18 x 12/86400 = 6.94e15, so earn(50e18) reverts WorkCeilingReached (verified against a copy of ParameterizedVault with _lagApplies returning true: earnLine 6944444444444444, earn reverts WorkCeilingReached). ACTUAL: earnLine() == 50e18 (backedDebt counts the whole 200e18 opened in the previous block; laggedNow() reports 27777777777777777), earn succeeds, totalEarned == 50e18. Continuation (test/scratch/EarnAtZeroWageUnwind.t.sol, passes as a demonstration): next block WORKER sends the 50e18 to BORROWER, BORROWER wipe(debtOf) and free(400e18). End state: totalDebt 0, imdUSD supply 50e18, backingPerUnit() == 0.","severity":"medium","snippet":"        return parameters.wage() != 0;","title":"A zero wage does not switch minting from work off: rights credited while the wage was on are still consumable through earn, and ParameterizedVault._lagApplies turns the D1 lagged work ceiling off at w"},{"citation":"resolved","description":"Q3 (the day-one dead state). When the collateral is a share (sIMD on mainnet), fundOracle's `available` is IShareVault(token).maxWithdraw(this) only: plain IMD sitting in the Treasury is never counted and never sent, even though `imd` (the asset) is already resolved two lines above and the asker is paid in exactly that token. The Treasury's only sIMD income is the liquidation cut, so until the first liquidation fundOracle returns 0 whatever IMD the Treasury holds. docs/MAINNET-RUNBOOK.md:336-337 claims the opposite: 'Once revenue lands (launch-pool fees in IMD arrive through `handOffLaunchFees`; liquidation cuts in sIMD) the Treasury takes over and the fallback can be switched off.' Launch-pool fees in IMD do not make the Treasury take over anything; the keeper's own IMD (ASK_PAID_IMD_PER_DAY, 2 IMD) stays the only automated buyer until a liquidation happens, and an operator who follows the runbook and switches KEEPER_ORACLE_FALLBACK off when IMD fees land leaves the price feeds unbought. The manual revival (operator `withdraw(IMD, ORACLE_ASKER, x)`) exists only while IMD is unlisted: runbook 7.3 lists the reserve assets before opening deposits and ParameterizedVault.sol:42-43 names usdPriceFeed as 'the price source the Treasury's register is expected to hold for IMD', and once IMD is listed `withdraw` reverts ReserveProtected while fundOracle still sends none of it, so the Treasury's IMD counts in reserveValueUsd but can fund the oracle only after a 48-hour delisting. Not a loss of funds; a liveness gap in the one path the runbook relies on, reachable with the constants as committed the moment ORACLE_ASKER has code. (fundOracle's own NatSpec is consistent with the code; the runbook's claim is the one without the property.) Smallest fix: in fundOracle, when `share`, pay plain IMD first — `uint256 held_ = IERC20(imd).balanceOf(address(this)); uint256 fromImd = min(want, held_); if (fromImd != 0) _withdraw(IERC20(imd), ORACLE_ASKER, fromImd); want -= fromImd;` — then unwrap only the remainder from maxWithdraw; and correct runbook 7.4 to say what revenue can fund the asker.","line":494,"path":"src/Treasury.sol","reproduction":"test/scratch/FundOracleIgnoresPlainImd.t.sol (PASSES: it demonstrates the state). ParameterizedVault over a 24-decimal ERC-4626 share of MockIMD (rate 7.95e12), ORACLE_ASKER etched with code, default oracleBudget 15e18. Operator mints 100e18 IMD to the Treasury (the shape of a launch-pool fee payout); the Treasury holds 0 shares. EXPECTED (runbook 7.4): fundOracle sends up to the day's 15 IMD budget to the asker. ACTUAL: fundOracle() returns 0, the asker holds 0 IMD, oracleSpent stays 0 (maxWithdraw(this) == 0). Then list IMD as a reserve asset (proposeReserveAsset(IMD, feed, 10000), 48 h, apply): treasury.withdraw(IMD, ORACLE_ASKER, 1e18) reverts ReserveProtected(IMD), fundOracle() still returns 0, reserveValueUsd() > 0.","severity":"low","snippet":"        uint256 available = share ? IShareVault(token).maxWithdraw(address(this)) : IERC20(token).balanceOf(address(this));","title":"fundOracle with a share collateral pays only from sIMD (maxWithdraw) and ignores IMD the Treasury holds directly, so the runbook's 'once launch-pool fees in IMD land the Treasury takes over' never hap"},{"citation":"resolved","description":"Q6, documentation claim without the property. WorkOracleFactory.create(maxAge) does `new SwarmWorkOracle(msg.sender, maxAge)`, so the oracle's `vault` is whoever called the factory. An already-deployed vault only calls the factory from its own constructor, so no later call can produce an oracle whose vault() is the vault: when the governor (or anyone) calls create, vault() is the governor, and Parameters._validate (line 392) refuses it with InvalidWorkOracle. docs/MAINNET-RUNBOOK.md:376 repeats the claim ('a fresh `SwarmWorkOracle` from `WorkOracleFactory.create` is proposable'). The working route is `new SwarmWorkOracle(address(vault), WORK_ORACLE_MAX_AGE)` from any account (the vault has code, so the constructor's codeless-vault check passes), which carries the same bytecode-pinned question as the factory's and is accepted. No security consequence: the rule itself (vault() must be this vault, wage must be 0, predecessor once anything was minted) holds and no hostile or wrong-vault oracle can be installed. Smallest fix: change both sentences to name direct construction (`new SwarmWorkOracle(vault, maxAge)`), or give WorkOracleFactory a `createFor(address vault_, uint256 maxAge_)` for vaults that already have code.","line":249,"path":"src/Parameters.sol","reproduction":"test/scratch/FactorySuccessorRefused.t.sol (PASSES: demonstration). ParameterizedVault with wage 0 and totalEarned 0 (the state the NatSpec addresses). `WorkOracleFactory f = new WorkOracleFactory(); vm.prank(APPROVED_OPERATOR); SwarmWorkOracle o = f.create(WORK_ORACLE_MAX_AGE);` gives o.vault() == APPROVED_OPERATOR. EXPECTED per the NatSpec: parameters.proposeWorkOracle(address(o)) succeeds. ACTUAL: reverts Parameters.InvalidWorkOracle. `new SwarmWorkOracle(address(vault), WORK_ORACLE_MAX_AGE)` is then proposed successfully (pendingChange() == (WorkOracle, eta)).","severity":"info","snippet":"    /// the first mint a fresh `SwarmWorkOracle` (through `WorkOracleFactory.create`) qualifies.","title":"NatSpec and runbook say a fresh SwarmWorkOracle 'through WorkOracleFactory.create' qualifies as a work-oracle replacement before the first mint; the factory names its CALLER as the vault, so anything "}],"hash":"f7e3c960cfc8dcb62032eca6b81adebf0d24d8cc42125bfaa0afa47c818ab490","nodeId":"26f73075-0423-47b1-b035-9ee5caf956b8","outcome":"completed","summary":"Review complete. No source, script, config or doc file was changed. The only additions are `.imd-findings.json` and six scratch tests under `test/scratch/`. All 137 tests in the in-scope suites pass on the committed code.\n\n## Result\n\nThree findings: one medium, one low, one info. The medium is a gap left by the D1 fix. The timelock, the Treasury's exits and accounting, the reserve valuation and the factories hold.\n\n| # | Severity | Where | Finding |\n|---|---|---|---|\n| 1 | medium | `src/ParameterizedVault.sol:112` | A zero wage does not switch minting from work off. Rights claimed while the wage was on stay consumable, and the D1 lag is off at wage 0, so borrow / earn / repay across adjacent blocks mints unbacked imdUSD again. Proof test fails on this code and passes on a patched copy. |\n| 2 | low | `src/Treasury.sol:494` | With sIMD collateral, fundOracle pays only from shares and ignores IMD the Treasury holds, so the runbook's \"once launch-pool fees in IMD land the Treasury takes over\" is false. Once IMD is listed as a reserve asset, the operator's manual route is closed too. |\n| 3 | info | `src/Parameters.sol:249` | NatSpec and runbook say a replacement from `WorkOracleFactory.create` qualifies before the first mint. The factory names its caller as the vault, so the result is always refused. Direct construction works. |\n\n## Answers to the numbered questions\n\n**1. Timelock.** Every change goes through one slot, is validated at proposal and again at application, and lands no sooner than 48 hours after `Proposed`. Only the pinned operator proposes or cancels, and application is permissionless. Every bound is a constant in Parameters, so nothing can land outside it. A pending proposal can be blocked only by its own validation failing at application, and the governor can always cancel, so no block is indefinite. One third-party block exists: a rights holder calling `earn` during a work-oracle proposal's 48 hours makes a fresh replacement fail at application. The existing test treats that as intended. The \"apply weeks later at a chosen moment\" gap is the one the code already documents.\n\n**2. Treasury exits.** Value leaves by seven routes: `withdraw` (operator; never the collateral or a listed asset; imdUSD only above totalBadDebt), `withdrawNative` (operator, unbounded, as documented), `payStream` (anyone; at most streamPerDay per UTC day, capped at 500 imdUSD, never below the bad-debt floor), `fundOracle` (anyone; at most oracleBudget per UTC day, capped at 100 IMD, topped up only to one day's budget), `redeemIMD` (vault only, the redemption payout), `cover` (vault burn of Treasury imdUSD against drained positions only), and `handOffLaunchFees` (operator, pinned factory, role only). Day counters reset on the UTC boundary, so two days' worth can leave within a minute across midnight, which the per-day wording allows. A budget lowered below what was already paid stops the stream for the day. Share withdrawal rounds shares up by at most one unit. Reentrancy is closed because counters move before each external call.\n\n**3. fundOracle on day one.** Yes, there is a dead state the runbook does not revive: the Treasury holds IMD from launch-pool fees but no sIMD, and fundOracle sends nothing (finding 2). The keeper's own IMD is the only buyer until a liquidation lands sIMD. The operator can hand IMD to the asker by `withdraw` only while IMD is unlisted. The prior same-block-hold finding still applies once sIMD arrives.\n\n**4. Accounting.** No double count or loss on the ERC-20, native or share-unwrapping paths. Every withdrawal credits unsynced arrivals first, moves the baseline before the transfer, and re-derives it after. A reentered `sync` during a withdrawal sees the lowered baseline. Tokens arriving during a transfer are credited by the next sync. One note, not a finding: `cover` burns Treasury imdUSD and remints the fee part to the Treasury, which the second sync records as a new receipt, so `totalReceived[imdUSD]` grows by fees th","treeHash":null,"usage":{"cachedInputTokens":3947672,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":66003,"runtime":"claude","turns":49,"wallClockMs":1028975}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b0ab61c6f4146d8d","findings":[],"hash":"fcc3053a70fed2269cfc12a28f3af51fdac5f7749d9d6e364f9f30d2c2a1d941","nodeId":"e91dbc27-afac-4467-8203-c12840f14c90","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":3,"wallClockMs":433380}}],"verification":[]}