# Audit report

> Audit script/DeployConfig.sol (BetaConfig, MainnetConfig), script/DeployBeta.s.sol, script/Deploy.s.sol and script/deploy/, at the pinned commit, for a capped mainnet beta. Read whatever else in src/ these depend on, but report on this scope.
>
> imdUSD v2 is a multi-collateral CDP stablecoin; this is its capped mainnet beta on Ethereum. Positions are NFT ids; borrowers set their own interest rate and redemptions take from the lowest rates first; liquidation is mark then bite, with a standing bid from Backstop sleeves; the peg pool is a Uniswap v4 pool with our own hook; prices come from Chainlink and from swarm-attested feeds (EIP-712 attestations bound to pinned questions). README.md gives the scope, the key invariants, the trust model and the known issues: read it first.
>
> BUILD AND TEST (required; Foundry, solc 0.8.26 via_ir per foundry.toml). Never build or test the whole project: with every suite it needs about 49 GB and the compiler will be killed. Run only this panel's tests: `forge test --match-path 'test/fork/DeployBeta.t.sol'` (measured: about 2.6 GB peak, 5 minutes). One build at a time on your machine. These tests fork mainnet: set MAINNET_RPC_URL to an archive endpoint if the public default refuses. Write each proof as a new file test/unit/Proof<Name>.t.sol, add it to the glob and run it; paste it with its output into the finding, then delete it: your submission must leave the tree unchanged. With Foundry 1.9 or later, `forge test --use solar --no-match-path 'test/fork/*'` builds everything and runs all 1,179 tests in seconds under 1 GB (Solar accepts a little syntax solc refuses: confirm a proof with the solc command).
>
> Answer each numbered question, including the ones where nothing is wrong:
> 1. AUTHORITIES. After the last stage every governor, owner and admin is the governance Safe, and no deployer or wirer power remains: list every one-time setter and its state after the seal.
> 2. CONFIG IS WHAT IS DEPLOYED. Every constructor argument and genesis setter matches BetaConfig (lines, risk values, caps, feeds per type: median, spot, NHI, live leg), and every feed reads what its type expects.
> 3. RESUME. A stage stopped part-way and resumed cannot adopt a contract it did not deploy, skip a check, or wire one contract to another chain's address.
> 4. GAS. Every transaction stays under EIP-7825's 16,777,216 per-transaction cap; any CREATE2 salt that could be front-run.
> 5. Out of scope: the launch shell and Safe batch scripts (not in this repo); script/DeployMainnetBridge.s.sol and script/DeployRobinhood.s.sol (after the beta).
>
> Not findings: anything listed under README.md 'Known issues and accepted risks' (say so if you find it worse than written); src/bridge, src/dex, src/earn/LoopVault.sol and src/stocks (after the beta) unless a question names them; test/ and its mocks; gas optimisations unless they change behaviour.
>
> For every finding: severity by impact; file, line and the lines copied; the call sequence from an external caller; a concrete failing input or state with expected against actual; whether it is reachable with the values in script/DeployConfig.sol (BetaConfig); the smallest fix; and a Foundry test that fails on this commit, RUN as BUILD says and pasted in full in the finding with its output (the judge reruns it). The test/ tree has harnesses to reuse (test/utils/System.sol, the unit suites). Answer every numbered question, including those where nothing is wrong. Give a coverage row (holds, finding or unreached, with a reason) for every external function of the contracts in scope. Also report every comment or NatSpec that claims a property the code does not have.

| | |
|---|---|
| Repository | https://github.com/fa11up/imdusd-v2-audit.git |
| Commit | `2875ffb22a726f041bd6891f32f35ddc7cdb983d` |
| Job | `17e4edf1-6697-4353-9311-12eb9ca61aab` |
| Judged | 2026-11-10 22:13 UTC |
| Findings | 1 medium · 3 low · 4 info |

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

## Findings

### 1. Medium: Resume: stage 3 keeps an already-registered type after comparing only its join; feeds, NHI, live leg, redeemable flag and the whole Risk struct of the earlier run are never checked against the config

`script/deploy/StageTypes.sol:53`

```
            } else if (d.params.config(s.ilk).join != address(join)) {
```

Question 3 (resume). DeployBase.sol:55-56 and StageTypes.sol:87-88 state the resume contract: a recorded contract with the right wiring is kept, one with the wrong wiring stops the run with Mismatch. For genesis type registrations StageTypes.sol:49-55 treats `params.exists(ilk)` as done and compares only `config(ilk).join`. The median, spot, nhi, yieldRate, pegAggregator, liveSpot and redeemable registered by an earlier attempt, and the whole Risk struct (line, mat, chop, fee, redeemDivisor, redeemCap...), are never compared with `_ilkConfig(s, join, d.feeds)` / `s.risk`. _deploySeats (StageTypes.sol:120-123) has the same gap for SEAT, and StageTypes.sol:65 keeps any existing chain line without comparing it with cfg.chain.chainLine (300,000e18). Parameters forbids re-pointing a type's feeds after registration (src/Parameters.sol:14-15), so a type sealed on wrong nodes can only be wound down and re-listed under a new ilk through the 48h timelock; wrong risk values need a 48h SET_RISK each. Call sequence: operator EOA -> the stage-3 runner (the same internal `_stageTypes(cfg, d, me)` that Deploy.run() calls) with a non-empty record `d`. Reachable with BetaConfig values: yes, on any resume of stage 3 after `addGenesisIlk` landed for at least one type and the config, or the feed entries of the record, changed in between (for example a run started from Deploy.s.sol and resumed from DeployBeta.s.sol, or stage 2 re-run after a node correction so that `d.feeds` names new nodes). Not reachable in the single-run Deploy.run() (fresh Parameters), which is all test/fork/DeployBeta.t.sol rehearses. Not a permission bypass (the record is the deployer's); it is 'deployed != config' without the Mismatch stop the scripts promise. Smallest fix: in the else branch compare `abi.encode(d.params.config(s.ilk))` with `abi.encode(_ilkConfig(s, address(join), d.feeds))` and `abi.encode(d.params.storedRisk(s.ilk))` with `abi.encode(s.risk)` and revert Mismatch("type registered with other feeds or risk"); the same for SEAT (StageTypes.sol:121) and PEG (StagePeg.sol:23-28), and compare `chainLine()` with cfg.chain.chainLine at line 65. Merged from four specialists (audit_permissions, audit_flow, audit_economics, audit_math): one mechanism, one fix.

**Reproduction**

Run on this commit: `forge test --match-path test/scratch/ProofResumeTypes.t.sol` (no fork; real Parameters, real `_stageTypes`). State A (risk): stage 3 ran with MainnetConfig (ETH line 2,000,000e18, redeemDivisor 4), stopped before the seal, resumed with BetaConfig (ETH line 50,000e18, divisor 16). Expected: revert Mismatch. Actual: returns; storedRisk("ETH").line == 2,000,000e18. State B (feeds): stage 3 ran, then the record's feed entries were replaced by new nodes (stage 2 re-run) and stage 3 resumed. Expected: Mismatch. Actual: returns; config("ETH").median is the old node while d.feeds[0] names the new one. Output: [FAIL: ETH line after the resume is not BetaConfig's: 2000000000000000000000000 != 50000000000000000000000] test_resumeWithBetaRiskKeepsTheOtherConfigsLine() (gas: 1669435); [FAIL: ETH median is not the node the record names: 0xa0Cb889707d426A7A386870A03bc70d1b0697598 != 0x03A6a84cD762D9707A21605b548aaaB891562aAb] test_resumeWithRedeployedFeedsKeepsTheOldNodes() (gas: 1794860). Suite result: FAILED. 0 passed; 2 failed.

**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 "forge-std/Test.sol";
import {DeployTypes, MainnetConfig, BetaConfig} from "script/DeployConfig.sol";
import {DeployBase} from "script/deploy/DeployBase.sol";
import {StageTypes} from "script/deploy/StageTypes.sol";
import {Parameters} from "src/Parameters.sol";
import {ParametersBase} from "src/ParametersBase.sol";
import {Ledger} from "src/Ledger.sol";
import {IFeed} from "src/interfaces/IFeed.sol";

contract Blank {
    function ping() external pure returns (bool) {
        return true;
    }
}

/// Stage 3 exactly as the launch runs it: the stage function on a deployment record `d`.
contract Stage3 is StageTypes {
    function stage3(DeployTypes.Config memory cfg, Deployed memory d) external returns (Deployed memory) {
        _stageTypes(cfg, d, address(this));
        return d;
    }
}

/// A stage-3 resume must refuse (Mismatch) a type that is registered with other feeds or other risk than the config
/// it is run with. StageTypes.sol:53 compares the join only.
contract ResumeTypesTest is Test {
    Stage3 internal h;
    Parameters internal params;
    address internal ledger;
    address internal token;

    function setUp() public {
        h = new Stage3();
        params = new Parameters(address(0x5AFE), address(h)); // the stage contract is the wirer, as the deployer EOA is
        ledger = address(new Blank());
        token = address(new Blank());
        address usd = address(new Blank());
        vm.prank(address(h));
        params.wire(ledger, usd);
    }

    function _cfg(ParametersBase.Risk memory risk) internal view returns (DeployTypes.Config memory cfg) {
        cfg.migratorClosed = true;
        cfg.ilks = new DeployTypes.IlkSpec[](1);
        cfg.ilks[0] = DeployTypes.IlkSpec(
            "ETH", token, 0, 1, DeployTypes.NONE, DeployTypes.NONE, address(0), DeployTypes.NONE, true, risk
        );
    }

    function _record() internal returns (DeployBase.Deployed memory d) {
        d.params = params;
        d.ledger = Ledger(payable(ledger));
        d.feeds = _nodes();
    }

    function _nodes() internal returns (IFeed[] memory f) {
        f = new IFeed[](2);
        f[0] = IFeed(address(new Blank()));
        f[1] = IFeed(address(new Blank()));
    }

    /// Stage 3 stopped after ETH was registered from the full config (line 2,000,000), resumed with BetaConfig (50,000).
    function test_resumeWithBetaRiskKeepsTheOtherConfigsLine() public {
        ParametersBase.Risk memory full = MainnetConfig.get().ilks[0].risk;
        ParametersBase.Risk memory beta = BetaConfig.get().ilks[0].risk;
        assertEq(full.line, 2_000_000e18, "full ETH line");
        assertEq(beta.line, 50_000e18, "beta ETH line");

        DeployBase.Deployed memory d = h.stage3(_cfg(full), _record());
        d = h.stage3(_cfg(beta), d); // expected: Mismatch. Actual: returns.

        ParametersBase.Risk memory got = params.storedRisk("ETH");
        assertEq(got.line, beta.line, "ETH line after the resume is not BetaConfig's");
        assertEq(got.redeemDivisor, beta.redeemDivisor, "ETH redeem divisor after the resume is not BetaConfig's");
    }

    /// Stage 2 was re-run and deployed new feed nodes (the record now names them); stage 3 resumes on that record.
    function test_resumeWithRedeployedFeedsKeepsTheOldNodes() public {
        ParametersBase.Risk memory beta = BetaConfig.get().ilks[0].risk;
        DeployBase.Deployed memory d = h.stage3(_cfg(beta), _record());
        d.feeds = _nodes();
        d = h.stage3(_cfg(beta), d); // expected: Mismatch. Actual: returns.

        ParametersBase.Config memory c = params.config("ETH");
        assertEq(address(c.median), address(d.feeds[0]), "ETH median is not the node the record names");
        assertEq(address(c.spot), address(d.feeds[1]), "ETH spot is not the node the record names");
    }
}
```

### 2. Low: BetaConfig (and MainnetConfig) deploy the Allocator with freezer = address(0): backstopSpec never assigns `freezer`, nothing refuses zero on chain 1, and the FREEZER guardian the README and the Backst

`script/DeployConfig.sol:503`

```
    function backstopSpec(uint256 simdBootstrap) internal pure returns (DeployTypes.BackstopSpec memory b) {
```

Questions 1 and 2. MainnetConfig.backstopSpec (script/DeployConfig.sol:503-516) sets enabled, edgePremium, maxSlippageBps and the seven sleeve types and never assigns `b.freezer`; BetaConfig.get() (line 679) reuses it unchanged and DeployBeta.config() is pure. StageGenesis._stageBackstop (script/deploy/StageGenesis.sol:179) passes it straight into `new Allocator(cfg.governor, address(d.treasury), b.freezer, b.maxSlippageBps)`, and the Allocator constructor (src/peg/Allocator.sol:149-155) only refuses a zero treasury and an over-large slippage. The BackstopSpec NatSpec (script/DeployConfig.sol:185) claims '`freezer` = the guardian key (zero refused off a local fork)': no code in script/ or src/ refuses it. The README trust model ('A separate FREEZER (guardian) key can only freeze() the Allocator and remove keepers') describes a role the beta does not ship. Consequence: after launch only the 2-of-3 Safe can freeze() or removeKeeper() (Allocator.sol:288-289, 307-308); naming a guardian is a 48h SET_FREEZER proposal. Bounded because the Allocator has no keeper at genesis (ADD_KEEPER is itself timelocked, so the Safe can batch SET_FREEZER with it), and no bypass results (msg.sender can never be address(0)). test/fork/DeployBeta.t.sol never reads allocator.freezer(), so the rehearsal does not catch it. Reachable with BetaConfig values: deterministically, every deploy. Smallest fix: assign the guardian key in backstopSpec (or a BetaConfig override), add `if (b.freezer == address(0) && block.chainid != 31337) revert Mismatch("backstop: no freezer");` in _stageBackstop as the NatSpec says, and assert `d.allocator.freezer()` in DeployBeta.t.sol. Merged from four specialists; one rated it medium, kept at low by impact (a documented fast brake missing until a 48h proposal, no funds or permissions).

**Reproduction**

Input: BetaConfig.get().backstop.freezer. Expected: a non-zero guardian key (or a refusal on chain 1). Actual: address(0); `new Allocator(GOVERNANCE_SAFE, treasury, address(0), 200)` succeeds with vm.chainId(1) and allocator.freezer() == address(0). Run on this commit: `forge test --match-path test/scratch/ProofFreezer.t.sol`: [FAIL: deployed Allocator has no freezer] test_allocatorBuiltFromBetaConfigHasAFreezerOnChain1() (gas: 114338); [FAIL: BetaConfig.backstop.freezer is address(0)] test_betaConfigNamesAFreezer() (gas: 105820). Suite result: FAILED. 0 passed; 2 failed.

**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 "forge-std/Test.sol";
import {DeployTypes, BetaConfig} from "script/DeployConfig.sol";
import {Allocator} from "src/peg/Allocator.sol";

/// BetaConfig (via MainnetConfig.backstopSpec) never assigns `backstop.freezer`, so StageGenesis._stageBackstop builds
/// `new Allocator(governor, treasury, address(0), 200)` and nothing refuses it on chain 1, although DeployConfig.sol:185
/// says "zero refused off a local fork" and the README names a FREEZER guardian. Fails on this commit; passes once the
/// config names a freezer or the stage/constructor refuses zero on a live chain.
contract ProofFreezer is Test {
    function test_betaConfigNamesAFreezer() public pure {
        DeployTypes.Config memory c = BetaConfig.get();
        assertTrue(c.backstop.enabled, "standing bid is on in the beta");
        assertTrue(c.backstop.freezer != address(0), "BetaConfig.backstop.freezer is address(0)");
    }

    function test_allocatorBuiltFromBetaConfigHasAFreezerOnChain1() public {
        vm.chainId(1);
        DeployTypes.Config memory c = BetaConfig.get();
        Allocator a = new Allocator(c.governor, makeAddr("treasury"), c.backstop.freezer, c.backstop.maxSlippageBps);
        assertTrue(a.freezer() != address(0), "deployed Allocator has no freezer");
    }
}
```

### 3. Low: USDC/USD and USDS/USD ChainlinkFeed maxAge equals the 24h heartbeat exactly, so the USDC, S-USDC-STEAKHOUSE and S-USDS types read stale around every heartbeat update while USDT and the peg hook's copy

`script/DeployConfig.sol:444`

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

Question 2 (every feed reads what its type expects). F_USDC (line 444) and F_USDS (line 442) get maxAge = 1 days, while every other 24h-heartbeat aggregator in the same config gets the heartbeat plus a margin: USDT (line 446), cbBTC (438), WBTC/BTC (440) and the SAME USDC/USD aggregator as pegQuoteFeed (line 427) all get 1 days + 1 hours. ChainlinkFeed.isStale (src/feeds/ChainlinkFeed.sol:74-77) is `block.timestamp - at > maxAge`. A heartbeat round is only started once the previous answer is 24h old and lands seconds to minutes later, so for that window each time USDC/USD or USDS/USD goes a full heartbeat without a deviation update (common for stablecoins) F_USDC/F_USDS are stale; RateFeed.isStale propagates it to F_SUSDC/F_SUSDS, Pricer.quote (src/Pricer.sol:61-83) sets medianOk = false and borrowable = false, and new debt and rate changes on USDC, S-USDC-STEAKHOUSE and S-USDS revert until the update is mined. Liveness only; repayment and liquidation are unaffected. Reachable with BetaConfig values: yes. Smallest fix: `_cl(CL_USDC_USD, 6, 1 days + 1 hours)` and `_cl(CL_USDS_USD, 18, 1 days + 1 hours)`, matching the rest of the config and the hook's quote gate. (Reported by audit_permissions; reproduced here with a unit test.)

**Reproduction**

State: a 24h-heartbeat aggregator whose latestRoundData().updatedAt = T and no deviation update since. Input: read ChainlinkFeed(F_USDC node) and ChainlinkFeed(F_USDT node), both built from BetaConfig.get().feeds, at block.timestamp = T + 86,401. Expected: both fresh (same heartbeat, same age). Actual: USDT fresh, USDC isStale() == true (86,401 > 86,400); also F_USDC.maxAge (86,400) != pegQuoteFeedMaxAge (90,000) for the same aggregator. Run on this commit: `forge test --match-path test/scratch/ProofFeedMaxAge.t.sol`: [FAIL: USDC node stale at heartbeat + 1s (expected: fresh, like USDT)] test_usdcFeedIsStaleOneSecondAfterAHeartbeatWhileUsdtIsNot() (gas: 409461); [FAIL: F_USDC maxAge has no margin over the 24h heartbeat: 86400 <= 86400] test_usdcNodeAllowsTheSameAgeAsUsdtAndThePegQuoteCopy() (gas: 108960). Suite result: FAILED. 0 passed; 2 failed.

**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 "forge-std/Test.sol";
import {DeployTypes, MainnetConfig, BetaConfig} from "script/DeployConfig.sol";
import {ChainlinkFeed} from "src/feeds/ChainlinkFeed.sol";

/// A 24h-heartbeat aggregator whose last answer was transmitted at `at`.
contract Aggregator24h {
    uint256 public at;

    constructor(uint256 at_) {
        at = at_;
    }

    function decimals() external pure returns (uint8) {
        return 8;
    }

    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {
        return (1, 1e8, at, at, 1);
    }
}

/// BetaConfig gives the USDC/USD and USDS/USD nodes maxAge = 1 days, exactly the aggregators' heartbeat, while USDT/USD
/// (same heartbeat) and the peg hook's copy of the SAME USDC/USD aggregator get 1 days + 1 hours. One second past a
/// full heartbeat (before the heartbeat round lands) ChainlinkFeed.isStale() is true for USDC/USDS and false for USDT.
/// Fails on this commit; passes once F_USDC and F_USDS carry a margin over the heartbeat like the other 24h nodes.
contract ProofFeedMaxAge is Test {
    function _node(uint256 i) internal pure returns (DeployTypes.FeedNode memory) {
        return BetaConfig.get().feeds[i];
    }

    function test_usdcNodeAllowsTheSameAgeAsUsdtAndThePegQuoteCopy() public pure {
        DeployTypes.Config memory c = BetaConfig.get();
        DeployTypes.FeedNode memory usdc = c.feeds[MainnetConfig.F_USDC];
        DeployTypes.FeedNode memory usds = c.feeds[MainnetConfig.F_USDS];
        DeployTypes.FeedNode memory usdt = c.feeds[MainnetConfig.F_USDT];
        assertEq(usdc.source, c.pegQuoteFeed, "same USDC/USD aggregator as the peg hook's quote gate");
        assertGt(usdc.maxAge, 1 days, "F_USDC maxAge has no margin over the 24h heartbeat");
        assertGt(usds.maxAge, 1 days, "F_USDS maxAge has no margin over the 24h heartbeat");
        assertEq(usdc.maxAge, usdt.maxAge, "F_USDC and F_USDT (both 24h heartbeat) differ");
        assertEq(usdc.maxAge, c.pegQuoteFeedMaxAge, "F_USDC and pegQuoteFeedMaxAge (same aggregator) differ");
    }

    function test_usdcFeedIsStaleOneSecondAfterAHeartbeatWhileUsdtIsNot() public {
        uint256 t0 = 1_800_000_000;
        vm.warp(t0);
        Aggregator24h agg = new Aggregator24h(t0);
        DeployTypes.FeedNode memory usdc = _node(MainnetConfig.F_USDC);
        DeployTypes.FeedNode memory usdt = _node(MainnetConfig.F_USDT);
        ChainlinkFeed fUsdc = new ChainlinkFeed(address(agg), usdc.decimals, usdc.maxAge);
        ChainlinkFeed fUsdt = new ChainlinkFeed(address(agg), usdt.decimals, usdt.maxAge);
        vm.warp(t0 + 1 days + 1); // the heartbeat round is being transmitted, not yet mined
        assertFalse(fUsdt.isStale(), "USDT node fresh at heartbeat + 1s");
        assertFalse(fUsdc.isStale(), "USDC node stale at heartbeat + 1s (expected: fresh, like USDT)");
    }
}
```

### 4. Low: Resume: stage 4 adopts recorded backstop, depth and peg-feed pieces on partial checks (Allocator freezer/slippage, sleeve listings' bootstrap and sale limit, edgePremium, the sleeves' and oracle's dep

`script/deploy/StageGenesis.sol:176`

```
        if (!(_has(address(d.allocator)) && _ok(d.allocator.treasury() == address(d.treasury) && d.allocator.governor() == cfg.governor,
```

Question 3 (resume), the same class as the stage-3 finding but in other functions with their own fixes. Every adoption below keeps a recorded contract or state whose values the current config would not have produced, where the scripts promise a Mismatch stop: (a) StageGenesis.sol:176-177 adopts a recorded Allocator on treasury and governor alone, so `freezer()` and `maxSlippageBps()` may differ from b.freezer / b.maxSlippageBps (both immutable to anything but a 48h proposal afterwards); (b) StageGenesis.sol:194-199 keeps a type already listed on a sleeve (`listed != address(0)`) without comparing its token, genesisLine (the edge bootstrap, 50,000e18 in BetaConfig) or saleMax with `si`, and line 188 skips setIlk when `ilkOf(token) != 0` without checking it equals si.ilk; (c) StageGenesis.sol:216-222 compares only the two sleeve addresses once `blueBackstop() != 0`, never edgePremium; (d) `_sleeve` (lines 240-241) and the PricerOracle check (170-171) compare immutables only and never `deployer()`: a Backstop or PricerOracle the deploying EOA did not create is adopted when it is already wired, listed and sealed (no deployer-only call is then made that would revert), and `_stageGenesis(cfg, d)` is not even given `me` to compare; (e) `_stageDepth` (line 276) adopts a recorded DepthSampler on poolId alone (feed0, feed1, bandBps, maxDeviationTicks, maxSteps, soldIsToken0, manager unchecked) and never compares the ImdDepth sampler list once `params.imdDepth()` is set; (f) StagePeg.sol:34 adopts a recorded PegShareFeed on `vault()` alone (quote aggregator, max age, two-sided flag unchecked); (g) StageTypes.sol:92 adopts a recorded SeatJoin on nft and wirer alone (ilk, adapter, maxSeats, perBorrower unchecked). Each needs a deployment record naming a contract or state from an earlier attempt with other values; none is reachable in the single-run Deploy.run() (d starts empty), and with a truthful record and an unchanged BetaConfig all hold. Smallest fix: compare what is kept with the config and revert Mismatch, as the stage already does for the depth rule and for `s.liquidator()/params()/seller()`: `d.allocator.freezer() == b.freezer && d.allocator.maxSlippageBps() == b.maxSlippageBps`; on an existing listing `listed == token` and the stored genesisLine / sale limit equal `si`; `d.params.edgePremium() == b.edgePremium`; `s.deployer() == me` and `d.backstopOracle.deployer() == me` (pass `me` into _stageGenesis); sampler feeds/band/deviation/steps/side; PegShareFeed quote feed, max age and flag; SeatJoin ilk/adapter/limits. Merged from audit_economics and audit_permissions; verified by reading each adoption against the contracts' getters (Backstop.sol:122/203/228/260, PricerOracle.sol:14/27/31/37, Allocator.sol:149-155).

**Reproduction**

State 1: the record's `allocator` was deployed with freezer F1 (or slippage 100); the config now names F2 (200). Call: stage-4 runner -> _stageGenesis -> _stageBackstop with that record. Expected: Mismatch("Allocator"). Actual: line 176 passes (treasury and governor match), the system is sealed around an Allocator whose freezer() is F1. State 2: stage 4 stopped after `edge.addIlk("SIMD", SIMD, genesisLine, 1e27, 1e27 / 1 days)` landed from a run with another bootstrap (Deploy.s.sol passes MainnetConfig's simdBootstrap; BetaConfig passes 50,000e18), then resumed with DeployBeta. `edge.ilks("SIMD").token != 0`, so line 195 is false and the listing is kept. Expected: Mismatch. Actual: the edge sleeve is sealed with the earlier genesisLine and the run reports success. State 3: the record's `samplers[0]` is a DepthSampler on IMD_ETH_POOL_ID built with bandBps 500 or another feed1; line 276 passes on the poolId and the sampler is listed in ImdDepth as is. (Each state was traced through the stage code and the contracts' constructors/getters; no proof file is attached to this low finding.)

### 5. Info: Gas (question 4): PositionNFT.bind is broadcast without a gas-log row, every feed node is logged with a fixed 3,000-byte init code (swarm leaves are 11-13 KB), and no test asserts the EIP-7825 cap on

`script/deploy/StageCore.sol:68`

```
        if (d.ledger.nft().ledger() == address(0)) d.ledger.nft().bind(address(d.ledger));
```

Every other broadcast call in the stages is bracketed by `g = gasleft(); ...; _rec(label, g, dataLen)` so that test/fork/Deploy.t.sol:395-406 can assert `rows[i].gas * 13 / 10 <= 16_777_216` per transaction. (1) The bind at StageCore.sol:68 is sent without `_rec`, so gasLog() is one transaction short of what is broadcast (DeployBase.sol:108 calls a row 'One broadcast transaction's gas'); the call is one SSTORE, far under the cap. (2) StageFeeds.sol:30 records `_rec("feed node", g, 3_000)` for all 25 nodes, while DeployBase.sol:109 says the row counts the init code at 16 gas a byte: measured init code (creationCode + args) ChainlinkFeed 2,255 B, RateFeed 2,779 B, SwarmPriceFeed 11,892 B, SwarmSpotFeed 11,621 B, SwarmNhiFeed 13,083 B, SwarmSeatMedianFeed 11,166 B, SwarmSeatSpotFeed 12,069 B, so the seven swarm rows understate their transactions by about 130-160k gas each. (3) The only assertion against 16,777,216 is in test/fork/Deploy.t.sol:406 for the FULL config; test/fork/DeployBeta.t.sol never reads gasLog(), so the transactions that exist only in the beta (SeatJoin + SeatAccount impl, SeatFeed, SeatSpotLeg, the two seat leaves, addGenesisIlk(SEAT), setGenesisChainLine, setGenesisMaxLull) are not held to the cap by any test. The largest create in either config is Parameters (about 10.2M gas measured by audit_economics on a fork at block 26,387,076; 10.2M x 1.3 = 13.3M < 16.78M), so no beta transaction is at the cap today; what is missing is the evidence. I could not run the fork rehearsal in this review's budget (see the coverage entry). Smallest fix: wrap line 68 as `g = gasleft(); d.ledger.nft().bind(address(d.ledger)); _rec("nft.bind", g, CALL_DATA);`, have `_feed` return the init length (or record per kind) for StageFeeds, and add Deploy.t.sol's gasLog loop to DeployBeta.t.sol (or revert inside `_rec` when `used * 13 / 10 > 16_777_216`). Merged from audit_economics and audit_math.

**Reproduction**

(1) Read StageCore.sol:68 against every other wire call (lines 80-83, 95-98, 105-108, 112-115): no `_rec`. Expected: one GasRow per broadcast transaction; actual: a fresh deploy sends the bind and gasLog() has no row for it. (2) Run on this commit `forge test --match-path test/scratch/ProofFeedGasRow.t.sol -vv` (a size check, deleted after the run): [FAIL: swarm leaf init code is over the 3,000 bytes the row assumes: 13083 > 3000] test_initCodeSizes(); Logs: ChainlinkFeed 2255, RateFeed 2779, SwarmPriceFeed 11892, SwarmSpotFeed 11621, SwarmNhiFeed 13083, SwarmSeatMedian 11166, SwarmSeatSpot 12069. (3) `grep -n 16_777_216 test/fork/DeployBeta.t.sol` finds nothing; Deploy.t.sol:406 is the only check and runs MainnetConfig (seats off).

### 6. Info: Gas (question 4): the PegHook CREATE2 salt is computable by anyone after stage 1 and can be front-run; the pre-deployed hook is byte-identical, so the effect is one halted broadcast and an orphan hook

`script/deploy/DeployBase.sol:264`

```
            if (uint160(a) & Hooks.ALL_HOOK_MASK != flags || a.code.length != 0) continue;
```

The hook address is mined from salt 0 upward over init = PegHook.creationCode ++ abi.encode(poolManager, usd, USDC, treasury, params, CL_USDC_USD, 25h) (StageGenesis.sol:295-308). Every argument is public once stage 1 is on chain (the launch runs one stage per script, so days can pass before stage 4), so anyone can compute the salt the script will use and send the same CREATE2 through 0x4e59b44847b379578588920cA78FbF26c0B4956C first. Because the init code is fixed, only the identical PegHook can occupy that address: nothing malicious can. Operational effect: the deterministic deployer reverts on an occupied address, `_create2Mined` (DeployBase.sol:266-268) reverts HookNotDeployed and the stage-4 broadcast halts; on the resume the mining loop skips the occupied salt (line 264, `a.code.length != 0`) and deploys a second identical hook one salt later, leaving the attacker's copy (and any pool the attacker opened on it: PegHook.beforeInitialize refuses any key or price other than its own, so only a $1 pool) orphaned, at a cost to the attacker of about 3.8M gas. No fund or authority impact; reported because question 4 asks for any front-runnable salt. Mitigation if wanted: adopt a hook already present at the mined address when its runtime matches (`a.code.length != 0 && a.codehash == keccak256(type(PegHook).runtimeCode)` is not usable with immutables; compare the getters checked at StageGenesis.sol:84-87 instead and return it), so a front-run cannot halt the run. Same for the DEX hook (off in the beta). Reported by audit_flow; verified against the mining loop and the deployer's revert-on-failure.

**Reproduction**

Input: after stage 1 is on chain, an outsider computes s = the first salt with (address & ALL_HOOK_MASK) == BEFORE_INITIALIZE | AFTER_SWAP | AFTER_SWAP_RETURNS_DELTA for keccak256(init) and sends CREATE2_DEPLOYER.call(abi.encodePacked(s, init)) ahead of the stage-4 broadcast. Expected (per the resume contract): stage 4 continues with the hook at the mined address. Actual: the script's CREATE2 call returns ok = false, `_create2Mined` reverts HookNotDeployed, the broadcast stops; the resume mines past s and deploys a second hook at s + 1 (DeployBase.sol:264 skips any salt whose address has code).

### 7. Info: Comments and NatSpec in the deploy scripts and config that state properties the code does not have (stage numbering, 'EOA only ever a wirer', idempotence, the edge bootstrap, the freezer refusal, samp

`script/Deploy.s.sol:23`

```
///   5 modules   PegLPVault, LoopVault, the Compound DEX (+ its linked libraries)
```

Each verified against the code. (1) script/Deploy.s.sol:23 lists PegLPVault under stage '5 modules'; it is deployed in stage 4 by StageGenesis.sol:112-132 (with the PEG type by StagePeg) BEFORE `seal()` (StageGenesis.sol:160-164); StageModules deploys only LoopVault and the DEX. StagePeg.sol:14 calls itself 'Stage 5c' and says the vault is 'deployed in 5b'; both run inside stage 4. Deploy.s.sol:22 says '(4b bridge' while Deploy.s.sol:60 calls the same step '5b'; StageModules.sol:25/45 number the earn vaults '6.' and the DEX '7.' inside stage 5. (2) Deploy.s.sol:25-28: 'the deploying EOA is only ever a `wirer` ... Stage 4 ends with `seal()`, after which the EOA can do nothing'. The EOA is also the `deployer` of both Backstop sleeves and of PricerOracle (src/backstop/Backstop.sol:122/203, PricerOracle.sol:14/27: `deployer = msg.sender`), a role that ends with those contracts' own `seal()` calls; after everything is sealed the EOA can still call Backstop.seal() and PricerOracle.seal() again (no one-shot guard, Backstop.sol:259-262, PricerOracle.sol:36-37; a no-op, so no power remains). (3) script/deploy/DeployBase.sol:55-56: 'Every stage function is idempotent: a contract already in `d` with code (and the expected wiring) is kept, anything else is deployed': a recorded contract with code and other wiring is not redeployed, the run reverts Mismatch (DeployBase.sol:155-156), and several adoptions check only part of the wiring (the two resume findings). (4) script/DeployConfig.sol:679 '// the edge bootstrap = the beta's day-7 sIMD target': Backstop.addIlk stamps listedAt at the genesis listing (Backstop.sol:247) and bootstrap() (lines 367-371) falls linearly to 0 over 60 days from that stamp, so when the sIMD line opens on day 7 the allowance is 50,000 x 53/60 = 44,166.67e18, 33,333e18 on day 20 (under the ~33.7k day-7 line test_safePostDeployBatch computes) and 0 on day 60; the README's 60-day bootstrap is the documented design, the comment overstates it. (5) script/DeployConfig.sol:185 '`freezer` = the guardian key (zero refused off a local fork)': nothing refuses zero (the freezer finding). (6) src/ImdUSD.sol:18 '`keeper` is set once by the same timelocked setter' and ImdUSD.sol:95: in this deploy it is set by Parameters.setGenesisKeeper (StageGenesis.sol:104-107, Parameters.sol:63-68) before the seal with no timelock (README line 142 says so correctly). (7) script/DeployConfig.sol:234-238: the 'Ethereum mainnet. Addresses from ...' NatSpec sits on `library DepthMath` (line 239), not on MainnetConfig. (8) script/DeployConfig.sol:380/399: sampler 0 samples IMD_ETH_POOL_ID with feed1 = F_IMD_USD_LIVE = ProductFeed(LivePoolPrice(IMD_ETH_POOL_ID), ETH/USD), i.e. its own pool's price, so DepthSampler's PoolOffReference guard (src/feeds/DepthSampler.sol:31-33, 186-188) can never fire for it and `maxDeviationTicks: 400` is dead configuration for sampler 0 (samplers 1 and 2 are tied to pool 0xb07d, not to an independent price); DepthSampler.sol:27-37 says in capitals never to wire a LivePoolPrice. The README 'Depth griefing' item accepts the consequence, so this is only the documentation contradiction the task asks for. None changes behaviour; (1), (4) and (5) are the ones an operator could act on wrongly. Merged from audit_economics, audit_permissions and audit_math.

**Reproduction**

Read script/Deploy.s.sol:23 against script/deploy/StageGenesis.sol:112-132 and 160-164 (PegLPVault created before `seal()`) and script/deploy/StageModules.sol:26-48 (no PegLPVault). Expected per the header: PegLPVault in stage 5 after the seal. Actual: stage 4, before the seal. For (4): Backstop.bootstrap("SIMD") at listing + 7 days with genesisLine 50,000e18 returns 44,166.67e18, not 50,000e18. For (8): BetaConfig.get().depth.samplers[0].poolId == MainnetConfig.IMD_ETH_POOL_ID and feeds[F_IMD_LIVE].poolId == the same id with feed1 == F_IMD_USD_LIVE.

### 8. Info: Coverage: answers to questions 1-4 and what this review ran (the fork rehearsal test/fork/DeployBeta.t.sol did not complete in the review budget)

`script/Deploy.s.sol:25`

```
/// AUTHORITY. The governance Safe is the `governor` of Parameters and Treasury from construction; the
```

Q1 AUTHORITIES (from the constructors and setters the stages call; the fork rehearsal's test_everyAuthorityIsTheSafe was not rerun here). Governor = cfg.governor (the Safe) at construction for Parameters, Treasury and Allocator; no contract in scope has an owner. One-time deployer powers and their state after the seal: ImdUSD.wire (ledger, migrator, bridgeSetter = Parameters; once), Parameters.wire (ledger, token; once), Treasury.wire / wireNotes (once each), Ledger.wire (redeemer, liquidator; once), PositionNFT.bind (once), Join.wire and SeatJoin.wire (once each), PegCollateral.setZap (once), Parameters genesis setters (addGenesisIlk, setGenesisChainLine, setGenesisMaxLull, setGenesisMigratorLine, setGenesisSavings, setGenesisNotes, setGenesisKeeper, setGenesisDepthRule, setGenesisImdDepth, setGenesisBackstops, setGenesisEdgeIlk; all `wirer` until `seal()`, which is one-shot), Backstop.wire/addIlk/disableIlk/seal and PricerOracle.setIlk/seal (deployer until each contract's own seal; afterwards only Parameters, except a repeated no-op seal()). After stage 4 every one of these is spent or sealed; the deployer keeps no power. Gaps found: the Allocator's freezer is address(0) (finding 2). Q2 CONFIG: the constructor arguments and genesis setters were read against BetaConfig.get(): lines, divisors, chain line 300k, graceOff, migrator 0, flash cap 100k, seats on with empty leaves, lpVault on, loop/DEX off, backstop sleeves. Gaps: F_USDC/F_USDS maxAge (finding 3), freezer unset (finding 2), sampler 0's self-referencing band (info). Q3 RESUME: a recorded contract with code but other wiring stops the run for the core, feeds, joins, hook, notes, vault and depth rule; types, seats, backstop listings, Allocator, samplers and PegShareFeed are adopted on partial checks (findings 1 and 4); nothing adopts a contract on another chain (cfg.chain.chainId is checked at StageCore.sol:22 and every adoption reads the recorded address's code on the current chain). Q4 GAS: see the gas-log finding; no transaction is near 16,777,216 by the specialists' fork measurement, but the beta has no assertion. WHAT RAN: solc 0.8.26 via_ir sparse builds of the scratch proofs (13 min first build, 1-4 min after); `forge test --match-path 'test/scratch/Proof*.t.sol'` for the four files pasted in the findings, all failing as stated; test/fork/DeployBeta.t.sol was NOT run (the budget after the first build did not allow a second multi-minute compile plus the 5-minute fork run); Foundry 1.8.3, so `--use solar` was unavailable. Specialist findings dropped: none outright; the four freezer reports, the four stage-3 resume reports, the two gas-log reports and the comment lists were merged. Coverage rows (external functions in scope): Deploy.run / DeployBeta.run / config / deployAs: holds (reads callers, single run); StageTypes.migratorLineFor: holds (0 when closed; max(floor, 2x v1 supply) otherwise); StageModules.dexPriceFor: unreached (DEX off in the beta); DeployBase.gasLog / pegHookSalt: finding (gas log incomplete). Internal stage functions: _stageCore holds; _stageFeeds holds (every node re-checked by _feedIs); _stageTypes finding; _deploySeats finding; _stageGenesis holds except _stageBackstop/_stageDepth (finding 4); _stagePeg finding 4 (PegShareFeed); _create2Mined info; _deployHook holds.

**Reproduction**

Not a defect: a coverage record for the judge. See each finding for its reproduction.

---

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