# Audit report

> 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.
>
> imdUSD is a dollar-denominated CDP stablecoin borrowed against sIMD (IdentityMD's staked IMD, an ERC-4626 share with 24 decimals, about 7.95 IMD each). Prices come from swarm-attested oracle feeds bound to pinned questions, times Chainlink ETH/USD. Everything about the deployment is in src/DeploymentConfig.sol and docs/MAINNET-RUNBOOK.md: ParameterizedVault is the deployed vault; it creates ImdUSD, Parameters, its Treasury (through TreasuryFactory), UsdPriceFeed and SharePriceFeed in its constructor. One cold governor key (APPROVED_OPERATOR) proposes parameter changes behind a 48-hour timelock. Collateral pricing is per 1e18 raw units throughout. IMD's only market is a full-range Uniswap v4 pool, about $2.3M a side with a 1% fee; docs/PARAMETERS-2026-10-05.md has the numbers every economic parameter was chosen from.
>
> Answer each numbered question, including the ones where nothing is wrong:
> 1. 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?
> 2. 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.
> 3. 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?
> 4. 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?
> 5. 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?
> 6. 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?
> 7. 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?
>
> Not findings: addresses in DeploymentConfig that are placeholders until deployment (INTAKE, ORACLE_ASKER, TREASURY_FACTORY, WORK_ORACLE_FACTORY); the mocks (MockIMD, MockWorkOracle, LaunchToken); script/checks/ (a separate, partly stale tree); web/ and points/; anything docs/COMPUTE-BACKING-DESIGN.md describes as future work; and findings of the earlier audits in docs/AUDIT-*.md and docs/INTERNAL-AUDIT-2026-10-04.md, unless the fix regressed. A constant set to a deliberate economic value is not a finding; an arithmetic or ordering error in how it is used is.
>
> For every finding: severity; file and function; the call sequence from an external caller; a concrete failing input or state with expected against actual; whether it is reachable with the constants as committed; and the smallest fix. Also report every place a comment or NatSpec claims a property the code does not have, and say which contracts you read in full and which you could not reach.

| | |
|---|---|
| Repository | https://github.com/fa11up/infer-protocol |
| Commit | `b73a05f0f9185bae139f46c56f044ed9c7391c4c` |
| Job | `e4a6f30e-93df-464e-ba53-e341e01d1ee8` |
| Judged | 2026-10-07 18:14 UTC |
| Findings | 1 medium · 2 low · 5 info |

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

## Findings

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

`src/ParameterizedVault.sol:112`

```
        return parameters.wage() != 0;
```

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

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

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

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

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ParameterizedVault} from "src/ParameterizedVault.sol";
import {CDPVault} from "src/CDPVault.sol";
import {Parameters} from "src/Parameters.sol";
import {TreasuryFactory} from "src/TreasuryFactory.sol";
import {SwarmWorkOracle} from "src/SwarmWorkOracle.sol";
import {WorkOracleFactory} from "src/WorkOracleFactory.sol";
import {ISwarmFeed} from "src/interfaces/ISwarmFeed.sol";
import {
    APPROVED_OPERATOR, TREASURY_FACTORY, WORK_ORACLE_FACTORY,
    WORK_ORACLE_SENTINEL, CHAINLINK_ETH_USD, ERC8004_ADAPTER
} from "src/DeploymentConfig.sol";

contract AuditIMD is ERC20 {
    constructor() ERC20("IMD", "IMD") {}
    function mint(address to, uint256 amount) external { _mint(to, amount); }
}

contract AuditShare is ERC20 {
    IERC20 public immutable asset;
    constructor(IERC20 token) ERC20("sIMD", "sIMD") { asset = token; }
    function decimals() public pure override returns (uint8) { return 24; }
    function convertToAssets(uint256 shares) public pure returns (uint256) {
        return shares * 7.95e12 / 1e18;
    }
    function deposit(uint256 amount, address to) external returns (uint256 shares) {
        asset.transferFrom(msg.sender, address(this), amount);
        shares = amount * 1e18 / 7.95e12;
        _mint(to, shares);
    }
}

contract AuditFreshFeed is ISwarmFeed {
    uint256 private immutable value;
    constructor(uint256 v) { value = v; }
    function latestValue() external view returns (uint256, uint64) {
        return (value, uint64(block.timestamp));
    }
    function maxAge() external pure returns (uint256) { return 1 days; }
    function isStale() external pure returns (bool) { return false; }
}

contract AuditUsd {
    function decimals() external pure returns (uint8) { return 8; }
    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {
        return (1, 2000e8, block.timestamp, block.timestamp, 1);
    }
}

// Only the attestation fixture is substituted: claim/rights/consume/wage remain production code.
contract AuditSeededWork is SwarmWorkOracle {
    constructor(address v, uint256 age) SwarmWorkOracle(v, age) {}
    function seed(bytes32 root) external { _accept(uint256(root), uint64(block.timestamp)); }
}

contract AuditWorkFactory {
    function create(uint256 age) external returns (SwarmWorkOracle) {
        return new AuditSeededWork(msg.sender, age);
    }
}

contract GovernanceResidualRightsTest is Test {
    ParameterizedVault private vault;
    Parameters private params;
    AuditSeededWork private work;
    AuditIMD private imd;
    AuditShare private share;
    address private constant WORKER = address(0xCA);

    function setUp() public {
        vm.warp(1_000_000);
        vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(WORK_ORACLE_FACTORY, address(new AuditWorkFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new AuditUsd()).code);
        imd = new AuditIMD();
        share = new AuditShare(IERC20(address(imd)));
        vault = new ParameterizedVault(
            address(share), address(0), WORK_ORACLE_SENTINEL,
            address(new AuditFreshFeed(5e14)), address(new AuditFreshFeed(0.85e18)),
            address(new AuditFreshFeed(5e14))
        );
        params = vault.parameters();
        work = AuditSeededWork(address(vault.oracle()));
        _wage(1e18);
        bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(uint256(7), uint32(500), uint64(500)))));
        work.seed(root);
        work.recordRoot();
        vm.mockCall(ERC8004_ADAPTER, abi.encodeWithSignature("isController(uint256,address)", 7, WORKER), abi.encode(true));
        vm.prank(WORKER);
        work.claim(7, 500, 500, new bytes32[](0), root);
        assertEq(work.mintingRights(WORKER), 500e18);
        _wage(0);
    }

    function _wage(uint256 rate) private {
        vm.prank(APPROVED_OPERATOR);
        params.proposeWage(rate);
        vm.warp(params.pendingEta());
        params.applyPending();
    }

    function test_zeroWageMustNotDisableBackingLagForOutstandingRights() public {
        assertEq(params.wage(), 0);
        assertEq(vault.totalEarned(), 0);
        imd.mint(WORKER, 2000e18);
        vm.startPrank(WORKER);
        imd.approve(address(vault), 2000e18);
        vault.lockIMD(2000e18);
        vault.draw(1000e18);
        vm.stopPrank();
        (uint256 lagDebt,) = vault.laggedNow();
        assertEq(lagDebt, 0, "fresh debt has not warmed up");

        // isolate=true gives each top-level call its own transaction, in the SAME block.
        // Allow either a zero-wage mint guard or an unconditional backing lag as the fix.
        vm.prank(WORKER);
        (bool minted,) = address(vault).call(abi.encodeCall(CDPVault.earn, (250e18)));
        minted;
        vm.startPrank(WORKER);
        vault.wipe(1000e18);
        (uint256 locked,) = vault.positions(WORKER);
        vault.free(locked);
        vm.stopPrank();
        assertEq(vault.totalDebt(), 0);
        assertEq(share.balanceOf(address(vault)), 0);
        assertEq(share.balanceOf(address(vault.treasury())), 0);
        assertEq(vault.stablecoin().totalSupply(), 0, "fresh temporary debt must not leave unbacked work supply");
    }

}
```

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

`src/Treasury.sol:494`

```
        uint256 available = share ? IShareVault(token).maxWithdraw(address(this)) : IERC20(token).balanceOf(address(this));
```

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

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

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

`src/Treasury.sol:261`

```
        (bool success, bytes memory data) = address(feed).staticcall(call);
```

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.

**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).

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

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Treasury} from "src/Treasury.sol";
import {ISwarmFeed} from "src/interfaces/ISwarmFeed.sol";

contract ReturnDataReserve is ERC20 {
    constructor() ERC20("Reserve", "RSV") { _mint(msg.sender, 100e18); }
}

contract ReturnDataFeed {
    bool public broken;
    function fail() external { broken = true; }
    function isStale() external view returns (bool) {
        if (broken) {
            assembly {
                mstore(0, 0)
                return(0, 0x200000)
            }
        }
        return false;
    }
    function latestValue() external view returns (uint256, uint64) {
        return (1e18, uint64(block.timestamp));
    }
}

contract ReserveReturnDataTest is Test {
    function parameters() external view returns (address) { return address(this); }
    function gem() external pure returns (address) { return address(0x1234); }
    function stablecoin() external pure returns (address) { return address(0x5678); }

    function test_brokenFeedMustNotRevertTheEntireReserveRead() public {
        Treasury treasury = new Treasury(address(this));
        ReturnDataReserve token = new ReturnDataReserve();
        ReturnDataFeed feed = new ReturnDataFeed();
        token.transfer(address(treasury), 100e18);
        treasury.setReserveAsset(IERC20(address(token)), ISwarmFeed(address(feed)), 10_000);
        assertEq(treasury.reserveValueUsd(), 100e18);
        feed.fail();
        (bool ok,) = address(treasury).staticcall{gas: 16_000_000}(abi.encodeCall(Treasury.reserveValueUsd, ()));
        assertTrue(ok, "a broken feed must count for zero instead of exhausting the caller's memory-copy gas");
    }
}
```

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

`src/Parameters.sol:249`

```
    /// the first mint a fresh `SwarmWorkOracle` (through `WorkOracleFactory.create`) qualifies.
```

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.

**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).

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

`src/Parameters.sol:244`

```
    /// @dev Adds no trust: a governor who could mint through a hostile oracle can already raise the wage.
```

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.

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

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

`src/Parameters.sol:240`

```
    /// from work is ON — at proposal and again at application — so no rights are ever claimable in two
```

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.

**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).

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

`src/Treasury.sol:232`

```
        uint256 unit = 10 ** entry.decimals;
```

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.

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

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

`src/DeploymentConfig.sol:147`

```
/// cliff, 120% worst-case backing with an empty reserve. Parameters refuses any proposal above
```

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.

**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%.

---

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