# Audit report

> Audit governance and the Treasury: src/Parameters.sol, src/Governed.sol, src/Treasury.sol, src/TreasuryFactory.sol, src/WorkOracleFactory.sol and src/SwarmWorkOracle.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. Four audit rounds and their fixes are already in (docs/AUDIT-*.md; the newest are docs/AUDIT-FINAL-PANEL-VAULT/GOVERNANCE/ORACLE-2026-10-07.md, fixed in 8756817: git show 8756817). This is the last sweep before the deployment commit is frozen, so it audits the code as it will deploy; a finding of an earlier round counts only if its fix regressed or left a gap. Spend turns on breaking the newest fixes first.
>
> 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 and bounds: any change applied sooner than 48 hours, outside its bounds, or by anyone other than the documented route; any proposal blocked indefinitely or applied at a chosen moment to harm borrowers.
> 2. Treasury exits, enumerated and bounded: withdraw, withdrawNative, payStream, fundOracle (now plain IMD first unless IMD is a listed reserve asset, then sIMD unwrapped; topped up to the daily budget), redeemIMD, cover. Day boundaries, rounding, rate changes, and the accounting (sync, lastSynced, totalReceived) across the plain-IMD path.
> 3. Reserve valuation with the bounded reads (_boundedCall copies at most two words): can a listed feed or token still make reserveValueUsd, earnLine, backingPerUnit or cash revert or misvalue (gas, malformed words, a token that reverts on balanceOf, decimals)? Is the memory use of the assembly sound?
> 4. The work oracle: proposeWorkOracle at wage 0, successors built directly (not through WorkOracleFactory.create), predecessor() after a first mint; SwarmWorkOracle.claim now refusing once superseded (probing vault.oracle()). Can rights be claimed, consumed or stranded wrongly across a replacement and a wage cycle, and does the probe behave for a vault with no oracle()?
> 5. The governor's minting power, stated in Parameters as a trust assumption (reserve listing against any shape-valid feed, plus a replacement oracle and a wage): is the statement complete and are the bounds (48 hours each, MAX_RESERVE_VALUE per asset, earn closed at wage 0) as described?
> 6. Day one: the asker seeded with IMD at deploy, the keeper's fallback, fundOracle's sources. Any state in which the oracle path is dead and nothing in docs/MAINNET-RUNBOOK.md section 7 revives it?
>
> 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 | `8756817e66e973b05ea08e2aa99ee627de225d09` |
| Job | `f6219aa5-8cf9-409f-b099-8244a20639a4` |
| Judged | 2026-10-07 21:11 UTC |
| Findings | 2 low · 2 info |

Four agents audited the code as it is at `8756817`, 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. Low: Treasury._boundedCall bounds the returndata copy but forwards gas(), so one listed source that burns gas puts a multi-million-gas floor under every cash, earn and backingPerUnit, and three such listin

`src/Treasury.sol:277`

```
            success := staticcall(gas(), target, add(data, 32), mload(data), out, 64)
```

Q3 (gas), a gap left by the 8756817 fix for the final governance panel's low #3. The fix copies at most two words of returndata, which closes the returndata bomb, but the staticcall still passes gas(), so a listed price source or token whose isStale()/latestValue()/balanceOf() spends everything it is given (an upgradeable or externally controlled feed that answered well at listing and turns later; validateReserveAsset probes shape only, and a probe that burned gas at proposal would be refused there) keeps 63/64 of whatever the Treasury has at each read (EIP-150). Measured on the committed code: with ONE such feed listed, a redemption that cost 299,803 gas before the feed turned reverts out of gas at a 3,000,000 limit and succeeds only at 30,000,000 (29,270,102 used); treasury.reserveValueUsd{gas: 5M}() returns but consumes 4,927,677 gas. With THREE such listings (1/64 of 1/64 of 1/64 of 30M is about 114 gas) reserveValueUsd{gas: 30M}() itself reverts, and with it earnLine, earn, reserveValue, _redemptionReserveBacking, backingPerUnit and every cash, until three sequential 48-hour delistings mature (one slot: 144 hours). Call sequence: any caller -> ParameterizedVault.cash / earn / backingPerUnit -> _redemptionReserveBacking or earnLine -> reserveValue -> Treasury.reserveValueUsd -> reserveValueOf -> _reservePrice -> _readBool -> _boundedCall. Reachable with the constants as committed only after the governor has listed a source that misbehaves after listing (48 hours visible); the planned mainnet register (sIMD through the vault's own immutable SharePriceFeed) cannot do it. Impact is liveness and cost, not loss: a dead source still counts for zero, so nothing is mispriced, but the peg's redemption channel and the work ceiling are priced out or shut for two days per delisting. Delisting itself still works (validateReserveAsset returns early for a zero source). NatSpec claiming a property the code does not have: Treasury.sol:206-208 ('it never makes this view revert'), 219-221 and 267-272 (the fixed-size copy presented as ending the out-of-gas). Smallest fix: forward a fixed stipend instead of gas() in _boundedCall, e.g. `staticcall(200000, target, add(data, 32), mload(data), out, 64)` (SharePriceFeed over UsdPriceFeed plus the Chainlink read needs well under 100k), so a source that cannot answer inside the budget counts for nothing exactly as the NatSpec promises; optionally require gasleft() > 200000 * 64 / 63 before the call so a caller cannot starve an honest read on purpose (a starved read only lowers the caller's own payout or refuses their earn). Verified: with that one-word change all three attached specialist proofs pass and test/ReserveValuation, Treasury, TreasuryGuards, OracleBudget, WorkCeiling and Redemption stay green (122/122). Merged from audit_permissions, audit_economics, audit_flow and audit_math (one defect, four reproductions, all confirmed).

**Reproduction**

test/scratch/Proof_50bf84642e46.t.sol (FAILS on this code: 'cash must not run out of gas because a listed feed burns the gas it is forwarded'; PASSES with staticcall(200000, ...)). ParameterizedVault over a 24-decimal share of IMD (rate 7.95e12), IMD $1 (primary 5e14 x ETH/USD 2000 etched at CHAINLINK_ETH_USD), NHI 0.85, TreasuryFactory etched at TREASURY_FACTORY. Governor proposeReserveAsset(junk ERC-20, GasBurnFeed answering (1e18, now) and isStale false, 10000); +48h applyPending; junk.mint(treasury, 100e18): reserveValueUsd() == 100e18. Borrower lockIMD(4000e18), draw(1000e18), sends 100e18 imdUSD to a redeemer; 100 IMD deposited as shares to the Treasury; +1 day. feed.arm() (every read now loops until out of gas). EXPECTED (Treasury NatSpec 206-208, 267-272): the junk counts for nothing and cash(10e18, 0, 0) costs about 300,000 gas, so a 3,000,000 limit is ample. ACTUAL: address(vault).call{gas: 3_000_000}(cash(10e18, 0, 0)) returns ok == false (out of gas inside the forwarded staticcall, then the 1/64 remainder cannot finish the redemption); at 30,000,000 it succeeds using 29,270,102 gas. Second shape, test/scratch/Proof_f2a455eb1009.t.sol (both tests FAIL here, PASS with the stipend): one listing against a feed whose isStale() executes INVALID when armed: reserveValueUsd{gas: 5_000_000}() succeeds but uses 4,927,677 gas; three such listings: reserveValueUsd{gas: 30_000_000}() returns ok == false.

**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 {Parameters} from "src/Parameters.sol";
import {Treasury} from "src/Treasury.sol";
import {TreasuryFactory} from "src/TreasuryFactory.sol";
import {ImdUSD} from "src/ImdUSD.sol";
import {MockIMD} from "src/MockIMD.sol";
import {ISwarmFeed} from "src/interfaces/ISwarmFeed.sol";
import {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from "src/DeploymentConfig.sol";

contract GasFeed is ISwarmFeed {
    uint256 public constant maxAge = 1 days;
    uint256 private value;
    uint64 private updatedAt;

    constructor(uint256 v) {
        set(v);
    }

    function set(uint256 v) public {
        value = v;
        updatedAt = uint64(block.timestamp);
    }

    function latestValue() external view returns (uint256, uint64) {
        return (value, updatedAt);
    }

    function isStale() external pure returns (bool) {
        return false;
    }
}

contract GasMirror is ISwarmFeed {
    ISwarmFeed private immutable p;

    constructor(ISwarmFeed p_) {
        p = p_;
    }

    function latestValue() external view returns (uint256, uint64) {
        return p.latestValue();
    }

    function isStale() external view returns (bool) {
        return p.isStale();
    }

    function maxAge() external view returns (uint256) {
        return p.maxAge();
    }
}

contract GasAggregator {
    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);
    }
}

/// @dev sIMD shape: 24 decimals over an 18-decimal asset; rate = asset raw per 1e18 share raw.
contract GasShare is ERC20 {
    IERC20 public immutable underlying;
    uint256 public constant rate = 7.95e12;

    constructor(IERC20 a) ERC20("sIMD", "sIMD") {
        underlying = a;
    }

    function decimals() public pure override returns (uint8) {
        return 24;
    }

    function asset() external view returns (address) {
        return address(underlying);
    }

    function convertToAssets(uint256 shares) external pure returns (uint256) {
        return shares * rate / 1e18;
    }

    function maxWithdraw(address owner) external view returns (uint256) {
        return balanceOf(owner) * rate / 1e18;
    }

    function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares) {
        require(msg.sender == owner, "owner only");
        shares = (assets * 1e18 + rate - 1) / rate;
        _burn(owner, shares);
        underlying.transfer(receiver, assets);
    }

    function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
        underlying.transferFrom(msg.sender, address(this), assets);
        shares = assets * 1e18 / rate;
        _mint(receiver, shares);
    }
}

contract GasToken is ERC20 {
    constructor() ERC20("R", "R") {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

/// @dev A well-formed price source at listing time; once armed, every read spends all the gas it is given.
contract GasBurnFeed {
    uint256 public constant maxAge = 1 days;
    uint256 private immutable price;
    bool public armed;

    constructor(uint256 p) {
        price = p;
    }

    function arm() external {
        armed = true;
    }

    function isStale() external view returns (bool) {
        if (armed) _burn();
        return false;
    }

    function latestValue() external view returns (uint256, uint64) {
        if (armed) _burn();
        return (price, uint64(block.timestamp));
    }

    function _burn() private pure {
        uint256 x;
        while (true) {
            x = x + 1;
        }
    }
}

/// @notice Treasury._boundedCall forwards gas() to a listed price source. A source that answers well at
/// listing and later burns the gas it is given makes every cash (and earn) that reads the reserve cost
/// ~63/64 of the transaction's gas: at an ordinary 3,000,000 gas limit the redemption reverts out of gas,
/// where the same redemption cost ~300,000 gas before the feed turned, for the 48 hours a delisting takes.
contract Proof_BoundedCallGas is Test {
    uint256 private constant IMD_ETH = 0.0005 ether; // $1 at ETH 2000
    address private constant BORROWER = address(0xBA);
    address private constant REDEEMER = address(0xCA);

    MockIMD private imd;
    GasShare private share;
    GasFeed private primary;
    GasFeed private nhi;
    ParameterizedVault private vault;
    Parameters private params;
    Treasury private treasury;
    ImdUSD private stable;

    function setUp() public {
        vm.warp(1_700_000_000);
        vm.roll(20_000_000);
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new GasAggregator()).code);
        imd = new MockIMD();
        share = new GasShare(imd);
        primary = new GasFeed(IMD_ETH);
        nhi = new GasFeed(0.85 ether);
        vault = new ParameterizedVault(
            address(share), address(0), address(0), address(primary), address(nhi), address(new GasMirror(primary))
        );
        params = vault.parameters();
        treasury = vault.treasury();
        stable = vault.stablecoin();
    }

    function _apply() private {
        vm.warp(params.pendingEta());
        primary.set(IMD_ETH);
        nhi.set(0.85 ether);
        vm.prank(address(0xA990));
        params.applyPending();
    }

    function test_aListedFeedThatBurnsGasDoesNotMakeCashUnaffordable() public {
        // A listing the register accepts: well-formed answers, a token with decimals.
        GasToken junk = new GasToken();
        GasBurnFeed feed = new GasBurnFeed(1 ether);
        vm.prank(APPROVED_OPERATOR);
        params.proposeReserveAsset(IERC20(address(junk)), ISwarmFeed(address(feed)), 10_000);
        _apply();
        junk.mint(address(treasury), 100 ether);
        assertEq(treasury.reserveValueUsd(), 100 ether, "listed and counted while healthy");

        // A borrower, some supply in a redeemer's hands, a reserve of sIMD, and a day for the lag.
        vm.prank(APPROVED_OPERATOR);
        imd.mint(BORROWER, 4000 ether);
        vm.startPrank(BORROWER);
        imd.approve(address(vault), 4000 ether);
        vault.lockIMD(4000 ether);
        vault.draw(1000 ether);
        stable.transfer(REDEEMER, 100 ether);
        vm.stopPrank();
        vm.prank(APPROVED_OPERATOR);
        imd.mint(address(this), 100 ether);
        imd.approve(address(share), 100 ether);
        share.deposit(100 ether, address(treasury));
        vm.warp(block.timestamp + 1 days);
        vm.roll(block.number + 1);
        primary.set(IMD_ETH);
        nhi.set(0.85 ether);

        // The feed turns after listing (an upgradeable or externally controlled source).
        feed.arm();

        // EXPECTED (Treasury NatSpec: a dead source "counts for nothing" and "never makes this view revert"):
        // the redemption still costs what it did, about 300,000 gas, so a 3,000,000 limit is ample.
        // ACTUAL on this code: the staticcall forwards gas(), the feed burns 63/64 of it, and cash reverts.
        vm.prank(REDEEMER);
        (bool ok,) = address(vault).call{gas: 3_000_000}(abi.encodeCall(vault.cash, (10 ether, 0, address(0))));
        assertTrue(ok, "cash must not run out of gas because a listed feed burns the gas it is forwarded");
    }
}
```

### 2. Low: Parameters.proposeWorkOracle keys 'nothing to carry over' on totalEarned alone, so a fresh SwarmWorkOracle without predecessor() is accepted while the current oracle holds rights credited at claim but

`src/Parameters.sol:400`

```
            bool minted = vault.totalEarned() != 0;
```

Q4 (rights stranded wrongly across a replacement and a wage cycle). SwarmWorkOracle prices and records rights AT CLAIM (creditedTasks, creditedRights, lines 168-172); a claim needs only a nonzero wage, an accepted root and the controller. _validate's only measure of state to carry over is vault.totalEarned(), which moves only when earn consumes rights, and earn is often refused between a claim and a mint (WorkCeilingReached with an empty register and little warmed debt, or StaleFeed), so 'claimed, not yet minted' is an ordinary state. The documented replacement route then REQUIRES governance to zero the wage (WorkMintingOn), and at wage 0 nothing can be minted (the 8756817 _earnOpen gate), so the holder has no exit during the 96 hours of public proposals. Once the successor is applied: vault.oracle() is the successor, earn reverts InsufficientRights (the successor holds nothing), the old oracle's claim reverts NotTheVaultsOracle (the 8756817 info fix) while its mintingRights still reports the credit, and only the vault may consumeRights, so the rights priced under the old wage are unconsumable forever. In the successor creditedTasks starts at zero, so the SAME cumulative tally is claimable again, at whatever wage governance sets next, by whoever controls the agent NOW, and only against a root the successor has accepted, which needs a new attestation bought for its address; a daily receipt lists only agents that worked that day (SwarmWorkOracle.sol:71-75), so an agent who has stopped working never recovers. The agent loses the price their work was credited at (1.0 to 0.1 imdUSD per task in the specialist's run), and if the identity NFT changed hands the credit for work done under the previous controller moves to the buyer, contradicting claim's own split rule (lines 146-149). No double CONSUMPTION is possible (the vault reads one oracle; a return to the old one is itself a WorkOracle proposal refused once anything was minted), so this is a governance-visible loss, not a bypass. Reachable with the constants as committed after one ordinary wage cycle; never on day one (WAGE_WAD = 0, no claims). NatSpec claiming a property the code does not have: Parameters.sol:252-255 ('so it can start from the tallies already credited... before that, there is nothing to carry over'), ParameterizedVault.sol:122-123 ('Rights claimed under a wage are kept, and spendable again the moment a wage is set'), docs/MAINNET-RUNBOOK.md 7b.3 ('before the first mint, a fresh SwarmWorkOracle built directly for the vault... is proposable'). Smallest fix: treat outstanding credited rights like a mint. Add `uint256 public totalCredited` to SwarmWorkOracle (+= rights in claim, -= amount in consumeRights) and in _validate probe vault.oracle() with a raw staticcall for totalCredited(); if it answers a nonzero word, require successor.predecessor() == vault.oracle() exactly as for `minted` (MockWorkOracle, which does not answer, is unaffected). Trade-off to state: this closes replacement at the first CLAIM rather than the first mint, which is the documented 'cannot be replaced until a predecessor-carrying type exists' position moved one step earlier, and it is within the governor's control (no claims exist until the governor sets a wage). If the requester prefers to keep the replacement open, the NatSpec at Parameters.sol:252-255 and ParameterizedVault.sol:122-123 and runbook 7b.3 must instead say that claims made under an earlier wage are stranded by a replacement and must be re-claimed, at the new wage and by the current controller, against a root the successor accepts. Merged from audit_permissions and audit_flow (both reproduced; the flow specialist's re-credit-to-buyer consequence is folded in).

**Reproduction**

test/scratch/Proof_3bef3812020d.t.sol (FAILS on this code with 'next call did not revert as expected'; PASSES with the totalCredited gate). ParameterizedVault built with WORK_ORACLE_SENTINEL (WORK_ORACLE_FACTORY etched to a factory building a SwarmWorkOracle subclass whose only addition is a seeding door for the root; claim/rights/consume/wage are production code), 24-decimal share collateral, IMD $1, NHI 0.85. Governor proposeWage(1e18), +48h applyPending. Two-leaf root over (51450, 500, 500) seeded and recordRoot(); ERC8004_ADAPTER mocked to confirm CONTROLLER; CONTROLLER claim(...) returns 500e18; work.mintingRights(CONTROLLER) == 500e18; vault.totalEarned() == 0. Governor proposeWage(0), +48h apply; B = new SwarmWorkOracle(address(vault), 1 days); governor proposeWorkOracle(B). EXPECTED per Parameters.sol:254-255: refused, as it is after a first mint, because the current oracle carries credited rights the successor cannot carry. ACTUAL: accepted. Full chain in test/scratch/JudgeStranded.t.sol (PASSES as a demonstration): with 10,000e18 of warmed debt, after +48h apply (vault.oracle() == B) and proposeWage(1e18) +48h: earnLine() > 1e18, work.mintingRights(CONTROLLER) == 500e18, B.mintingRights(CONTROLLER) == 0, vault.earn(1e18) from CONTROLLER reverts InsufficientRights, work.claim(...) reverts NotTheVaultsOracle, and B.claim(...) by the agent's new controller reverts UnknownRoot until an attestation is bought for B.

**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 {Parameters} from "src/Parameters.sol";
import {TreasuryFactory} from "src/TreasuryFactory.sol";
import {MockIMD} from "src/MockIMD.sol";
import {SwarmWorkOracle} from "src/SwarmWorkOracle.sol";
import {ISwarmFeed} from "src/interfaces/ISwarmFeed.sol";
import {
    APPROVED_OPERATOR,
    CHAINLINK_ETH_USD,
    TREASURY_FACTORY,
    WORK_ORACLE_FACTORY,
    WORK_ORACLE_SENTINEL,
    ERC8004_ADAPTER
} from "src/DeploymentConfig.sol";

contract SrFeed is ISwarmFeed {
    uint256 public constant maxAge = 1 days;
    uint256 private value;
    uint64 private updatedAt;

    constructor(uint256 v) {
        set(v);
    }

    function set(uint256 v) public {
        value = v;
        updatedAt = uint64(block.timestamp);
    }

    function latestValue() external view returns (uint256, uint64) {
        return (value, updatedAt);
    }

    function isStale() external pure returns (bool) {
        return false;
    }
}

contract SrMirror is ISwarmFeed {
    ISwarmFeed private immutable p;

    constructor(ISwarmFeed p_) {
        p = p_;
    }

    function latestValue() external view returns (uint256, uint64) {
        return p.latestValue();
    }

    function isStale() external view returns (bool) {
        return p.isStale();
    }

    function maxAge() external view returns (uint256) {
        return p.maxAge();
    }
}

contract SrAggregator {
    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);
    }
}

contract SrShare is ERC20 {
    IERC20 public immutable underlying;
    uint256 public constant rate = 7.95e12;

    constructor(IERC20 a) ERC20("sIMD", "sIMD") {
        underlying = a;
    }

    function decimals() public pure override returns (uint8) {
        return 24;
    }

    function asset() external view returns (address) {
        return address(underlying);
    }

    function convertToAssets(uint256 shares) external pure returns (uint256) {
        return shares * rate / 1e18;
    }

    function maxWithdraw(address owner) external view returns (uint256) {
        return balanceOf(owner) * rate / 1e18;
    }

    function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares) {
        require(msg.sender == owner, "owner only");
        shares = (assets * 1e18 + rate - 1) / rate;
        _burn(owner, shares);
        underlying.transfer(receiver, assets);
    }

    function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
        underlying.transferFrom(msg.sender, address(this), assets);
        shares = assets * 1e18 / rate;
        _mint(receiver, shares);
    }
}

/// @dev The production work oracle with a test-only seeding door (the attester's key is not ours).
contract SrSeedableWork is SwarmWorkOracle {
    constructor(address vault_, uint256 maxAge_) SwarmWorkOracle(vault_, maxAge_) {}

    function seed(uint256 v) external {
        _accept(v, uint64(block.timestamp));
    }
}

contract SrSeedableWorkFactory {
    function create(uint256 maxAge_) external returns (SrSeedableWork o) {
        o = new SrSeedableWork(msg.sender, maxAge_);
    }
}

/// @notice Parameters.proposeWorkOracle keys "there is nothing to carry over" on vault.totalEarned() alone.
/// Rights are credited at claim, so an oracle can hold credited, unconsumed rights with nothing minted; a
/// fresh SwarmWorkOracle is then accepted as the successor and those rights are stranded: the old oracle
/// refuses further claims (NotTheVaultsOracle), the vault reads the new one (InsufficientRights).
contract Proof_StrandedRights is Test {
    uint256 private constant IMD_ETH = 0.0005 ether;
    uint256 private constant AGENT = 51450;
    address private constant CONTROLLER = address(0xA11CE);

    MockIMD private imd;
    SrFeed private primary;
    SrFeed private nhi;
    ParameterizedVault private vault;
    Parameters private params;
    SrSeedableWork private work;

    function setUp() public {
        vm.warp(1_700_000_000);
        vm.roll(20_000_000);
        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);
        vm.etch(WORK_ORACLE_FACTORY, address(new SrSeedableWorkFactory()).code);
        vm.etch(CHAINLINK_ETH_USD, address(new SrAggregator()).code);
        imd = new MockIMD();
        SrShare share = new SrShare(imd);
        primary = new SrFeed(IMD_ETH);
        nhi = new SrFeed(0.85 ether);
        vault = new ParameterizedVault(
            address(share), address(0), WORK_ORACLE_SENTINEL, address(primary), address(nhi), address(new SrMirror(primary))
        );
        params = vault.parameters();
        work = SrSeedableWork(address(vault.oracle()));
    }

    function _apply() private {
        vm.warp(params.pendingEta());
        primary.set(IMD_ETH);
        nhi.set(0.85 ether);
        vm.prank(address(0xA990));
        params.applyPending();
    }

    function _setWage(uint256 wad) private {
        vm.prank(APPROVED_OPERATOR);
        params.proposeWage(wad);
        _apply();
    }

    /// @dev A two-leaf StandardMerkleTree holding the agent's leaf and one other.
    function _tree(uint256 id, uint32 accepted, uint64 cumulative) private pure returns (bytes32 root, bytes32[] memory proof) {
        bytes32 a = keccak256(bytes.concat(keccak256(abi.encode(id, accepted, cumulative))));
        bytes32 b = keccak256(bytes.concat(keccak256(abi.encode(uint256(1), uint32(1), uint64(1)))));
        root = a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a));
        proof = new bytes32[](1);
        proof[0] = b;
    }

    function test_aReplacementIsRefusedWhileTheCurrentOracleHoldsCreditedUnconsumedRights() public {
        // Before any claim a fresh successor is a valid proposal (nothing to carry over), and is cancelled.
        SwarmWorkOracle early = new SwarmWorkOracle(address(vault), 1 days);
        vm.prank(APPROVED_OPERATOR);
        params.proposeWorkOracle(address(early));
        vm.prank(APPROVED_OPERATOR);
        params.cancel();

        // Governance turns the wage on; an agent's controller claims 500 attested tasks and mints nothing.
        _setWage(1 ether);
        (bytes32 root, bytes32[] memory proof) = _tree(AGENT, 500, 500);
        work.seed(uint256(root));
        work.recordRoot();
        vm.mockCall(
            ERC8004_ADAPTER, abi.encodeWithSignature("isController(uint256,address)", AGENT, CONTROLLER), abi.encode(true)
        );
        vm.prank(CONTROLLER);
        assertEq(work.claim(AGENT, 500, 500, proof, root), 500 ether);
        assertEq(work.mintingRights(CONTROLLER), 500 ether, "credited, unconsumed");
        assertEq(vault.totalEarned(), 0, "nothing minted");

        // Governance turns the wage off, as proposeWorkOracle requires, and proposes a fresh SwarmWorkOracle.
        _setWage(0);
        SwarmWorkOracle successor = new SwarmWorkOracle(address(vault), 1 days);
        // EXPECTED: refused, because the current oracle still carries credited rights the successor cannot
        // carry over (the same rule that applies once anything has been minted).
        // ACTUAL on this code: accepted; applied 48 hours later the 500e18 rights are stranded in the old
        // oracle (it refuses claims once superseded; the vault reads the successor, which holds nothing).
        vm.prank(APPROVED_OPERATOR);
        vm.expectRevert();
        params.proposeWorkOracle(address(successor));
    }
}
```

### 3. Info: The governor's minting trust statement in Parameters is incomplete: the three-step path it describes is 144 hours with one proposal slot (not 96), the 'per-asset cap' is $1e18 per listing with an unbo

`src/Parameters.sol:58`

```
/// Together, after 96 hours of public proposals, that is minting with no collateral and no attested
```

Q5 (is the statement complete and are the bounds as described). The mechanisms are stated correctly and the code matches: _validate enforces every one; every proposal waits TIMELOCK = 48 hours (Governed.sol:25, 58, 83), one slot at a time (_propose reverts ProposalPending while pendingEta != 0), _validate runs again at application; reserveValueOf caps each asset at MAX_RESERVE_VALUE (Treasury.sol:238) and saturates the sum; earn is refused at wage 0 (ParameterizedVault._earnOpen, CDPVault.earn:491); a hostile oracle cannot be installed while the wage is nonzero and cannot replace a SwarmWorkOracle after a first mint. Three things the statement does not say. (1) Hours: 'after 96 hours' counts the listing and the oracle; the same paragraph then says the oracle step mints nothing until a wage proposal has also been public 48 hours, so from the launch state (wage 0) the path it describes is three serial proposals, 144 hours, and from a running wage four (wage to 0 first), 192; the oracle + wage pair alone (96 hours) mints only against the ratio term (backedDebt x earnMat / 10000, up to 25% of the lagged collateral-backed debt, $250k at the $1M LINE). The two sentences give different totals for the same path. (2) 'The per-asset cap' is Treasury.MAX_RESERVE_VALUE = 1e36 in 1e18-scaled USD, i.e. $1e18 per listed asset, a trillion times LINE ($1e6), with _reserveAssets unbounded in length and earn reading no `line`: its own NatSpec (Treasury.sol:63) says it is an overflow bound, not a limit on damage. (3) The listing step has a consequence of its own that needs neither oracle nor wage and is the opposite direction from 'lowering the redemption backing': reserveValueUsd is the `others` term of ParameterizedVault._redemptionReserveBacking (ParameterizedVault.sol:178), which CDPVault._backingPerUnit adds on both the live and the lagged side for every cash. A single listing of a token the Treasury holds one unit of, against a feed answering 1e30, after 48 hours reads backingPerUnit() at par whatever the collateral is worth, and any unprivileged redeemer (including one who bought below peg) is then paid (1 - fee) of par in real sIMD from the Treasury's reserve first and from candidates' collateral next, while true backing is below par; the remaining holders are left with the junk. Measured: at backing 0.84 after a price fall, cash(10e18) paid 20.79 IMD; after the listing alone, 24.75 IMD (1.19x), with backingPerUnit() reading 1e18. The ParameterizedVault header ('See Parameters for what that lets the governor do') defers to the same incomplete text, and runbook section 3 does not mention it. This is the governor's intended, visible power and is reported as the completeness of the statement, as the final panel classed the whole power (info), not as a bypass. Smallest fix, documentation only: restate lines 54-62 as 'a reserve listing sets the reserve term of earnLine AND of the redemption backing (backingPerUnit, cash), so a listing alone, after 48 hours, lets redeemers be paid at par against a reserve the governor priced; with a replacement oracle (48 h) and a wage (48 h), 144 hours in all from launch, it is also minting with no collateral and no attested work; against the ratio term alone the oracle and wage steps, 96 hours, mint up to earnMat of the lagged backed debt; the per-asset cap is $1e18 and bounds overflow, not damage', and say the same in runbook section 3. If the redemption half is not intended, exclude registered non-collateral assets from _redemptionReserveBacking instead (they never leave through redeemIMD). Merged from audit_permissions, audit_economics, audit_flow and audit_math (one statement, four reproductions, all confirmed; the economics specialist's 'low' is kept at the panel's 'info' because the fix is to the text and the power is the stated design).

**Reproduction**

Hours, read-only: Governed.TIMELOCK = 48 hours; Governed._propose reverts ProposalPending while pendingEta != 0, so proposeReserveAsset -> applyPending -> proposeWorkOracle -> applyPending -> proposeWage -> applyPending cannot complete before 3 x 48 = 144 hours; Parameters.sol:58 says 96. Cap: Treasury.sol:64 MAX_RESERVE_VALUE = 1e36 (1e18-scaled USD) against DeploymentConfig.LINE = 1_000_000e18: 1e36 / 1e24 = 1e12. Payout: test/scratch/JudgeListing.t.sol (PASSES as a demonstration). WorkBackingFixture (OpenWorkVault over MockIMD at $1, NHI 0.85, empty register). BORROWER locks 2,000 IMD and draws 1,000 imdUSD, 100 imdUSD to REDEEMER, 100 IMD minted to the Treasury as its reserve; one day warms the lag; the price falls to $0.40: backingPerUnit() == 840000000000000000 ((800 + 40) / 1000). cash(10e18, 0, BORROWER) pays 20790000000000000000 raw collateral (snapshot, reverted). Then APPROVED_OPERATOR proposeReserveAsset(ReserveTestToken holding 1e18 at the Treasury, TestSwarmFeed answering 1e30, 10000), +48h applyPending; no oracle or wage change. EXPECTED per the statement: the listing sets only 'the reserve term of earnLine' and the governor's power is 'lowering the redemption backing'. ACTUAL: reserveValueUsd() == 1e30, backingPerUnit() == 1e18, and the same cash(10e18, 0, BORROWER) pays 24750000000000000000 raw collateral out of the vault's real reserve.

### 4. Info: fundOracle's new plain-IMD leg is all-or-nothing with the sIMD unwrap it falls through to: when the share leg reverts under sIMD's same-block hold (a liquidation cut, or the accepted D5 dust transfer,

`src/Treasury.sol:533`

```
            if (share) _withdrawUnderlying(IERC20(token), fromShares);
```

Q2 and Q6, a gap left by the 8756817 fix for the final governance panel's low #2 ('spend plain IMD first'). When the Treasury's plain IMD covers only part of the day's remaining budget (plain < want), the remainder is unwrapped from sIMD in the SAME call with no fallback: _withdraw(imd, ORACLE_ASKER, plain) is followed by _withdrawUnderlying -> IShareVault.withdraw, and sIMD refuses that (SameBlockRedeem) in any block in which the Treasury received shares (the hold travels with a transfer: docs/COMPUTE-BACKING-DESIGN.md:537-548). So in a block where a liquidation's protocol cut lands, or where anyone sends one raw share unit to the Treasury (accepted residual D5, docs/AUDIT-FIX-PLAN-2026-10-05.md:38), the whole call reverts and the plain IMD, which needs no unwrap, is not sent. The NatSpec at Treasury.sol:488-490 documents the revert for the share case and the honest case clears a block later; what the fix promised (487-488 and runbook 7.4: plain IMD revenue 'takes over' from the keeper) is nevertheless not delivered in exactly the D5 state, which D5's acceptance ('a later block succeeds') did not weigh for the plain path because it did not exist. Bounded: griefing or a one-block delay, never loss; the keeper fallback and askPaid revive it, so info rather than low. CAVEAT: sIMD's hold is modelled from the repository's own fork notes (test/SharePriceFeedFork.t.sol:168-176, COMPUTE-BACKING-DESIGN.md:537-548); StakedIMD itself could not be reached here. Smallest fix: make the share leg best-effort: send `plain` first, then `try` the unwrap through an external self-call (`this.unwrapForOracle(fromShares)`, msg.sender == address(this)) and on failure set fromShares = 0, computing `sent` and `oracleSpent` from what actually left; or return early with sent = plain when the share vault reports a hold for this block. Merged from audit_economics and audit_flow (both reproduced against a modelled hold).

**Reproduction**

test/scratch/JudgeFundOracleHold.t.sol (PASSES as a demonstration). ParameterizedVault over HeldShare: an ERC-4626-shaped 24-decimal share (rate 7.95e12) that stamps lastIn[receiver] = block.number on every mint or transfer and reverts SameBlockRedeem in withdraw while lastIn[owner] == block.number; ORACLE_ASKER etched with code; default oracleBudget 15e18. The Treasury holds 10e18 plain IMD (below the budget) and 50e18 IMD deposited as shares in an earlier block. In the keeper's block anyone deposits 1e18 IMD into the share and transfers one raw share unit to the Treasury. treasury.fundOracle(): EXPECTED (NatSpec 487-488, runbook 7.4): at least the 10e18 plain IMD reaches the asker. ACTUAL: reverts SameBlockRedeem; imd.balanceOf(ORACLE_ASKER) == 0. One block later fundOracle() returns 15e18 (10 plain + 5 unwrapped) and the asker holds 15e18.

---

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