{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"a69e204e-d047-4c2f-9944-f37a69dd2796","kind":"audit","nodes":[{"acceptedSubmissionHash":"4ff5c4b9c6ffb1405272582aa974b073a4f8b05f5b6fb24a70366ce5543546ae","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"bf81619db7ffc5843821d2c8d74df00467af8b500ead0273e53c8aaaf11479df","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"327584de1412adb7ad57f9ba72560e5542ae3cadf2b62e0d48e0a9546af153a2","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"02b9f940c870965a1ebd67ae53cdf0969dd354d77e1f3081985059e0f21e6f51","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"e6ef647ba98f5d16d11a508e5efad82c45b0347fe67a0a939e4f88ba0f2aec51","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the whole system: src/, script/DeployMainnet.s.sol, script/DeployPreflight.sol and deploy/mainnet/, at the pinned commit, for a mainnet launch. Fourteen audit rounds and their fixes are in (docs/AUDIT-*.md; the chain is web/content/docs/reference/audit-history.md). This is the LAST review before mainnet: a panel over the whole system at the commit that will deploy. Since the previous whole-system sweep (6085c8a, docs/AUDIT-FINAL-SWEEP-PANEL-2026-10-08.md) the vault's backing design was replaced by three paced figures (CDPVault._pace; docs/AUDIT-LAUNCH-VAULT-PANEL-2026-10-08.md and docs/AUDIT-PACED-VAULT-PANEL-2026-10-08.md, whose Resolution sections say how each finding was answered), the deploy script reads the vault's CREATE2 salt from the environment and refuses a second vault (docs/AUDIT-DELTA-PANEL-2026-10-08.md), and nothing else in src/ changed but comments: git diff 6085c8a a3aa9e4 -- src script deploy. Read the newest vault lines first (git diff d3861ac a3aa9e4 -- src: resecure, _clampPacedDebt after every cancellation, _backingPacedAt, the seeded paced supply), then everything that crosses subsystem lines. A finding of an earlier round counts only if its fix regressed or left a gap. Items ACCEPTED with their reasons stated where they live are findings only if the reason is wrong or the stated bound does not hold: the dip and the stale-term read (the paced figures' NatSpec), the redemption-fee floor, the work ceiling as an aggregate once the wage is on, the oracle's walk cost (docs/PARAMETERS-2026-10-05.md). Rank severity by what a finding lets someone take or block with the constants as committed (LINE $1M, wage 0, mat 170 at NHI >= 0.85, BACKING_RISE_PER_HOUR 2 points of par, FOLLOW_BPS_PER_HOUR 10%, PACE_INTERVAL 1 hour, fee floor 100,000 imdUSD).\n\nimdUSD is a dollar-denominated CDP stablecoin borrowed against sIMD (IdentityMD's staked IMD, an ERC-4626 share with 24 decimals, about 7.95 IMD each). Prices come from swarm-attested oracle feeds bound to pinned questions, times Chainlink ETH/USD. Everything about the deployment is in src/DeploymentConfig.sol and docs/MAINNET-RUNBOOK.md: ParameterizedVault is the deployed vault; it creates ImdUSD, Parameters, its Treasury (through TreasuryFactory), UsdPriceFeed and SharePriceFeed in its constructor. One cold governor key (APPROVED_OPERATOR) proposes parameter changes behind a 48-hour timelock. Collateral pricing is per 1e18 raw units throughout. IMD's only market is a full-range Uniswap v4 pool, about $2.3M a side with a 1% fee; docs/PARAMETERS-2026-10-05.md has the numbers every economic parameter was chosen from.\n\nAnswer each numbered question, including the ones where nothing is wrong:\n1. THE VAULT'S NEWEST LINES (git diff d3861ac a3aa9e4 -- src). resecure(owner): anyone may re-price any position's term at a fresh, agreed price. Can it be used to lift the live figure or the paced backing past the honest one, to lower another borrower's term below the honest one, to change a position's eligibility, grace, health or mark, or to grief (gas, ordering, a flood of calls)? _clampPacedDebt after cash, bite and cover: any ordering of draw and cancellation, in one transaction or across a block boundary, that leaves zero-second debt counting? The backing's own clock and the seeded paced supply: any way to bank a rise or to reset the fee base.\n2. THE PACED FIGURES ACROSS THE SYSTEM. With the Treasury (fundOracle unwrapping sIMD, redeemIMD, cover burning Treasury imdUSD, withdraw's bad-debt-first floor, donations), Parameters (a mat, gap, divisor, wage or earnMat change applied mid-flight), the feeds (a first value, a widened epoch's first value, a stale window, a Chainlink outage) and SwarmRelay (relayMany, relayAndBark, relayAndBite bundling a feed update with a liquidation or a pace): any sequence that pays a redemption more than the honest backing plus the rise the elapsed time allows, mints work against debt not held for the follow rate, moves the fee base faster than the follow rate, or desynchronises a record.\n3. THE LAUNCH WINDOW, hour by hour for the first day, with docs/MAINNET-RUNBOOK.md section 7 against the code: stage one, the first values and verifySeeded, stage two with VAULT_SALT through a private relay, listing sIMD, the keeper (ETH, imdUSD inventory to bite, IMD for the oracle fallback; pace() hourly; resecure after each update), the first draws, the first redemptions (the fee floor, the seeded paced supply), the first price fall and liquidation (grace, tail, the dust rules, bad debt and its covering with no fees yet accrued), and every way the protocol can halt in that day and how each recovers.\n4. THE ORACLE AS AN ATTACK SURFACE ON THE VAULT, with the pool as it is (about $2.0M a side, 1% fee, checked 2026-10-06): the cheapest profitable manipulation of collateral prices (over-borrowing, then a redemption or liquidation) or of NHI (mat and grace) in money and hours at LINE $1M, now that redemptions are paced; whether pacing or resecure gives a manipulated print a longer or shorter reach than before.\n5. GOVERNANCE AND THE TREASURY for regressions only (unchanged since 6085c8a): the timelock and bounds, every exit from the Treasury bounded as documented, bad debt first, the reserve valuation, the work oracle's replacement rules, the factories.\n6. THE DEPLOYMENT: DeployMainnet.run, verifySeeded, runVault (VAULT_SALT, PUBLIC_VAULT_SALT refused, _refuseAnotherVault, record before verify), verify, plan.py and the pinned bodies, the gas and EIP-7825 checks; what can still be deployed wrong and pass, and what a stranger can do before, between and during the stages. Contract size: ParameterizedVault initcode 47,089 of 49,152 bytes.\n7. Every comment, NatSpec or runbook line in scope that claims a property the code does not have, and the list of what you read in full and what you could not reach.\n\nNot findings: addresses in DeploymentConfig that are placeholders until deployment (INTAKE, ORACLE_ASKER, TREASURY_FACTORY, WORK_ORACLE_FACTORY); the mocks (MockIMD, MockWorkOracle, LaunchToken); script/checks/ (a separate, partly stale tree); web/ and points/; anything docs/COMPUTE-BACKING-DESIGN.md describes as future work; and findings of the earlier audits in docs/AUDIT-*.md and docs/INTERNAL-AUDIT-2026-10-04.md, unless the fix regressed. A constant set to a deliberate economic value is not a finding; an arithmetic or ordering error in how it is used is.\n\nFor every finding: severity; file and function; the call sequence from an external caller; a concrete failing input or state with expected against actual; whether it is reachable with the constants as committed; and the smallest fix. Also report every place a comment or NatSpec claims a property the code does not have, and say which contracts you read in full and which you could not reach.","parentJobId":null,"planHash":"38730557cecdeafc9be461fc8f0ceb3b5f969b93302440685e4c8eb52b48e354","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a69e204e-d047-4c2f-9944-f37a69dd2796","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51876","feedbackHash":"00092eadc3060c33a7a44a752bad307059c19f98834051e2c03bd56bfa158dd9","nodeKey":"audit_economics","submissionHash":"4ff5c4b9c6ffb1405272582aa974b073a4f8b05f5b6fb24a70366ce5543546ae","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52220","feedbackHash":"41d92932e279de9186874f556ca1b15b9e12f3a525039d548263056415a078f7","nodeKey":"audit_flow","submissionHash":"bf81619db7ffc5843821d2c8d74df00467af8b500ead0273e53c8aaaf11479df","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52215","feedbackHash":"947c6b5d0757e6872dc8b4b5969523e50b9e517c433c6d3719fa94587069f133","nodeKey":"audit_judge","submissionHash":"327584de1412adb7ad57f9ba72560e5542ae3cadf2b62e0d48e0a9546af153a2","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51428","feedbackHash":"e23594388e0f24c5d59d23cded95d84b52c6489f3a4b3a2afaaec2cb8d255a8e","nodeKey":"audit_math","submissionHash":"02b9f940c870965a1ebd67ae53cdf0969dd354d77e1f3081985059e0f21e6f51","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50958","feedbackHash":"865dcdd3f85da294f1a659c8e12f0f1803cfca483be2f19d4d2c30e41a16d30c","nodeKey":"audit_permissions","submissionHash":"e6ef647ba98f5d16d11a508e5efad82c45b0347fe67a0a939e4f88ba0f2aec51","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"711699a3a7b2bbdc0a5f0379a66381ed8b3dd2b6d69c7bcfc36a69fd92d67e5f","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bc1e515ca5c78ac4","findings":[{"citation":"resolved","description":"CDPVault._clampPacedDebt (called after cash, bite and cover) clamps _debtPaced to totalDebt + WIPED - MINTED, where MINTED is only THIS transaction's draws. When the draw happened in an earlier transaction (any time within the follow window, including the previous block), the new debt sits in totalDebt above _debtPaced, and a cancellation of HELD debt (another borrower's principal retired by a redemption, a bite or cover) only reduces that un-paced excess: the clamp sees live >= _debtPaced and does nothing. The paced debt therefore keeps counting the cancelled, held debt, which is now one-block-old debt someone else drew. The NatSpec at lines 310-313 and 899-904 ('DEBT falls at once', 'debt cancelled by a redemption or a liquidation and drawn again by someone else backs nothing until it has been held', 'in either order') claims the cross-transaction case is covered; it is not. The same happens with a plain wipe by the other borrower in the later transaction (no clamp at all), so in normal operation the paced debt stops falling at once whenever draws have outrun the follow rate. Reachability with the committed constants: the paced debt only feeds ParameterizedVault.backedDebt and so earnLine; with WAGE_WAD = 0 earn is refused, so nothing can be minted against it at launch. It becomes live the moment governance proposes a nonzero wage (48h timelock), which is the design's stated path. It is a gap in the fix for the sweep panel 2026-10-07 HIGH ('cancel another borrower's debt and draw as much in one call'): the one-call version is closed, the two-transaction version (draw first, then cancel, e.g. in the next block) is open and costs the attacker only the redemption fee on the cancelled amount. Smallest fix: make every cancellation lower the paced figure by the principal it retired, saturating, in addition to the clamp: in _clampPacedDebt take a `cancelled` argument (principalCancelled from cash, principalPaid from bite/cover via _reduceDebt) and do `_debtPaced = _debtPaced > cancelled ? _debtPaced - cancelled : 0;` before the existing min. This over-tightens only when the cancelled debt was itself un-paced, which is the safe direction. If wipe by another position is to be covered too, apply the same subtraction in wipe and drop the WIPED add-back except for the same-position same-transaction redraw.","line":908,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// The paced debt does not fall when held debt is cancelled while un-paced new debt is outstanding.\n// Sequence: B holds 100k long enough to be fully paced; A draws 100k in one transaction (paced stays\n// 100k, the draw is excluded); in the NEXT transaction A redeems 50k against B. B's held debt falls to\n// 50k, A's zero-second debt is still 100k, yet the paced debt stays 100k: the cancelled 50k of held\n// debt has silently been replaced by 50k of A's one-block-old debt in the work ceiling's figure.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract PdFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        set(v);\n    }\n\n    function set(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract PdAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract PacedDebtDrawThenCancelTest is Test {\n    address private constant B = address(0xB0B);\n    address private constant A = address(0xA11CE);\n    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    PdFeed private primary;\n    PdFeed private health;\n    PdFeed private spot;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new PdAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new PdFeed(DOLLAR);\n        health = new PdFeed(0.85 ether);\n        spot = new PdFeed(DOLLAR);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        stable = vault.stablecoin();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(B, 1_000_000 ether);\n        imd.mint(A, 1_000_000 ether);\n        vm.stopPrank();\n        vm.prank(B);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(A);\n        imd.approve(address(vault), type(uint256).max);\n    }\n\n    function _next(uint256 seconds_) private {\n        vm.roll(block.number + 1 + seconds_ / 12);\n        vm.warp(block.timestamp + seconds_);\n        primary.set(DOLLAR);\n        spot.set(DOLLAR);\n        health.set(0.85 ether);\n    }\n\n    function test_cancellingHeldDebtAfterAnUnpacedDrawLowersThePacedDebt() public {\n        // B: 100k of debt at 200% (eligible for redemption below mat + gap = 220%), held until fully paced.\n        vm.startPrank(B);\n        vault.lock(200_000 ether);\n        vault.draw(100_000 ether);\n        vm.stopPrank();\n        for (uint256 i; i < 14; ++i) {\n            _next(1 hours);\n            vault.pace();\n        }\n        (,, uint256 pacedDebt,,) = vault.paced();\n        assertEq(pacedDebt, 100_000 ether, \"B's debt is fully paced\");\n\n        // Transaction 1: A draws 100k. The draw is excluded, so the paced debt stays 100k.\n        _next(12);\n        vm.startPrank(A);\n        vault.lock(300_000 ether);\n        vault.draw(100_000 ether);\n        vm.stopPrank();\n        (,, pacedDebt,,) = vault.paced();\n        assertEq(pacedDebt, 100_000 ether, \"A's draw does not count yet\");\n\n        // Transaction 2, the next block: A redeems 50k against B, cancelling 50k of B's HELD debt.\n        _next(12);\n        vm.prank(A);\n        vault.cash(50_000 ether, 0, B);\n        // Fees were paid first, so a few imdUSD of the burn went to B's accrued fee rather than principal.\n        assertApproxEqAbs(vault.totalDebt(), 150_000 ether, 50 ether, \"B 50k + A 100k\");\n\n        // The debt that has been held is now B's 50k. A's 100k is one block old. The work ceiling's debt\n        // (backedDebt, read in a fresh transaction) must be no more than B's remainder plus one block's step\n        // (10% of 100k per hour, 12 seconds of it: about 33 imdUSD).\n        _next(12);\n        assertLe(vault.backedDebt(), 50_000 ether + 100 ether, \"paced debt should have fallen with the cancellation\");\n    }\n}","reproduction":"State: B locks 200k IMD and draws 100k imdUSD; 14 hourly pace() calls so the paced debt equals 100k. Tx1 (next block): A locks 300k, draws 100k. paced() debt = 100k (A's draw excluded, as designed). Tx2 (next block): A calls cash(50_000e18, 0, B), cancelling ~50k of B's HELD principal. totalDebt ~150k. Tx3 (next block): read backedDebt(). Expected: B's remaining held debt plus one block's step, about 50,033e18. Actual: 100,066e18 (the paced debt never fell; A's one-block-old 50k is counted as held). Proof: test/scratch/PacedDebtDrawThenCancel.t.sol, which fails on this code with 'paced debt should have fallen with the cancellation: 100066677777777777777777 > 50100000000000000000000'.","severity":"medium","snippet":"        if (live < _debtPaced) _debtPaced = live;","title":"Paced debt does not fall when held debt is cancelled while un-paced new debt is outstanding (draw in one tx, cancel in the next)"}],"hash":"02b9f940c870965a1ebd67ae53cdf0969dd354d77e1f3081985059e0f21e6f51","nodeId":"7c26bc57-ae33-47d4-a93c-5ece0cc7dffe","outcome":"completed","summary":"The review is complete. The findings file holds one substantiated finding with a failing Foundry proof; one further candidate was withdrawn after its own test contradicted it.\n\n## Findings\n\n**Medium. The paced debt does not fall when held debt is cancelled while un-paced new debt is outstanding.** `src/CDPVault.sol:908` in `_clampPacedDebt`. The clamp compares the stored paced debt with total debt less this transaction's own mints. When a draw landed in an earlier transaction (even the previous block), that new debt sits above the paced figure, and a later redemption, bite or cover of another borrower's held debt only eats into that un-paced excess. The paced debt never falls, so the cancelled held debt is silently replaced by one-block-old debt in the work ceiling. Proof: B holds 100k fully paced, A draws 100k in one block, A redeems 50k against B in the next. Expected backed debt about 50,033; actual 100,066. The NatSpec at lines 310 to 313 and 899 to 904, and ParameterizedVault lines 238 to 240, claim the \"either order\" case is closed; it is closed only inside one transaction. With wage 0 nothing can be minted against it, so it takes nothing at launch; it becomes live the moment governance sets a wage. The smallest fix, verified against the proof, is to subtract the cancelled principal from the paced debt before the existing clamp. The proof fails on the committed code and passes with that change.\n\n## Answers to the numbered questions\n\n1. **Newest lines.** `resecure` only re-prices a term at a fresh, agreed price, tallies increases per transaction, touches no mark, grace or health, and cannot lower a term below the honest one. The backing's own clock caps the rise at one interval across stale windows. The seeded supply only resets when the live supply is exactly zero, which matches its NatSpec. The clamp has the gap above.\n2. **Across the system.** Treasury exits (redeemIMD vault-only, withdraw blocks gem and keeps the bad-debt floor, fundOracle bounded by the daily budget, cover burns through the same path as wipe) all reduce the live figure at once and lift it only at the rise rate. Parameters changes mid-flight touch no paced storage, and `apply` drips before a duty change. Bundled relay-and-act calls cannot skip the pacing: the payout is always min(live now, stored ceiling).\n3. **Launch window.** Stage two is refused without a reference price and a secret salt. Redemption on day one needs an eligible candidate below 220% until the Treasury holds sIMD. Cover is impossible until imdUSD lands in the Treasury, which the runbook answers with a donation. Halts: price or spot stale after one hour, NHI after a day, Chainlink after two hours, divergence over 5%, a share vault that stops answering. Each recovers with a fresh attestation or the leg returning.\n4. **Oracle surface.** Pacing shortens the reach of a pumped print on redemptions and lengthens the reach of a dumped print (hours of underpayment), which is the accepted dip. I did not recompute the walk cost in docs/PARAMETERS-2026-10-05.md.\n5. **Governance and Treasury.** No regression found: timelock and bounds, bad-debt-first, reserve valuation guards, the factories and the work-oracle replacement rule are as documented.\n6. **Deployment.** The constants, refusals and record-before-verify order match the runbook. CHAINLINK_ETH_USD still holds the Sepolia address in source, but the broadcast refuses until plan.py converges it, so it cannot ship wrong.\n7. **Claims the code does not have.** The three NatSpec passages named in the finding.\n\n## Coverage\n\nRead in full: CDPVault, ParameterizedVault, Treasury, SwarmFeed, SwarmRelay, UsdPriceFeed, SharePriceFeed, the factories, DeploymentConfig, DeployMainnet, and runbook section 7. Read partially: Parameters (bounds and apply only), Governed (grep only). Not reached: OracleAsker, SwarmWorkOracle, Registry, ImdUSD, DeployPreflight, plan.py and the pinned bodies, and the walk-cost numbers.","treeHash":null,"usage":{"cachedInputTokens":4343312,"inputTokens":834,"model":"claude-fable-5-1","outputTokens":56565,"runtime":"claude","turns":34,"wallClockMs":853057}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e2a4a53638df3fc6","findings":[{"citation":"resolved","description":"CDPVault.cash pays gemOut = amount x min(backing,1) x (1-fee) / price. On a book with slack above par the paced backing is 1e18 and does not bind, so a redemption's IMD per imdUSD follows the attested price directly. SwarmFeed accepts a single step of 20% from a fresh anchor (FEED_MAX_DEVIATION_BPS 2000; 40% after two silent hours), and the primary (13-sample 2h median) and the spot (last block) both read IMD's one Uniswap v4 pool, so pushing the pool moves both and SKEW_BPS sees agreement. Sequence: hold imdUSD (drawn earlier at the honest price); sell ~12% of the pool's IMD side (~$240k against ~$2.0M) to drop the price 20% and hold it ~65 minutes so 7 of 13 median samples sit low; relay primary and spot (askPaid, or the Treasury's own 5% fall trigger buys it); cash(amount,0,candidate) against every in-band candidate (at -20% every position under ~275% honest CR is in band) and the reserve; buy IMD back. Gain 1.25 x 0.95 - 1 = 18.75% of redeemed volume (58% after two hours of feed silence), taken from candidates' collateral and the Treasury's sIMD; cost ~$5k pool fees plus whatever dip-buyers absorb during the hold, ~$9 attestations. With LINE $1M the takeable amount is up to ~$187k at fee cap. The accepted 'oracle walk cost' (docs/PARAMETERS-2026-10-05.md) prices only the over-borrowing walk (x1.7, several hourly steps, ~$40k); this route needs one step inside the committed allowance. Pacing and resecure bound backing per unit, not price per IMD, so they do not shorten its reach. Smallest fix: (a) floor the redemption fee at the primary feed's fall from its current epoch anchor (feeBps >= (anchor - price) x 10000 / anchor), or (b) pace the payout price (pay at max(price, pacedPrice) with pacedPrice falling at a bounded fraction per hour). Either makes the proof pass; both underpay redeemers for up to an hour after an honest fall, the conservative direction.","line":742,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// A redemption is paid IMD at the feed's price with the backing capped at par, so a one-step feed fall within\n// the committed per-epoch allowance (20% on a fresh feed, 40% after two hours of silence) lets a redeemer take\n// 1 / (1 - step) IMD per imdUSD from the reserve and from any candidate, while the redemption fee is capped at\n// 5%. On a par book the paced backing does not bind (B = 1), so pacing does not slow it. The property asserted:\n// after a 20% feed fall, one redemption does not pay more IMD, valued at the pre-fall price, than the imdUSD it\n// burned. Fails on a3aa9e4: 50,000 imdUSD takes 59,375 IMD (worth $59,375 at the pre-fall price) out of the\n// candidate's collateral, and the candidate's debt falls by only 50,000.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Math} from \"@openzeppelin/contracts/utils/math/Math.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract OsFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 hours;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        set(v);\n    }\n\n    function set(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external view returns (bool) {\n        return block.timestamp - updatedAt > maxAge;\n    }\n}\n\ncontract OsAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract OneStepFallRedemptionTest is Test {\n    address private constant BOOK = address(0xB00C);\n    address private constant HOLDER = address(0x401D);\n    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    OsFeed private primary;\n    OsFeed private health;\n    OsFeed private spot;\n    uint256 private imdEth = DOLLAR;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new OsAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new OsFeed(DOLLAR);\n        health = new OsFeed(0.85 ether); // mat 170, gap 50: a position at 200% is a candidate\n        spot = new OsFeed(DOLLAR);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        stable = vault.stablecoin();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BOOK, 200_000 ether);\n        imd.mint(HOLDER, 300_000 ether);\n        vm.stopPrank();\n        vm.startPrank(BOOK);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(199_000 ether);\n        vault.draw(99_500 ether); // 200%: the candidate\n        vm.stopPrank();\n        vm.startPrank(HOLDER);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(300_000 ether);\n        vault.draw(100_000 ether); // the redeemer's imdUSD, held for a day\n        vm.stopPrank();\n        for (uint256 i; i < 24; ++i) _hour();\n        assertEq(vault.backingPerUnit(), 1e18, \"a par book\");\n    }\n\n    function _next(uint256 seconds_) private {\n        vm.warp(block.timestamp + seconds_);\n        vm.roll(block.number + 1 + seconds_ / 12);\n        primary.set(imdEth);\n        spot.set(imdEth);\n        health.set(0.85 ether);\n    }\n\n    function _hour() private {\n        _next(1 hours);\n        vault.pace();\n    }\n\n    /// @dev One step the feeds accept on a fresh epoch (SwarmFeed.maxDeviationBps 2000): primary and spot both\n    /// 20% below the price of a moment ago, within SKEW_BPS of each other. The next block, a holder redeems.\n    function test_oneStepFallDoesNotPayMoreThanTheImdUSDBurnedAtThePreFallPrice() public {\n        uint256 preFall = DOLLAR;\n        imdEth = DOLLAR * 80 / 100;\n        _next(12);\n        (uint256 price,) = vault.collateralPriceFeed().latestValue();\n        assertEq(price, 0.8 ether, \"the vault prices at the attested low\");\n        assertEq(vault.backingPerUnit(), 1e18, \"the par book stays at par through a 20% fall: pacing does not bind\");\n        uint256 collateralBefore = vault.securedCollateral();\n        (uint256 bookCollateralBefore, uint256 bookDebtBefore) = vault.positions(BOOK);\n        vm.prank(HOLDER);\n        uint256 gemOut = vault.cash(50_000 ether, 0, BOOK);\n        (uint256 bookCollateralAfter, uint256 bookDebtAfter) = vault.positions(BOOK);\n        // The candidate funded all of it (no reserve), and its debt fell by exactly what was burned.\n        assertEq(bookCollateralBefore - bookCollateralAfter, gemOut, \"paid from the candidate\");\n        assertEq(bookDebtBefore - bookDebtAfter, 50_000 ether, \"fifty thousand of debt cancelled\");\n        collateralBefore; // silence\n        // EXPECTED: 50,000 imdUSD takes at most 50,000 IMD at the pre-fall price (the fee is a brake, not a\n        // premium). ACTUAL on a3aa9e4: 50,000 x 0.95 / 0.80 = 59,375 IMD, worth 59,375 at the pre-fall price.\n        uint256 valueAtPreFall = Math.mulDiv(gemOut, preFall * 2000, 1e18); // DOLLAR x 2000 = $1 per IMD\n        assertLe(valueAtPreFall, 50_000 ether, \"a one-step feed fall pays the redeemer more than it burned\");\n    }\n}","reproduction":"Proof below (test/scratch/OneStepFallRedemption.t.sol): ParameterizedVault over MockIMD at $1, NHI 0.85 (mat 170). BOOK locks 199,000/draws 99,500 (200%, candidate); HOLDER locks 300,000/draws 100,000; 24 hourly pacings; backingPerUnit()==1e18. Both feeds set to 0.80x price; next block HOLDER cash(50_000e18,0,BOOK). Expected <= 50,000 IMD (pre-fall price). Actual gemOut 59,375e18 (50,000 x 0.95 / 0.80), all from BOOK's collateral; BOOK loses $9,375 at the pre-fall price. Run on a3aa9e4: fails with 'a one-step feed fall pays the redeemer more than it burned: 59375000000000000000000 > 50000000000000000000000'. Reachable with committed constants; real-world feasibility depends on how much dip-buying the attacker must absorb during the 65-minute hold.","severity":"high","snippet":"        uint256 payoutScale = Math.mulDiv(_backingPerUnit(price), 10_000 - feeBps, 10_000);","title":"cash pays IMD at a one-step manipulated feed low: a 20% pool push held ~65 min takes ~18.75% of in-band debt from candidates (fall/redemption route missing from the accepted walk analysis)"},{"citation":"resolved","description":"Merged from three specialist reports (d6e63b8b, db768696, f3224fb3): one root cause. _clampPacedDebt (after draw, cash, bite, cover) clamps _debtPaced to totalDebt + WIPED_THIS_TX - MINTED_THIS_TX. The tallies are transient, so (1) debt drawn in an EARLIER transaction is invisible: tx1 lock+draw(X) leaves paced at T; tx2 (next block) cash(X,0,victim) cancels X of seasoned debt, live = T, no clamp; the churner's one-block-old X now counts in full for ParameterizedVault.backedDebt, contrary to NatSpec at CDPVault 310-313 / 899-904 and ParameterizedVault 237-240 / 262-264 ('debt cancelled by a redemption or liquidation and drawn again by someone else backs nothing until it has been held'). A plain wipe by another borrower behaves the same way. (2) In the other direction, cancelling the transaction's OWN fresh draw nets MINTED out of a total that no longer contains it: lock(200k)+draw(100k)+cash(100k,0,self) in one call writes paced debt 0 though the seasoned book (99,500) is untouched; it then climbs ~10,000/hour, so earnLine falls to the reserve term (a gas-priced, repeatable denial of the work channel). The only consumer is the work ceiling and WAGE_WAD is 0 at launch, so nothing is takeable or blockable with the constants as committed (hence low); once a wage is set behind the 48h timelock, (1) is the sweep-panel high's round trip at one-block holding time (25% of cancelled seasoned debt minted as work against debt unwound next block) and (2) is a denial of earn. Smallest fix: record the paced debt and totalDebt as the transaction found them (ParameterizedVault already records debtAtTxStart in _debtChanged) and clamp to pacedAtTxStart - cancelledPreexisting, where cancelledPreexisting = debtAtTxStart - (totalDebt + WIPED - MINTED) saturated, with a per-position transient 'minted this tx' tally netted out of principal cancelled by cash/bite/cover so a tx cancelling its own fresh draw moves nothing.","line":907,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// The paced debt's clamp after a cancellation (CDPVault._clampPacedDebt, added in c1ecb05 for the paced vault\n// panel's medium #1) measures \"the debt this transaction began with less what it has cancelled\" from totalDebt\n// and the transaction's own transient tallies. A draw in ONE transaction and the cancellation of another\n// position's seasoned debt in the NEXT (same block or the next) leaves the paced debt at the book's total: the\n// cancelled seasoned debt is replaced, in the counted figure, by debt drawn a transaction earlier, which the\n// NatSpec (CDPVault 310-313, ParameterizedVault 237-240, 261-264) says backs nothing until it has been held.\n// Fails on a3aa9e4: after lock+draw(20,000) and, in the next block, cash(20,000, 0, BOOK), backedDebt() reads\n// 99,500 where the seasoned book is 79,500 and the churner's 20,000 is twelve seconds old.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract DcFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        set(v);\n    }\n\n    function set(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract DcAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract DrawThenCancelAcrossTransactionsTest is Test {\n    address private constant BOOK = address(0xB00C);\n    address private constant CHURNER = address(0xC4A1);\n    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    DcFeed private primary;\n    DcFeed private health;\n    DcFeed private spot;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new DcAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new DcFeed(DOLLAR);\n        health = new DcFeed(0.85 ether); // mat 170, gap 50: the book at 200% is a candidate\n        spot = new DcFeed(DOLLAR);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BOOK, 200_000 ether);\n        imd.mint(CHURNER, 40_000 ether);\n        vm.stopPrank();\n        vm.startPrank(BOOK);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(199_000 ether);\n        vault.draw(99_500 ether); // 200%: eligible for redemption, and the only debt on the book\n        vm.stopPrank();\n        vm.prank(CHURNER);\n        imd.approve(address(vault), type(uint256).max);\n        // A day of hourly pacing: the paced debt catches up with the book.\n        for (uint256 i; i < 24; ++i) {\n            vm.warp(block.timestamp + 1 hours);\n            vm.roll(block.number + 300);\n            primary.set(DOLLAR);\n            spot.set(DOLLAR);\n            health.set(0.85 ether);\n            vault.pace();\n        }\n        assertEq(vault.backedDebt(), 99_500 ether, \"the book counts in full after a day\");\n    }\n\n    function _next() private {\n        vm.warp(block.timestamp + 12);\n        vm.roll(block.number + 1);\n    }\n\n    function test_drawThenCancelInTheNextTransactionCountsZeroSecondDebt() public {\n        // Transaction 1: the churner locks and draws 20,000 (its debt is not yet counted: paced stays 99,500).\n        vm.startPrank(CHURNER);\n        vault.lock(40_000 ether);\n        vault.draw(20_000 ether);\n        vm.stopPrank();\n        (,, uint256 pacedAfterDraw,,) = vault.paced();\n        assertEq(pacedAfterDraw, 99_500 ether, \"the fresh draw is not counted\");\n        // Transaction 2, the next block: redeem the drawn 20,000 against the seasoned book.\n        _next();\n        vm.prank(CHURNER);\n        vault.cash(20_000 ether, 0, BOOK);\n        // The next block: the book's total is back at 99,500, of which 20,000 is twelve seconds old.\n        _next();\n        // A day's stability fees (about 12 imdUSD) were cancelled first, so the principal is a few imdUSD above.\n        assertApproxEqAbs(vault.totalDebt(), 99_500 ether, 20 ether);\n        (, uint256 bookDebt) = vault.positions(BOOK);\n        assertApproxEqAbs(bookDebt, 79_500 ether, 20 ether, \"the book lost 20,000 of seasoned debt\");\n        // EXPECTED per the NatSpec: at most the seasoned 79,500 plus two blocks of the follow step (about 67).\n        // ACTUAL on a3aa9e4: 99,500: the churner's zero-second debt counts in full for the work ceiling.\n        assertLe(vault.backedDebt(), 79_600 ether, \"cancelled seasoned debt replaced by zero-second debt counts in full\");\n    }\n}","reproduction":"(1) Proof below: BOOK 199,000/99,500 at 200%, 24 hourly pacings (backedDebt 99,500e18). CHURNER lock(40,000)+draw(20,000) (paced stays 99,500); next block cash(20,000,0,BOOK); next block backedDebt(). Expected <= 79,600e18; actual 99,512,105,242,694,063,896,500 wei (fails on a3aa9e4). Same result from the independent proof db768696 (100,066e18 > 50,100e18). (2) Same fixture; a contract with 200,000 IMD runs approve; lock(200_000e18); draw(100_000e18); cash(100_000e18,0,address(this)) in one tx. Next block: totalDebt 99,500e18, paced().debt 0, backedDebt() 33,333,333,333,333,333,333 wei. Expected ~99,500e18. Reproduced in test/scratch/SelfRedeem.t.sol.","severity":"low","snippet":"        uint256 live = _debtForPacing();","title":"_clampPacedDebt mismeasures cancellations: a draw in one tx and a cancellation of seasoned debt in the next counts zero-second debt in full; a self-redemption of the tx's own fresh draw zeroes the pac"},{"citation":"resolved","description":"Merged from a57a6cb5 and dcc57c32. _pacedSupply returns the live supply while _supplyPaced == 0. The first borrower's own pacing sees supply 0, so the next capital-moving transaction by anyone writes that borrower's whole draw into the fee base with no follow-rate limit. (a) Large draw held one block: WHALE lock(1.6M)+draw(900k), BOOK draws 100k next block (seeds 900k), WHALE wipes and frees the block after: the fee base stays ~900k and decays only 10%/hour, so redemption fees are diluted for about a day (a 50,000 redemption pays 328 bps instead of 500), against the NatSpec 'principal drawn for a block cannot dilute the fee' (CDPVault 307-309) and the seed's comment 'there is no earlier base for a draw to dilute'. Redeemed-against borrowers lose the fee difference. (b) Small first draw (lock(10)/draw(1), by a front-runner or an honest test draw): the seed is ~1 and the base sits at the 100,000 floor for the first day while the live supply is several times it, exactly the state paced vault panel #5 marked 'Fixed' (redemptionFeeBps(5,000) 300 instead of 100 with a 500,000 book). The same reset recurs whenever the live supply returns to zero. Cost: gas plus one block of collateral. Smallest fix: seed no higher than the floor (`if (paced == 0) return Math.min(live, _feeBaseFloor());`), which makes (a) impossible and errs high on fees on day one (the borrower-protective direction), and restate panel #5 as accepted; or have the operator make the first draw the launch book in the deployment block sequence.","line":829,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// CDPVault._pacedSupply seeds a zero paced supply with the whole live supply at once. At launch the first borrower\n// draws (the pacing at the start of its own transaction sees zero), and the NEXT capital-moving transaction by anyone\n// writes that draw into the fee base in full. Repaid a block later, it keeps the fee base inflated for about a day,\n// falling only at FOLLOW_BPS_PER_HOUR: principal drawn for a block dilutes every redemption fee in that window.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract SeedFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        set(v);\n    }\n\n    function set(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract SeedAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract SeededFeeBaseTest is Test {\n    address private constant WHALE = address(0xA11CE);\n    address private constant BOOK = address(0xB00C);\n    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    SeedFeed private primary;\n    SeedFeed private health;\n    SeedFeed private spot;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new SeedAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new SeedFeed(DOLLAR);\n        health = new SeedFeed(0.85 ether);\n        spot = new SeedFeed(DOLLAR);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        stable = vault.stablecoin();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(WHALE, 2_000_000 ether);\n        imd.mint(BOOK, 220_000 ether);\n        vm.stopPrank();\n        vm.prank(WHALE);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(BOOK);\n        imd.approve(address(vault), type(uint256).max);\n    }\n\n    function _next() private {\n        vm.roll(block.number + 1);\n        vm.warp(block.timestamp + 12);\n        primary.set(DOLLAR);\n        spot.set(DOLLAR);\n        health.set(0.85 ether);\n    }\n\n    function _bookBorrows() private {\n        // An honest book of 100,000 imdUSD at 200%: inside the redeemable band (mat 170 + gap 50).\n        vm.startPrank(BOOK);\n        vault.lock(200_000 ether);\n        vault.draw(100_000 ether);\n        vm.stopPrank();\n    }\n\n    /// Control: the same honest book with no block-long draw in front of it. 50,000 burned against a 100,000 base\n    /// (the floor) stores the cap: 50 + 450 = 500 bps.\n    function test_controlFeeAtLaunch() public {\n        _bookBorrows();\n        _next();\n        vault.pace();\n        _next();\n        assertEq(vault.redemptionFeeBps(50_000 ether), 500);\n    }\n\n    function test_blockLongDrawAtLaunchDilutesTheFeeBase() public {\n        // Block 1: the first borrower draws 900,000 (LINE is 1,000,000). Its own pacing sees a zero supply.\n        vm.startPrank(WHALE);\n        vault.lock(1_600_000 ether);\n        vault.draw(900_000 ether);\n        vm.stopPrank();\n        // Block 2: the honest book borrows; its pacing seeds the paced supply with the whole 900,000.\n        _next();\n        _bookBorrows();\n        // Block 3: the whale repays its principal and leaves. Live supply is 100,000 again.\n        _next();\n        vm.startPrank(WHALE);\n        vault.wipe(900_000 ether);\n        vault.free(1_500_000 ether); // a few cents of accrued fee stay owed; the collateral comes out\n        vm.stopPrank();\n        _next();\n        (, uint256 pacedSupply,,,) = vault.paced();\n        emit log_named_uint(\"paced supply (fee base)\", pacedSupply);\n        emit log_named_uint(\"live supply\", stable.totalSupply());\n        uint256 fee = vault.redemptionFeeBps(50_000 ether);\n        emit log_named_uint(\"fee for 50,000, bps\", fee);\n        // EXPECTED (the paced figures' NatSpec: principal drawn for a block cannot dilute the fee): 500 bps, as in\n        // the control. ACTUAL: 328 bps, the 900,000 drawn for two blocks counting in the fee base in full.\n        assertEq(fee, 500, \"a block-long draw diluted the redemption fee\");\n        // And it lasts: twelve hours later the base is still about 300,000 against a live supply of 100,000.\n        for (uint256 i; i < 12; ++i) {\n            vm.warp(block.timestamp + 1 hours);\n            vm.roll(block.number + 300);\n            primary.set(DOLLAR);\n            spot.set(DOLLAR);\n            health.set(0.85 ether);\n            vault.pace();\n        }\n        assertEq(vault.redemptionFeeBps(50_000 ether), 500, \"still diluted twelve hours later\");\n    }\n}","reproduction":"Proof below (SeededFeeBase.t.sol): fresh vault, IMD $1, NHI 0.85. Block 1 WHALE lock(1,600,000)+draw(900,000); block 2 BOOK lock(200,000)+draw(100,000) (seeds 900,000); block 3 WHALE wipe(900,000)+free(1,500,000); block 4 redemptionFeeBps(50,000). Expected 500 (control); actual 328, paced().supply 900,300e18. Fails on a3aa9e4 with 'a block-long draw diluted the redemption fee: 328 != 500'. Direction (b): lock(10e18)/draw(1e18), pace, WHALE lock(1.5M)/draw(500k), pace an hour later: paced supply ~10,001e18, redemptionFeeBps(5,000e18)=300 vs 100 intended.","severity":"low","snippet":"        if (paced == 0) return live;","title":"Seeded paced supply takes the whole live supply at the first pacing after the first draw, unpaced: whoever draws first sets the launch fee base in either direction"},{"citation":"resolved","description":"Merged 760cda55 and 8a726544. ParameterizedVault/CDPVault have no pause, allowlist or opening switch: lock, lockIMD and draw are open from the block runVault lands in, with feeds fresh from stage one. Steps 3-4 (keeper start and funding, ORACLE_ASKER prefund) are therefore not preconditions of borrowing, and the rollback window ('abandon only before anyone deposits') closes at block N+1 without the operator acting; together with the paced-supply seed the first borrower picks the launch fee base. Fix: say deposits are open from the vault's first block; move keeper start and asker prefund before runVault, and have the operator make the first position.","line":392,"path":"docs/MAINNET-RUNBOOK.md","reproduction":"After runVault's CREATE2 tx is mined at block N, any EOA with sIMD calls vault.lock(x), vault.draw(y) at N+1: both succeed; grep finds no launch flag in src/CDPVault.sol or src/ParameterizedVault.sol. Expected per runbook: deposits refused until step 5.","severity":"info","snippet":"5. **Only then** open deposits.","title":"Runbook 7 step 5 'Only then open deposits' describes a gate the vault does not have"},{"citation":"resolved","description":"Merged 6009be2d and b809730a. CDPVault constructor comment and WorkOracleFactory NatSpec (src/WorkOracleFactory.sol 10-11, '36,416 ... 52,880 bytes') state 36,416; actual is 47,089, 2,063 bytes of headroom, not ~12.7 KB. Conclusion holds (47,089 + 16,464 = 63,553 > 49,152) but the margin is overstated. Fix: update both figures.","line":418,"path":"src/CDPVault.sol","reproduction":"forge inspect src/ParameterizedVault.sol:ParameterizedVault bytecode -> (hex length - 2)/2 = 47089 (run at a3aa9e4). Comment states 36,416.","severity":"info","snippet":"            // bytes and this vault's subclass is already at 36,416 of the 49,152 EIP-3860 permits.","title":"Stale initcode size in comments: ParameterizedVault is 47,089 bytes of initcode, not 36,416"},{"citation":"resolved","description":"_liveBacking is (reserve + min(held x price, mat x (debt - bad)/100)) / supply (_securedCollateralValue). The NatSpec gives only the cap term, so a book whose remaining positions are underwater satisfies the stated 'no dip' condition and still dips (the repository's own test_aParBookDipsWhenThePositionCarryingTheCapLeavesAndReturns). Fix: 'reserve + min(the remaining positions' secured value at the price, mat x (debt - bad debt - its principal) / 100)'.","line":326,"path":"src/CDPVault.sol","reproduction":"Kept test's numbers: reserve 20,000 IMD x $0.40 = 8,000; cap 1.7 x 99,500 = 169,150; 177,150 >= 99,500 so the sentence predicts no dip; the test asserts backingPerUnit() < 0.9e18 (secured term 79,600 + 8,000 over 99,500 = 0.88).","severity":"info","snippet":"    /// book WITHOUT the leaving position is below par: reserve + mat x (debt - bad debt - its principal) less than","title":"Paced-figures NatSpec states the dip condition without the remaining positions' secured value"},{"citation":"resolved","description":"_step is a fraction of the current paced value per pacing, and pace() is permissionless, so paced every block the figures grow e^0.1-1 = 10.52%/hour. CDPVault 305-307 was corrected to 'compounding per pacing'; ParameterizedVault 238-239, 262 and CDPVault 1105 were not. Fix: use the corrected wording.","line":262,"path":"src/ParameterizedVault.sol","reproduction":"Paced debt 1,000,000e18, live far larger: one pace after 1h -> 1,100,000e18; 300 paces 12s apart -> 1,000,000 x (1+1/3000)^300 ~ 1,105,100e18 > the 1,100,000e18 the comments state. grep 'at most FOLLOW_BPS_PER_HOUR an hour' src/ finds CDPVault.sol:1105 and ParameterizedVault.sol:262 (and 238-239 wraps the same phrase).","severity":"info","snippet":"        // D1: debt counts only up to the paced debt, which rises by at most FOLLOW_BPS_PER_HOUR an hour and falls","title":"Two comments state the follow bound as 'at most FOLLOW_BPS_PER_HOUR an hour' without the per-pacing compounding"}],"hash":"327584de1412adb7ad57f9ba72560e5542ae3cadf2b62e0d48e0a9546af153a2","nodeId":"ca9c0dee-274f-41bb-9166-f7e0dd9c4457","outcome":"completed","summary":"I wrote 7 findings to `.imd-findings.json` after checking all 12 specialist findings against the code. Every one of them reproduces. All four attached proofs fail on a3aa9e4 for the reasons their findings give. I wrote and ran my own test for the self-redemption case, which had no proof. Duplicates are merged, and no source file was changed.\n\n**Findings, most severe first**\n\n1. **High, `CDPVault.cash` (line 742): redemptions pay out at a manipulated feed low.** Someone pushes IMD's only pool down 20% and holds it there for about 65 minutes. That is about $240k of selling and about $5k in pool fees, plus whatever dip-buyers take during the hold. The feeds accept that one step, and both of them read the same pool, so the agreement check passes. A redeemer is then paid 1.25 × 0.95 IMD per imdUSD, which takes about 18.75% of the in-band debt from the positions redeemed against. That is up to about $187k at LINE $1M, or 58% after two hours of feed silence. Pacing and `resecure` don't limit this, because they bound the backing per imdUSD, not the price of IMD.\n   - The accepted \"oracle walk cost\" item only prices the over-borrowing walk, so it doesn't cover this route.\n   - Proof: 50,000 imdUSD takes 59,375 IMD from the redeemed position instead of at most 50,000.\n   - Whether it pays in practice depends on how much dip-buying the attacker has to absorb during the hold.\n   - Fix: either set the redemption fee to at least the feed's fall from its epoch anchor, or pace the payout price.\n\n2. **Low, `_clampPacedDebt` (line 907): the paced debt is wrong after a cancellation.** This merges three reports with one root cause. Severity is low because the paced debt only feeds the work ceiling, and the wage is 0 at launch. Once governance sets a wage it becomes medium.\n   - A draw in one transaction and a cancellation of someone else's seasoned debt in the next leaves fresh debt counted in full. The proof gets `backedDebt` 99,512 where at most 79,600 is expected.\n   - In one transaction, lock + draw + `cash` against your own position zeroes the paced debt while the 99,500 seasoned book is untouched. That would block the work channel for gas.\n   - Fix: clamp against the paced debt as the transaction found it, minus only the pre-existing debt it cancelled.\n\n3. **Low, `_pacedSupply` (line 829): whoever draws first sets the launch fee base.** This merges two reports. A large draw held for one block seeds the fee base at about 900k, so a 50,000 redemption pays 328 bps instead of 500 for about a day. A tiny first draw does the opposite and leaves the base at the 100,000 floor, the state the paced vault panel's finding 5 (marked \"Fixed\") was meant to prevent. Fix: seed no higher than the floor.\n\n4. **Info, `docs/MAINNET-RUNBOOK.md:392`:** \"Only then open deposits\" describes a gate the vault doesn't have. Anyone can `lock` and `draw` from the block after the vault lands.\n\n5. **Info, `CDPVault.sol:418` and `WorkOracleFactory.sol`:** the comments say the vault's initcode is 36,416 bytes. It is 47,089, which leaves 2,063 bytes of headroom, not about 12.7 KB.\n\n6. **Info, `CDPVault.sol:326`:** the NatSpec's condition for the accepted dip leaves out the remaining positions' secured value. The repository's own dip test contradicts it.\n\n7. **Info, `ParameterizedVault.sol:262` and `CDPVault.sol:1105`:** these say the follow rate is \"at most 10% an hour\". Paced every block it compounds to about 10.5%; the main NatSpec was already corrected but these two lines were not.\n\n**Coverage.** I read in full the cash, pacing and clamp section of `CDPVault.sol` (lines 280–340 and 690–1000), the work-ceiling part of `ParameterizedVault.sol` (225–305), the walk analysis in the parameters doc, and the three feed question bodies. I did not independently answer questions 3, 5 and 6, or the launch-window parts of question 1. That covers the governance and Treasury regression pass, `DeployMainnet`, `plan.py`, and `resecure`'s griefing surface. Those areas rest only on the speci","treeHash":null,"usage":{"cachedInputTokens":956513,"inputTokens":28,"model":"claude-opus-5-5","outputTokens":15131,"runtime":"claude","turns":14,"wallClockMs":169775}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d03fc84904b54d9e","findings":[{"citation":"resolved","description":"Function: CDPVault.cash (payoutScale, gemOut = amount x backing x (1 - fee) / price). The payout is denominated at the collateral price the feeds attest, and the backing is capped at par. On a book with slack above par (every healthy position at launch: a 170% book is still at par after a 41% fall, because the aggregate cap is mat x debt and the terms are valued at the new price) the paced backing is 1e18 and does nothing, so the IMD a redemption takes is amount x (1 - fee) / price, with the fee capped at 500 bps (REDEMPTION_FEE_FLOOR_BPS + REDEMPTION_FEE_CAP_BPS). The feeds accept a single step of 20% from a fresh anchor (FEED_MAX_DEVIATION_BPS 2000, SwarmFeed._checkValue) and 40% once the value has been stale a whole hour past its one-hour lifetime (SwarmFeed._allowanceNow), and being stale is the price feeds' normal state between purchased updates. The primary and spot both read IMD's one pool, so pushing the pool pushes both and the SKEW_BPS guard sees agreement. Call sequence, from any account, with the constants as committed (LINE $1M, wage 0, mat 170 at NHI >= 0.85, fee cap 5%): (1) hold imdUSD (drawn earlier against the attacker's own sIMD at the honest price, or bought); (2) sell about 12% of the pool's IMD side (about 22,000 IMD, $240k at $10.92 against a $2.0M side) so the pool price falls 20%, and hold it there for about 65 minutes so that 7 of the primary's 13 window samples sit at the low (the body is a 2-hour median); (3) buy and relay the primary and the spot (askPaid, or let the Treasury's own drift trigger pay: OracleAsker.ask fires on a 5% fall); (4) next block, cash(amount, 0, candidate) for each in-band candidate (at -20% every position with an honest CR below 275% is in band, and RedemptionWorsensRatio only needs CR >= 95% at the low) and the reserve; (5) buy the IMD back. Pool cost is the two 1% fees on the round trip (about $5k on $240k) plus whatever dip-buyers take during the hold; attestations about $9; time about 75 minutes. Gain: 1.25 x 0.95 - 1 = 18.75% of the redeemed volume, taken from the candidates' collateral (each loses collateral worth 1.1875 per imdUSD of debt cancelled at the pre-fall price) and from the Treasury's sIMD first. After two hours of feed silence the same with a 40% step: 1/0.6 x 0.95 - 1 = 58% (the honest-value ceiling is the book's own CR, 70% at 170%). The same one-step low also makes every position with an honest CR under 212% liquidatable (bite seizes 1.2 / 0.8 = 1.5x honest value per debt repaid, 47.5% net of the cuts to a self-marking liquidator), at the cost of holding the low for grace (6 hours at NHI >= 0.85) plus one tail. This is a gap in the accepted item 'the oracle's walk cost' (docs/PARAMETERS-2026-10-05.md, 'The walk, at the committed constants'): that analysis prices the over-borrowing walk, which needs x1.7 (three or four hourly steps, hours in the open, about $40k of fees, exposure to every holder); the redemption route needs ONE step inside the committed allowance, about 75 minutes, and about $5k, and the paced backing and resecure do not shorten its reach because they bound the backing per unit, not the price per IMD. The runbook's 7.2 sentence 'What a redemption is paid can rise by at most two points of par an hour however much capital arrives' holds for the backing only; the IMD paid per imdUSD rises at once with a feed fall. Smallest fix (either): (a) floor the redemption fee at the primary feed's fall from its current epoch anchor (SwarmFeed.epoch(): feeBps >= (anchor - price) x 10000 / anchor), so a print below the anchor pays no more IMD than the anchor would, which neutralises exactly the one-step gain and leaves multi-epoch declines priced as now; or (b) pace the payout price as the backing is paced: a stored price that falls by at most a fixed fraction of the anchor per hour, with cash paying at max(price, pacedPrice). The attached proof passes under either.","line":742,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// A redemption is paid IMD at the feed's price with the backing capped at par, so a one-step feed fall within\n// the committed per-epoch allowance (20% on a fresh feed, 40% after two hours of silence) lets a redeemer take\n// 1 / (1 - step) IMD per imdUSD from the reserve and from any candidate, while the redemption fee is capped at\n// 5%. On a par book the paced backing does not bind (B = 1), so pacing does not slow it. The property asserted:\n// after a 20% feed fall, one redemption does not pay more IMD, valued at the pre-fall price, than the imdUSD it\n// burned. Fails on a3aa9e4: 50,000 imdUSD takes 59,375 IMD (worth $59,375 at the pre-fall price) out of the\n// candidate's collateral, and the candidate's debt falls by only 50,000.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Math} from \"@openzeppelin/contracts/utils/math/Math.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract OsFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 hours;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        set(v);\n    }\n\n    function set(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external view returns (bool) {\n        return block.timestamp - updatedAt > maxAge;\n    }\n}\n\ncontract OsAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract OneStepFallRedemptionTest is Test {\n    address private constant BOOK = address(0xB00C);\n    address private constant HOLDER = address(0x401D);\n    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    OsFeed private primary;\n    OsFeed private health;\n    OsFeed private spot;\n    uint256 private imdEth = DOLLAR;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new OsAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new OsFeed(DOLLAR);\n        health = new OsFeed(0.85 ether); // mat 170, gap 50: a position at 200% is a candidate\n        spot = new OsFeed(DOLLAR);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        stable = vault.stablecoin();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BOOK, 200_000 ether);\n        imd.mint(HOLDER, 300_000 ether);\n        vm.stopPrank();\n        vm.startPrank(BOOK);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(199_000 ether);\n        vault.draw(99_500 ether); // 200%: the candidate\n        vm.stopPrank();\n        vm.startPrank(HOLDER);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(300_000 ether);\n        vault.draw(100_000 ether); // the redeemer's imdUSD, held for a day\n        vm.stopPrank();\n        for (uint256 i; i < 24; ++i) _hour();\n        assertEq(vault.backingPerUnit(), 1e18, \"a par book\");\n    }\n\n    function _next(uint256 seconds_) private {\n        vm.warp(block.timestamp + seconds_);\n        vm.roll(block.number + 1 + seconds_ / 12);\n        primary.set(imdEth);\n        spot.set(imdEth);\n        health.set(0.85 ether);\n    }\n\n    function _hour() private {\n        _next(1 hours);\n        vault.pace();\n    }\n\n    /// @dev One step the feeds accept on a fresh epoch (SwarmFeed.maxDeviationBps 2000): primary and spot both\n    /// 20% below the price of a moment ago, within SKEW_BPS of each other. The next block, a holder redeems.\n    function test_oneStepFallDoesNotPayMoreThanTheImdUSDBurnedAtThePreFallPrice() public {\n        uint256 preFall = DOLLAR;\n        imdEth = DOLLAR * 80 / 100;\n        _next(12);\n        (uint256 price,) = vault.collateralPriceFeed().latestValue();\n        assertEq(price, 0.8 ether, \"the vault prices at the attested low\");\n        assertEq(vault.backingPerUnit(), 1e18, \"the par book stays at par through a 20% fall: pacing does not bind\");\n        uint256 collateralBefore = vault.securedCollateral();\n        (uint256 bookCollateralBefore, uint256 bookDebtBefore) = vault.positions(BOOK);\n        vm.prank(HOLDER);\n        uint256 gemOut = vault.cash(50_000 ether, 0, BOOK);\n        (uint256 bookCollateralAfter, uint256 bookDebtAfter) = vault.positions(BOOK);\n        // The candidate funded all of it (no reserve), and its debt fell by exactly what was burned.\n        assertEq(bookCollateralBefore - bookCollateralAfter, gemOut, \"paid from the candidate\");\n        assertEq(bookDebtBefore - bookDebtAfter, 50_000 ether, \"fifty thousand of debt cancelled\");\n        collateralBefore; // silence\n        // EXPECTED: 50,000 imdUSD takes at most 50,000 IMD at the pre-fall price (the fee is a brake, not a\n        // premium). ACTUAL on a3aa9e4: 50,000 x 0.95 / 0.80 = 59,375 IMD, worth 59,375 at the pre-fall price.\n        uint256 valueAtPreFall = Math.mulDiv(gemOut, preFall * 2000, 1e18); // DOLLAR x 2000 = $1 per IMD\n        assertLe(valueAtPreFall, 50_000 ether, \"a one-step feed fall pays the redeemer more than it burned\");\n    }\n}","reproduction":"test/scratch/OneStepFallRedemption.t.sol (attached). ParameterizedVault over MockIMD at $1 (IMD/ETH 1/2000 x Chainlink 2000e8 etched at CHAINLINK_ETH_USD), NHI 0.85 (mat 170, gap 50), TreasuryFactory etched, no reserve. BOOK locks 199,000 and draws 99,500 (200%, a candidate); HOLDER locks 300,000 and draws 100,000; 24 hourly pacings; backingPerUnit() == 1e18. Both feeds move to 0.80 of the price (one step inside the fresh allowance), next block HOLDER calls cash(50_000e18, 0, BOOK). backingPerUnit() still reads 1e18 (par, pacing does not bind). EXPECTED: 50,000 imdUSD takes at most 50,000 IMD at the pre-fall price. ACTUAL: gemOut == 59,375e18 IMD (50,000 x 0.95 / 0.80), all out of BOOK's collateral, while BOOK's debt falls by 50,000: BOOK loses $9,375 at the pre-fall price on this one call. Run: forge test --match-path test/scratch/OneStepFallRedemption.t.sol -vv; the assertion 'a one-step feed fall pays the redeemer more than it burned' fails on a3aa9e4.","severity":"high","snippet":"        uint256 payoutScale = Math.mulDiv(_backingPerUnit(price), 10_000 - feeBps, 10_000);","title":"cash: a one-step feed fall within the committed per-epoch allowance (20% fresh, 40% after two hours of silence) pays a redeemer 1/(1 - step) IMD per imdUSD from the reserve and from candidates, agains"},{"citation":"resolved","description":"Function: CDPVault._clampPacedDebt, called after draw, cash, bite and cover (added in c1ecb05 for the paced vault panel's medium #1). Its live figure is totalDebt + WIPED_THIS_TX - MINTED_THIS_TX, and the tallies are transient, so it only knows about debt minted in the SAME transaction. Across two transactions: tx1 lock + draw(X): the paced debt stays at T (the fresh X is not counted, correct). tx2 (same block, or the next): _pace writes min(T + X, T + step) = T + step (step 0 or one block's worth), then cash(X, 0, victim) (or bite(victim, X), or cover) cancels X of the victim's seasoned debt; the clamp reads live = T + 0 - 0 = T and writes T. The next transaction finds totalDebt T and paced T, so ParameterizedVault.backedDebt counts the churner's X, drawn a block earlier, in full: exactly what the panel's one-transaction finding described, one transaction later. The NatSpec at CDPVault 310-313 and 899-904 and ParameterizedVault 237-240 and 262-264 ('debt cancelled by a redemption or a liquidation and drawn again by someone else backs nothing until it has been held'; 'the paced debt never exceeds the debt this transaction began with less what it has cancelled') holds within a transaction only. Reachable now with the constants as committed; the ordering costs the churner gas and the redemption fee (or the bite's price). Its only consumer is the work ceiling and WAGE_WAD is 0, so nothing is takeable at launch; once governance sets a wage (48-hour timelock) it is the D1 round trip at zero holding time: 25% (EARN_MAT_BPS 2500) of whatever seasoned debt the attacker can cancel is replaced in the counted figure by debt it unwinds the block after work is minted against it. Smallest fix: clamp against the paced figure as the transaction FOUND it, less what the transaction cancelled of pre-existing debt: after _pace, remember _debtPaced in a transient slot; in _clampPacedDebt also apply _debtPaced = min(_debtPaced, pacedAtTxStart - cancelled) with cancelled = debtAtTxStart - (totalDebt + WIPED - MINTED) (ParameterizedVault already records debtAtTxStart through _debtChanged; expose it to CDPVault or move the slot down). With that, tx2 writes T - X and the churner's X is counted only as the paced figure climbs at FOLLOW_BPS_PER_HOUR.","line":908,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// The paced debt's clamp after a cancellation (CDPVault._clampPacedDebt, added in c1ecb05 for the paced vault\n// panel's medium #1) measures \"the debt this transaction began with less what it has cancelled\" from totalDebt\n// and the transaction's own transient tallies. A draw in ONE transaction and the cancellation of another\n// position's seasoned debt in the NEXT (same block or the next) leaves the paced debt at the book's total: the\n// cancelled seasoned debt is replaced, in the counted figure, by debt drawn a transaction earlier, which the\n// NatSpec (CDPVault 310-313, ParameterizedVault 237-240, 261-264) says backs nothing until it has been held.\n// Fails on a3aa9e4: after lock+draw(20,000) and, in the next block, cash(20,000, 0, BOOK), backedDebt() reads\n// 99,500 where the seasoned book is 79,500 and the churner's 20,000 is twelve seconds old.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract DcFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        set(v);\n    }\n\n    function set(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract DcAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract DrawThenCancelAcrossTransactionsTest is Test {\n    address private constant BOOK = address(0xB00C);\n    address private constant CHURNER = address(0xC4A1);\n    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    DcFeed private primary;\n    DcFeed private health;\n    DcFeed private spot;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new DcAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new DcFeed(DOLLAR);\n        health = new DcFeed(0.85 ether); // mat 170, gap 50: the book at 200% is a candidate\n        spot = new DcFeed(DOLLAR);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BOOK, 200_000 ether);\n        imd.mint(CHURNER, 40_000 ether);\n        vm.stopPrank();\n        vm.startPrank(BOOK);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(199_000 ether);\n        vault.draw(99_500 ether); // 200%: eligible for redemption, and the only debt on the book\n        vm.stopPrank();\n        vm.prank(CHURNER);\n        imd.approve(address(vault), type(uint256).max);\n        // A day of hourly pacing: the paced debt catches up with the book.\n        for (uint256 i; i < 24; ++i) {\n            vm.warp(block.timestamp + 1 hours);\n            vm.roll(block.number + 300);\n            primary.set(DOLLAR);\n            spot.set(DOLLAR);\n            health.set(0.85 ether);\n            vault.pace();\n        }\n        assertEq(vault.backedDebt(), 99_500 ether, \"the book counts in full after a day\");\n    }\n\n    function _next() private {\n        vm.warp(block.timestamp + 12);\n        vm.roll(block.number + 1);\n    }\n\n    function test_drawThenCancelInTheNextTransactionCountsZeroSecondDebt() public {\n        // Transaction 1: the churner locks and draws 20,000 (its debt is not yet counted: paced stays 99,500).\n        vm.startPrank(CHURNER);\n        vault.lock(40_000 ether);\n        vault.draw(20_000 ether);\n        vm.stopPrank();\n        (,, uint256 pacedAfterDraw,,) = vault.paced();\n        assertEq(pacedAfterDraw, 99_500 ether, \"the fresh draw is not counted\");\n        // Transaction 2, the next block: redeem the drawn 20,000 against the seasoned book.\n        _next();\n        vm.prank(CHURNER);\n        vault.cash(20_000 ether, 0, BOOK);\n        // The next block: the book's total is back at 99,500, of which 20,000 is twelve seconds old.\n        _next();\n        // A day's stability fees (about 12 imdUSD) were cancelled first, so the principal is a few imdUSD above.\n        assertApproxEqAbs(vault.totalDebt(), 99_500 ether, 20 ether);\n        (, uint256 bookDebt) = vault.positions(BOOK);\n        assertApproxEqAbs(bookDebt, 79_500 ether, 20 ether, \"the book lost 20,000 of seasoned debt\");\n        // EXPECTED per the NatSpec: at most the seasoned 79,500 plus two blocks of the follow step (about 67).\n        // ACTUAL on a3aa9e4: 99,500: the churner's zero-second debt counts in full for the work ceiling.\n        assertLe(vault.backedDebt(), 79_600 ether, \"cancelled seasoned debt replaced by zero-second debt counts in full\");\n    }\n}","reproduction":"test/scratch/DrawThenCancelAcrossTransactions.t.sol (attached). Same fixture as the panel's proof (test/paced-vault-panel/economics_medium_742_ProofDrawThenCancelTest.t.sol): BOOK locks 199,000 and draws 99,500 at 200%; 24 hourly pacings so backedDebt() == 99,500e18. CHURNER, in ONE transaction, lock(40_000e18) + draw(20_000e18): paced().debt == 99,500e18. In the NEXT block, cash(20_000e18, 0, BOOK). One block later: totalDebt about 99,512e18 (a day's fees were cancelled first), BOOK's debt about 79,512e18. EXPECTED per the NatSpec: backedDebt() <= 79,600e18 (the seasoned book plus two blocks of the follow step). ACTUAL: backedDebt() == 99512105242694063896500. Run: forge test --match-path test/scratch/DrawThenCancelAcrossTransactions.t.sol -vv; fails on a3aa9e4.","severity":"medium","snippet":"        if (live < _debtPaced) _debtPaced = live;","title":"_clampPacedDebt measures the cancellation against totalDebt and the transaction's own tallies only, so a draw in one transaction and a cancellation of another position's seasoned debt in the next (sam"},{"citation":"resolved","description":"Function: CDPVault._debtForPacing, read by _clampPacedDebt after cash. A contract locks C, draws X (MINTED = X, totalDebt = T + X) and redeems the X it drew against its OWN position (eligible at 200%, and the position-funded fee stays in its own collateral, b952037a): _redeemPosition retires X of principal, totalDebt = T, and the clamp reads live = T + 0 - X, saturating at 0 when X >= T, and writes it. The cancelled debt was the transaction's own zero-second debt, so the seasoned T is unchanged, but the figure says 0 (or T - X). It then climbs at FOLLOW_BPS_PER_HOUR of max(paced, 100,000) per paced hour, about ten hours from zero to 100,000 and 10% an hour after. Cost to the caller: gas and one block of capital; the fee is retained in its own position and it frees everything in the same call (debt 0 after the self-redemption). Consumer: ParameterizedVault.backedDebt, so earnLine falls to the reserve term and every earn above it is refused with WorkCeilingReached until the figure climbs back; the churn can be repeated every block. With WAGE_WAD 0 as committed nothing is blocked today (earn is refused at wage 0); once a wage is set it is a gas-priced denial of the work channel, needing collateral worth about twice the paced debt for one block (sIMD held, not borrowed: there is no flash source). The same gap, in the other direction, is the medium finding above (a fresh draw a transaction earlier counted in full). Smallest fix: keep a per-position transient tally of principal minted this transaction (written in draw); in _reduceDebt on the cash, bite and cover paths net the overlap min(principalPaid, mintedThisTx[owner]) out of both that tally and MINTED_THIS_TX_SLOT, so a cancellation of the transaction's own fresh debt moves the paced figure by nothing.","line":849,"path":"src/CDPVault.sol","reproduction":"Probe run on a3aa9e4 (same fixture as the attached proofs: BOOK at 200% with 99,500 of debt, 24 paced hours, backedDebt() == 99,500e18). A contract holding 200,000 IMD runs in one transaction: approve; lock(200_000e18); draw(100_000e18); cash(100_000e18, 0, address(this)). Next block: totalDebt == 99,500e18, paced().debt == 0, backedDebt() == 33333333333333333333 (one block of the 10,000-an-hour step from zero). EXPECTED: paced().debt == 99,500e18 (the seasoned book is untouched; only the caller's own fresh debt was cancelled).","severity":"low","snippet":"        return live > minted ? live - minted : 0;","title":"_debtForPacing nets the whole MINTED tally out of totalDebt even when the transaction cancelled its own fresh debt, so lock + draw(X) + cash(X, 0, self) in one call zeroes the paced debt: the work cei"},{"citation":"resolved","description":"The live figure is min(reserve + min(held x price, mat x (debt - bad debt) / 100), supply) / supply (_liveBacking, _securedCollateralValue), so the book without the leaving position P is below par iff reserve + min(secured_rest x price, mat x (D - B - P) / 100) < S - P. The NatSpec gives only the cap term. In the scenario kept as test_aParBookDipsWhenThePositionCarryingTheCapLeavesAndReturns (BOOK 199,000 IMD against 99,500 at $0.40, WHALE 2,500,000 against 500,000, reserve 20,000 IMD): reserve 8,000 + 1.7 x (599,500 - 0 - 500,000) = 177,150 is NOT less than 99,500, so the stated condition says no dip, while the test asserts the figure dips below 0.9 (to 79,600 + 8,000 over 99,500 = 0.88 by the secured term, 0.80 as the panel measured with the stale term). The prose that follows (an underwater position, realized bad debt or work-minted supply) is right; the formula is not, and a reader bounding the dip from it would conclude a book with an underwater tail has none. Fix: 'reserve + min(the remaining positions' secured value at the price, mat x (debt - bad debt - its principal) / 100) less than the supply without it'. The accepted item itself (underpays only, bounded by the gap to the rest-of-book figure, climbs back at the rise rate) is not in question.","line":326,"path":"src/CDPVault.sol","reproduction":"Read _liveBacking (797-805) and _securedCollateralValue (971-983) against the sentence at 325-327, and compute the kept test's numbers: reserve 20,000 x 0.40 = 8,000; cap 1.7 x 99,500 = 169,150; sum 177,150 >= 99,500 (no dip by the sentence); the test asserts backingPerUnit() < 0.9e18 after WHALE's wipe and redraw across two blocks.","severity":"info","snippet":"    /// book WITHOUT the leaving position is below par: reserve + mat x (debt - bad debt - its principal) less than","title":"NatSpec: the accepted dip's condition is stated as 'reserve + mat x (debt - bad debt - its principal) less than the supply without it', which omits the secured value of the remaining positions; the pa"},{"citation":"resolved","description":"_step is FOLLOW_BPS_PER_HOUR x min(elapsed, PACE_INTERVAL) / 1 hour of the CURRENT paced value, applied at every pacing, and pace() is permissionless, so paced every block the supply and debt figures grow by e^0.1 - 1 = 10.52% an hour and 11.0x over a day, not 10% and 9.85x. c1ecb05 corrected the paced figures' NatSpec (CDPVault 305-307: 'per hour of elapsed time, compounding per pacing') but ParameterizedVault 238-239 and 262 ('rises by at most FOLLOW_BPS_PER_HOUR an hour') and CDPVault 1105 ('follows the supply by at most FOLLOW_BPS_PER_HOUR an hour') still state the un-compounded bound. No economic consequence beyond the fee base and the work ceiling catching up about 5% faster than those lines say. Fix: 'per hour of elapsed time, compounding per pacing', as at 306.","line":262,"path":"src/ParameterizedVault.sol","reproduction":"Paced debt 1,000,000e18 with a far larger live debt: one pace after an hour gives 1,100,000e18; 300 paces 12 seconds apart over the same hour give 1,000,000 x (1 + 1/3000)^300 = about 1,105,100e18, above the 1,100,000e18 the two lines state. grep -n 'at most FOLLOW_BPS_PER_HOUR an hour' src/ParameterizedVault.sol src/CDPVault.sol finds lines 239, 262 and 1105.","severity":"info","snippet":"        // D1: debt counts only up to the paced debt, which rises by at most FOLLOW_BPS_PER_HOUR an hour and falls","title":"Two comments still state the follow bound as 'at most FOLLOW_BPS_PER_HOUR an hour' without the compounding the paced figures' NatSpec was corrected to (paced vault panel info #6): ParameterizedVault.b"}],"hash":"4ff5c4b9c6ffb1405272582aa974b073a4f8b05f5b6fb24a70366ce5543546ae","nodeId":"caf56166-76f6-45a7-92bf-c1942c67f432","outcome":"completed","summary":"The review is complete. Five findings are in `.imd-findings.json`: one high, one medium, one low, two info. The two most severe carry Foundry proofs under `test/scratch/`, each run and failing on a3aa9e4 for the stated reason. The existing suite is green on the pinned commit (631 passed, 4 skipped). No tracked file was changed.\n\n**Findings, most severe first**\n\n- **High, `cash` (CDPVault.sol:742).** A single feed step inside the committed allowance lets a redeemer take 1/(1 − step) IMD per imdUSD from candidates and the reserve, against a fee capped at 5%. The feeds accept a 20% step from a fresh anchor and 40% after two hours of silence, which is the price feeds' normal state. Both feeds read the one pool, so pushing it 20% down for about 65 minutes (7 of the primary's 13 samples) and buying both attestations costs roughly $5k of pool fees and 75 minutes. Pacing does not bind because a healthy book stays at par through a 41% fall. Proof: 50,000 imdUSD takes 59,375 IMD out of a 200% candidate while its debt falls by 50,000. The accepted \"walk cost\" analysis prices the over-borrow route (×1.7, hours, ~$40k); this route needs one step. Two fixes are offered: floor the fee at the primary's fall from its epoch anchor, or pace the payout price as the backing is paced.\n- **Medium, `_clampPacedDebt` (CDPVault.sol:908).** The clamp added in c1ecb05 nets only the same transaction's tallies. Draw in one transaction, cancel another position's seasoned debt in the next, and the paced debt stays at the book's total with zero-second debt counted in full. Proof: backedDebt reads 99,512 where the seasoned book is 79,512. Nothing takeable at wage 0; it is the panel's medium one transaction later. Fix: also clamp to the paced figure the transaction found less what it cancelled of pre-existing debt.\n- **Low, `_debtForPacing` (CDPVault.sol:849).** Lock, draw X and self-redeem X in one call makes the clamp's live figure saturate at zero, so the paced debt reads 0 and the work ceiling's debt term takes about ten paced hours to climb back. Costs gas only (the fee stays in the caller's own position). Blocks nothing at wage 0.\n- **Info, CDPVault.sol:326.** The accepted dip's stated condition omits the remaining positions' secured value; the panel's own kept test dips at 0.80 where the formula says no dip.\n- **Info, ParameterizedVault.sol:262 and CDPVault.sol:1105.** Two comments still give the follow bound un-compounded after the paced NatSpec was corrected.\n\n**Answers to the numbered questions**\n\n1. *The newest lines.* `resecure` cannot lift the figure past honest or lower a term below honest: it writes exactly `_secured` at a fresh, agreed price, pacing runs before it, any rise goes into the same-transaction exclusion, and `cash` always reads min(live, paced). It touches nothing but the term (no mark, grace, health or eligibility). Griefing is gas only. The clamp after cancellations closes the one-transaction ordering but not the two-transaction one (medium) and over-clamps on a self-cancellation (low). The backing's own clock banks at most one interval. The seeded supply is reachable only while the supply is exactly zero; once any fee has been minted to the Treasury the paced supply never returns to zero, so there is no fee-base reset after launch day.\n2. *Across the system.* Treasury exits (fundOracle unwrap, redeemIMD, cover's burn, withdraw's bad-debt floor) all lower the live figure and are paced at once; donations rise at two points an hour. Mid-flight parameter changes only move the cap or eligibility. Stale or diverged feeds hold the backing without consuming its interval. Relay bundling adds nothing beyond the finding above, since the price is what moves the payout, not the backing.\n3. *Launch day.* Stage one, seeding, verifySeeded and the private stage two hold up as written. A stranger between the stages can only spend the asker's prefunded IMD on honest first values (bounded by the in-flight slot and the ten-minute interval) or relay a f","treeHash":null,"usage":{"cachedInputTokens":4993947,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":84238,"runtime":"claude","turns":61,"wallClockMs":1400232}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4ca9ed4f0937da89","findings":[{"citation":"resolved","description":"CDPVault._pacedSupply (new in c1ecb05, paced vault panel #5) returns the whole live supply whenever _supplyPaced is zero. At deployment it is zero, and the first borrower's own pacing runs before its draw, so it still sees zero. The next capital-moving transaction by anyone (lock, draw, wipe, cash, pace) then writes that borrower's whole draw into the fee base at once, with no follow-rate limit. If the borrower repays a block later, the fee base falls only at FOLLOW_BPS_PER_HOUR (10% an hour, compounding), so it stays well above the real supply for roughly a day. The redemption-fee increase is amount / feeBase / divisor, so every redemption in that window pays less and stores a lower base rate. Borrowers are the ones who lose: the fee is their compensation for being redeemed against. This breaks the property the paced figures' NatSpec states ('principal drawn for a block cannot dilute the fee', CDPVault lines 307-309). The seed's own comment ('there is no earlier base for a draw to dilute') is wrong: the draw itself is the base. Reachable with the committed constants (LINE $1M, floor 100,000, divisor 2, FOLLOW_BPS_PER_HOUR 1000). The only requirement is to be the first borrower after the vault is public, and anyone can backrun the stage-two deployment to do that. Cost: about 1.6M of sIMD collateral at 170% for two blocks, plus gas; the stability fee for a few seconds is negligible. Impact: lower redemption fees for every redeemer, the attacker included, for about a day after launch (a 50,000 redemption pays 328 bps instead of 500, so the redeemed-against borrowers lose about $860). The same reset also happens whenever the live supply returns to exactly zero. Smallest fix: do not seed above the floor, e.g. `if (paced == 0) return Math.min(live, _feeBaseFloor());`, so the base grows from the floor at the follow rate. Fees then err high on day one, the direction that protects borrowers. Alternatively remove the seed and restate paced-vault-panel #5 as accepted.","line":829,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// CDPVault._pacedSupply seeds a zero paced supply with the whole live supply at once. At launch the first borrower\n// draws (the pacing at the start of its own transaction sees zero), and the NEXT capital-moving transaction by anyone\n// writes that draw into the fee base in full. Repaid a block later, it keeps the fee base inflated for about a day,\n// falling only at FOLLOW_BPS_PER_HOUR: principal drawn for a block dilutes every redemption fee in that window.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract SeedFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        set(v);\n    }\n\n    function set(uint256 v) public {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract SeedAggregator {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract SeededFeeBaseTest is Test {\n    address private constant WHALE = address(0xA11CE);\n    address private constant BOOK = address(0xB00C);\n    uint256 private constant DOLLAR = uint256(1 ether) * 1e18 / 2000 ether; // IMD/ETH at $1\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    SeedFeed private primary;\n    SeedFeed private health;\n    SeedFeed private spot;\n\n    function setUp() public {\n        if (TREASURY_FACTORY.code.length == 0) vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new SeedAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new SeedFeed(DOLLAR);\n        health = new SeedFeed(0.85 ether);\n        spot = new SeedFeed(DOLLAR);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(spot)\n        );\n        stable = vault.stablecoin();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(WHALE, 2_000_000 ether);\n        imd.mint(BOOK, 220_000 ether);\n        vm.stopPrank();\n        vm.prank(WHALE);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(BOOK);\n        imd.approve(address(vault), type(uint256).max);\n    }\n\n    function _next() private {\n        vm.roll(block.number + 1);\n        vm.warp(block.timestamp + 12);\n        primary.set(DOLLAR);\n        spot.set(DOLLAR);\n        health.set(0.85 ether);\n    }\n\n    function _bookBorrows() private {\n        // An honest book of 100,000 imdUSD at 200%: inside the redeemable band (mat 170 + gap 50).\n        vm.startPrank(BOOK);\n        vault.lock(200_000 ether);\n        vault.draw(100_000 ether);\n        vm.stopPrank();\n    }\n\n    /// Control: the same honest book with no block-long draw in front of it. 50,000 burned against a 100,000 base\n    /// (the floor) stores the cap: 50 + 450 = 500 bps.\n    function test_controlFeeAtLaunch() public {\n        _bookBorrows();\n        _next();\n        vault.pace();\n        _next();\n        assertEq(vault.redemptionFeeBps(50_000 ether), 500);\n    }\n\n    function test_blockLongDrawAtLaunchDilutesTheFeeBase() public {\n        // Block 1: the first borrower draws 900,000 (LINE is 1,000,000). Its own pacing sees a zero supply.\n        vm.startPrank(WHALE);\n        vault.lock(1_600_000 ether);\n        vault.draw(900_000 ether);\n        vm.stopPrank();\n        // Block 2: the honest book borrows; its pacing seeds the paced supply with the whole 900,000.\n        _next();\n        _bookBorrows();\n        // Block 3: the whale repays its principal and leaves. Live supply is 100,000 again.\n        _next();\n        vm.startPrank(WHALE);\n        vault.wipe(900_000 ether);\n        vault.free(1_500_000 ether); // a few cents of accrued fee stay owed; the collateral comes out\n        vm.stopPrank();\n        _next();\n        (, uint256 pacedSupply,,,) = vault.paced();\n        emit log_named_uint(\"paced supply (fee base)\", pacedSupply);\n        emit log_named_uint(\"live supply\", stable.totalSupply());\n        uint256 fee = vault.redemptionFeeBps(50_000 ether);\n        emit log_named_uint(\"fee for 50,000, bps\", fee);\n        // EXPECTED (the paced figures' NatSpec: principal drawn for a block cannot dilute the fee): 500 bps, as in\n        // the control. ACTUAL: 328 bps, the 900,000 drawn for two blocks counting in the fee base in full.\n        assertEq(fee, 500, \"a block-long draw diluted the redemption fee\");\n        // And it lasts: twelve hours later the base is still about 300,000 against a live supply of 100,000.\n        for (uint256 i; i < 12; ++i) {\n            vm.warp(block.timestamp + 1 hours);\n            vm.roll(block.number + 300);\n            primary.set(DOLLAR);\n            spot.set(DOLLAR);\n            health.set(0.85 ether);\n            vault.pace();\n        }\n        assertEq(vault.redemptionFeeBps(50_000 ether), 500, \"still diluted twelve hours later\");\n    }\n}","reproduction":"Fresh ParameterizedVault, IMD at $1, NHI 0.85 (mat 170). Block 1: WHALE lock(1,600,000) and draw(900,000); its pacing sees supply 0. Block 2: BOOK lock(200,000) and draw(100,000); its pacing seeds _supplyPaced = 900,000. Block 3: WHALE wipe(900,000) and free(1,500,000); live supply is back to about 100,000. Block 4: redemptionFeeBps(50,000). Expected (control without WHALE, or with the seed capped at the floor): 500 bps, since 50,000 / 100,000 / 2 stores the 4.5% cap. Actual: 328 bps, with paced() supply = 900,300e18. Twelve hourly pacings later the base is still about 254,000 (900,000 x 0.9^12) and the fee is still under 500. forge test --match-path test/scratch/SeededFeeBase.t.sol fails on a3aa9e4 (328 != 500). It passes with the seed line removed (checked in a scratch copy).","severity":"low","snippet":"        if (paced == 0) return live;","title":"Seeded paced supply lets a launch draw held for one block set the fee base in full, diluting redemption fees for about a day"},{"citation":"resolved","description":"ParameterizedVault has no pause or deposit switch. lock, lockIMD and draw are open from the constructor, so the vault is open to anyone who reads the stage-two transaction once it is mined, whatever the private relay did for the salt. Steps 3 and 4 (listing, keeper funding, prefunding the asker) and the rollback table's 'abandon only before anyone deposits' assume the operator decides when the first deposit happens. They cannot. Together with the seeded paced supply (CDPVault._pacedSupply, line 829, reported separately), the first borrower chooses the launch fee base. Fix: state in the runbook that deposits are open from the block the vault lands, and order steps 3 and 4 accordingly. Alternatively, have the operator make the first, small position in the block after deployment, so the seed is honest.","line":392,"path":"docs/MAINNET-RUNBOOK.md","reproduction":"After runVault's CREATE2 transaction is mined at block N, any account calls vault.lock(x) and vault.draw(y) in block N+1, with feeds fresh from stage one. Both succeed: no code path checks a launch flag. Expected from runbook 7.5: deposits are refused until the operator opens them. Actual: they are accepted at once, and the rollback window in section 6 closes at block N+1 without the operator acting.","severity":"info","snippet":"5. **Only then** open deposits.","title":"Runbook 7.5 'Only then open deposits' describes a gate the vault does not have; a stranger can be the first borrower the block after runVault lands"},{"citation":"resolved","description":"The comment in the CDPVault constructor and WorkOracleFactory's NatSpec (src/WorkOracleFactory.sol lines 9-11: 'at 36,416 of the 49,152 ... 52,880 bytes') give ParameterizedVault's initcode as 36,416 bytes. At a3aa9e4 it is 47,089 (paced vault panel resolution, task statement), only 2,063 bytes under EIP-3860. The conclusion still holds, but the margin these comments imply is about 12.7 KB, and the real one is about 2 KB. Any further fix to the vault has to be weighed against the real number. Fix: update both comments to 47,089 and 63,553 (47,089 + 16,464).","line":418,"path":"src/CDPVault.sol","reproduction":"forge inspect ParameterizedVault bytecode returns 94,180 hex characters (0x plus 2 x 47,089), i.e. 47,089 bytes of initcode. The comment states 36,416.","severity":"info","snippet":"            // bytes and this vault's subclass is already at 36,416 of the 49,152 EIP-3860 permits.","title":"Initcode size claims are stale: 36,416 bytes stated, 47,089 actual"}],"hash":"bf81619db7ffc5843821d2c8d74df00467af8b500ead0273e53c8aaaf11479df","nodeId":"2faf9639-ef21-47f9-b92a-673777679a82","outcome":"completed","summary":"Nothing I could prove is critical, high or medium. There is one low finding in the newest vault lines and two info items; all three are in `.imd-findings.json`. The low finding has a Foundry proof at `test/scratch/SeededFeeBase.t.sol`: it fails on a3aa9e4 and passes once the seed line is removed, which I checked in a scratch copy. No repository files were changed.\n\n## Findings\n\n**1. Low: the seeded paced supply lets a one-block draw at launch set the fee base** (`src/CDPVault.sol:829`, `if (paced == 0) return live;`, in `_pacedSupply`)\n- **How it happens:** the paced supply starts at zero. The first borrower's own pacing runs before its draw, so it still sees zero. The next transaction by anyone that moves capital then copies the whole draw into the fee base at once, with no follow-rate limit.\n- **Reproduction:** in block 1, WHALE locks 1.6M and draws 900,000. In block 2, an honest borrower (BOOK) draws 100,000, and its pacing writes 900,000 as the fee base. In block 3, WHALE repays and leaves.\n- **Expected against actual:** a 50,000 redemption should pay 500 bps (the control case). It pays 328 bps, with the stored supply at 900,300.\n- **How long it lasts:** the base falls about 10% an hour, so it is still about 254,000 after twelve hourly pacings.\n- **Who loses:** the borrowers being redeemed against, who are paid the fee; about $860 on that one 50,000 redemption. Anyone can be the first borrower by acting the block after `runVault` lands.\n- **The comment is wrong too:** \"there is no earlier base for a draw to dilute\" is false, because the draw itself becomes the base.\n- **Smallest fix:** `return Math.min(live, _feeBaseFloor());`. The base then grows from the floor at the follow rate, and day-one fees err high, which protects borrowers.\n\n**2. Info: runbook §7 step 5, \"Only then open deposits\"** (`docs/MAINNET-RUNBOOK.md:392`). The vault has no deposit switch, so `lock` and `draw` are open from the block it is deployed. The rollback window (\"abandon only before anyone deposits\") therefore closes on a stranger's schedule, not the operator's.\n\n**3. Info: stale size comments.** `src/CDPVault.sol:418` and `src/WorkOracleFactory.sol:9-11` say the vault's initcode is 36,416 bytes. `forge inspect` gives 47,089, which leaves about 2 KB under the 49,152 limit, not the 12.7 KB the comments imply.\n\n## Answers to the seven questions\n\n1. **Newest vault lines.**\n   - **`resecure`:** it only writes a position's term at the current fresh, agreed price, and any increase is excluded for the rest of that transaction. It can't lift the live or paced backing past the honest figure, and it doesn't touch eligibility, grace, health or marks. Re-pricing only some positions can leave the figure lower until the keeper re-prices the rest; that underpays and never overpays. Gas cost is the same as calling `pace()`.\n   - **`_clampPacedDebt`:** I traced draw-then-cancel and cancel-then-draw, in one transaction and across blocks, for cash, bite and cover. Every path that reduces debt either clamps or adds the amount back as a wipe. No ordering leaves zero-second debt counting.\n   - **The backing's own clock:** pacing during a stale or diverged window, then relaying a price and redeeming in the same transaction, pays at most the stored value plus 2%. Nothing accumulates, so a rise can't be banked.\n   - **Fee base reset:** only the seed issue above.\n2. **Paced figures across the system.** Treasury exits (oracle funding, reserve redemption, cover, the bad-debt-first withdrawal, donations), a mid-flight Parameters change, and SwarmRelay bundling all either lower the backing at once or raise it at most 2% an hour. Wage 0 keeps work minting closed. I found no overpayment beyond the stated rise, and no record that falls out of sync.\n3. **Launch day.** Stale feeds stop draw, cash, bark, bite and resecure, while lock and wipe stay open. Recovery is a relay through the keeper's IMD fallback. Bad debt can only be covered once the Treasury holds imdUSD, which on day one","treeHash":null,"usage":{"cachedInputTokens":3494607,"inputTokens":56,"model":"claude-opus-5-5","outputTokens":34963,"runtime":"claude","turns":29,"wallClockMs":408687}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d20c1a95c50699ea","findings":[{"citation":"resolved","description":"_pacedSupply seeds the paced supply from the live supply only while _supplyPaced == 0. The first pacing after the first mint writes whatever supply exists at that moment; from then on the paced supply follows by at most 10% of max(paced, 100,000) per paced hour. So the seed captures the first borrower's draw, not the launch-day supply. If the first draw is small (an honest 1 imdUSD test draw, or anyone front-running the launch with lock(10)/draw(1)), the paced supply is seeded at ~1 and then grows ~10,000 an hour: exactly the state paced vault panel F5 reported (fee base at the floor while the live supply is several times it; 9,000 imdUSD of burns, about 250-450 imdUSD of fee, store the 4.5% cap as everyone's base rate for a half-life). The NatSpec at CDPVault.sol:825-828 ('measuring the first day's redemptions against the floor instead of a live supply five times it would store the cap for 9,000 of burns') and the Resolution row 5 ('Fixed') claim a property that holds only when the whole first day's supply is drawn in one transaction before any other vault call. Reachable with the constants as committed: anyone, at the cost of gas and ~$2 of sIMD collateral, or simply the natural order of launch-day borrowing. Impact: redeemers pay up to 5% instead of ~1% on day one, and a band borrower can pin the cap for ~9,000 imdUSD of burns. Fix: seed while the paced supply is below the floor (e.g. `if (paced < _feeBaseFloor() && block.timestamp < deployedAt + 1 days) return live;` or seed once at the first pacing at or after a fixed launch time), or document in _feeBase and the runbook that the seed is the first draw only and have the operator make the first draw the launch book.","line":829,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault with IMD at $1, NHI 0.85. FIRST: lock(10e18), draw(1e18). 12 s later anyone calls pace() (paced supply seeded = 1e18). WHALE: lock(1,500,000e18), draw(500,000e18). One hour later pace(). Expected (the seed's stated purpose; the project's own test_launchDayFeeIsMeasuredAgainstTheLiveSupply): paced supply 500,000 and redemptionFeeBps(5,000e18) == 100. Actual: paced supply 10,001 and redemptionFeeBps(5,000e18) == 300 (forge test test/scratch/SeedGap.t.sol: 'launch-day fee measured against the live supply: 300 != 100').","severity":"low","snippet":"        if (paced == 0) return live;","title":"Launch seed of the paced supply (paced vault panel F5, 'Fixed') is defeated by any small first draw: the first day's fee base falls back to the 100,000 floor"},{"citation":"resolved","description":"The comment justifying WorkOracleFactory says the subclass is at 36,416 of 49,152 bytes. At the pinned commit `forge inspect src/ParameterizedVault.sol:ParameterizedVault bytecode` is 47,089 bytes (2,063 of headroom, as the paced vault panel's verification line records). The figure understates how close the vault is to the EIP-3860 limit by ~10.7 KB; anyone adding code on that reading would discover the limit at deploy. Fix: update the figure (or drop it and point to the verification record).","line":418,"path":"src/CDPVault.sol","reproduction":"forge inspect src/ParameterizedVault.sol:ParameterizedVault bytecode | (count bytes) -> 47089. Comment says 36,416. Expected the comment to state the current size.","severity":"info","snippet":"            // bytes and this vault's subclass is already at 36,416 of the 49,152 EIP-3860 permits.","title":"Stale size claim in the vault constructor: ParameterizedVault initcode is 47,089 bytes, not 36,416"},{"citation":"resolved","description":"ParameterizedVault has no pause, allowlist or opening switch: lock, lockIMD and draw are callable by anyone from the block runVault() lands in, as soon as the feeds are fresh and agree (they are, since verifySeeded just required it). Steps 3-4 (listing sIMD, which takes 48 hours; starting and funding the keeper; prefunding ORACLE_ASKER) therefore are NOT preconditions of borrowing: a stranger can lock and draw up to LINE while no keeper is running, no liquidator is staged and the Treasury cannot fund the oracle. The only control the operator has is not to announce, which a watcher of the CREATE2 deployer does not need. Fix: reword step 5 ('only then announce; deposits are possible from the vault's first block') and move the keeper start (step 4) and the asker prefund before runVault, so the vault never exists without them.","line":392,"path":"docs/MAINNET-RUNBOOK.md","reproduction":"After runVault() lands, any EOA with sIMD calls vault.lock(x) then vault.draw(y) in the next block (feeds fresh from runbook 7.1): both succeed; nothing in ParameterizedVault/CDPVault refers to an opening step. Expected per the runbook: deposits not possible until step 5.","severity":"info","snippet":"5. **Only then** open deposits.","title":"Runbook 7 step 5 'Only then open deposits' describes a gate the vault does not have"}],"hash":"e6ef647ba98f5d16d11a508e5efad82c45b0347fe67a0a939e4f88ba0f2aec51","nodeId":"93e6ee43-fdb9-4c44-adc6-0cdb85f134ea","outcome":"completed","summary":"I found three defects: one low and two info. Nothing is high, medium or critical with the constants as committed, though not every area got the same depth (listed at the end). The findings are in `.imd-findings.json`. I removed the scratch test I used to reproduce the low finding, and changed no other files.\n\n## Findings\n\n**1. Low — the launch seed of the paced supply only works if the first day's supply arrives in one transaction** (`src/CDPVault.sol:829`, `_pacedSupply`, `if (paced == 0) return live;`)\n- **What happens:** the seed copies whatever supply exists at the first pacing after the first mint. After that, the paced supply grows by about 10,000 an hour from the 100,000 floor.\n- **Reproduction:** someone locks 10 sIMD and draws 1 imdUSD, then anyone calls `pace()`. A 500,000 draw follows, and an hour later the vault is paced again.\n  - Expected: the fee for redeeming 5,000 is 100 bps, as in the project's own `test_launchDayFeeIsMeasuredAgainstTheLiveSupply`.\n  - Actual: the paced supply is 10,001 and the fee is 300 bps. I confirmed this with a Foundry test.\n- **Impact:** this is exactly the defect paced vault panel F5 reported, which its Resolution marks \"Fixed\". A small first draw restores it, whether honest or a front-run for gas and about $2 of sIMD. About 9,000 imdUSD of burns then store the 4.5% cap as everyone's base rate.\n- **Fix:** seed while the paced supply is below the floor during a launch window. Alternatively, document that the seed is just the first draw and make the operator's first draw the launch book.\n\n**2. Info — stale size claim** (`src/CDPVault.sol:418`). The comment says the vault is at 36,416 of 49,152 bytes. `forge inspect` gives 47,089, so the headroom is about 2 KB, not 13 KB.\n\n**3. Info — runbook claims a gate that doesn't exist** (`docs/MAINNET-RUNBOOK.md:392`, \"Only then open deposits\"). Nothing in the vault can be \"opened\": anyone can lock and draw from the block `runVault` lands in. That can happen before sIMD is listed (which takes 48 hours), before the keeper runs and before the asker is prefunded. Fix: start the keeper and prefund the asker before stage two, and reword step 5.\n\n## Answers to the numbered questions\n\n**1. The newest vault lines.**\n- **`resecure`:** it always writes the honest term at a fresh, agreed price. An increase is added to the per-transaction tally, so it is excluded from that transaction's own reads. Within one transaction it can only lower what the caller is paid. It does not touch marks, accrual, eligibility, grace or health. The cost of a flood of calls is gas only.\n- **One inherent point:** `resecure` paces before it re-prices. So the keeper's first call after a fall writes the stale low read, and recovery then runs at 2 points of par an hour. That is the stale-term read you listed as accepted, so I did not report it.\n- **`_clampPacedDebt`:** I traced draw then cash, cash then draw, wipe and draw (by the same or different callers), bite and cover, and each ordering across a block boundary. In every case the paced debt is at most the starting debt less what was cancelled, and in a same-block transaction no time has passed, so the follow step is zero. I found no zero-second debt counting. The comment's \"a position's own wipe\" is narrower than the per-transaction tally, but total debt is the same either way, so nothing is gained.\n- **The backing's own clock:** each write resets it and counts at most one hour. A read inside a transaction whose pacing ran at a stale price (feed relayed later in the same transaction) gets the same ceiling the next pacing would write, so no rise can be banked.\n- **Resetting the fee base:** this needs the total supply to be zero at the start of a transaction, which can't happen while anyone holds imdUSD, including the Treasury's fees.\n\n**2. The paced figures across the system.**\n- Treasury exits (`fundOracle`, `redeemIMD`, `withdraw`, `cover` burns) only lower the live figure, and a fall is paced at once. Donations rise at the rise ","treeHash":null,"usage":{"cachedInputTokens":4043026,"inputTokens":62,"model":"claude-opus-5-5","outputTokens":39236,"runtime":"claude","turns":33,"wallClockMs":441584}}],"verification":[]}