{"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":"fc96f209-e004-42c6-bea0-728288548eee","kind":"audit","nodes":[{"acceptedSubmissionHash":"13e4785ce845a16b7646d70b2a3b6ca493481d5539e5f3fade2da0c64e8a6455","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":"c3764890eb287fe3e4fbd996a4317194e292df369fe0942974129b0140fa9376","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":"2b3f8953da425f5a057b4fa2f8a1e2ff09592aa841d67a19eb617df3cf182a1c","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"6c2af6e89198e30fe46fb1385724e8a20411bedf9d43629e8072f7c3a2933fb5","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":"ebf8eb1b4fe5b141e3a25ff3814860180a1075d8a5a7e8f3ed075a634ddad155","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 changes since 6085c8a and what they touch: src/CDPVault.sol, src/ParameterizedVault.sol, src/Treasury.sol, script/DeployMainnet.s.sol and deploy/mainnet/, at the pinned commit, for a mainnet launch. Read whatever else in src/ these depend on, but report on this scope. Ten audit rounds and their fixes are already in (docs/AUDIT-*.md). The newest is docs/AUDIT-FINAL-SWEEP-PANEL-2026-10-08.md, a panel over the whole system at 6085c8a that found nothing above low; its fixes are git diff 6085c8a 07905bb -- src script deploy, and its Resolution section says how each finding was answered. That diff is what no panel has read and what to break first. A finding of an earlier round counts only if its fix regressed or left a gap. Three items are accepted with their reasons stated where they live, and are findings only if the reason is wrong: the repay-then-redeem premium (CDPVault._backingPerUnit), a redraw after a redemption releasing a repayment's fee share early (CDPVault._lag), and a debt-side orphan overstating the backing cap's lagged figure by a bounded amount (CDPVault._cool).\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 RESERVE'S SHARE (CDPVault._backingPerUnit). The lagged figure is now (reserve x warm / supply + warm secured collateral) / warm, warm = supply - fresh, supply = live + REPAID_THIS_TX_SLOT. Prove or break: no sequence, in one transaction or across several, lets capital brought in (by lock, draw, wipe, cover, a donation of collateral to the Treasury, or work minted) raise what a redemption is paid above the honest backing of the book it found, at launch (most supply new) or later; quantify how far an honest redeemer can be UNDERPAID by a large new loan or by work-minted supply (warm with no collateral), and whether either is a cheap grief on redemptions or the peg; check that REPAID_THIS_TX_SLOT and the reserve share interact correctly (the slot inflates supply, so it also shrinks the reserve's share), and that min(live, lagged) is still the figure every payout path reads (cash, the reserve route, the mixed route).\n2. THE FEE-BASE FLOOR (_feeBaseFloor, 100,000 imdUSD; _feeBase; _redemptionRate; the stored base rate in cash). Prove or break: the cheapest way to store the cap for everyone at launch, now; whether the floor can be used to dilute fees for anyone once the warm base is past it; what the floor costs honest redeemers while the protocol is small (a weaker brake on a run when the warm supply is far below 100,000), and whether that matters for the peg given the payout is capped at backing.\n3. THE VAULT'S DEPLOY SALT (DeployMainnet.plan, runVault, _record, PUBLIC_VAULT_SALT; docs/MAINNET-RUNBOOK.md sections 6 and 7). The salt is read from VAULT_SALT and stage two is broadcast through a private relay (MEV Blocker). Prove or break: anything committed, recorded or broadcast before stage two lands that reveals the vault's address or salt; what happens if the private relay leaks or delays the transaction, if stage two is rerun, or if a vault already sits at the address (the 'exists, skipped' path then verify); whether stage one's record, check() and plan.py still give the operator and the keeper everything they need.\n4. EVERYTHING THE DIFF TOUCHES, for regressions: the redemption invariant's model, cover and bite after the cash changes, the Treasury's fundOracle (unchanged code, rewritten NatSpec), and contract size (ParameterizedVault initcode 46,679 of 49,152 bytes).\n5. Every comment, NatSpec or runbook line changed in the diff that claims a property the code does not have.\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":"5c1e4023469dcb322716e79333372229b7058dd3ee0be55cbcc2717efcc3ce53","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"fc96f209-e004-42c6-bea0-728288548eee","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":"51166","feedbackHash":"f757326715bed145a41959eaf3084973862dbfbd3236bb2f06c158943a16cd4a","nodeKey":"audit_economics","submissionHash":"13e4785ce845a16b7646d70b2a3b6ca493481d5539e5f3fade2da0c64e8a6455","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51143","feedbackHash":"c45e67c91cec19e3a881c086c465627db7f5823eb4d6a03f4c6c5d6d5f38f17d","nodeKey":"audit_flow","submissionHash":"c3764890eb287fe3e4fbd996a4317194e292df369fe0942974129b0140fa9376","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52130","feedbackHash":"870ec2515095dc0bd8bbe58ae58a3663cd21014547ac67c08bde62c5ff6fa496","nodeKey":"audit_judge","submissionHash":"2b3f8953da425f5a057b4fa2f8a1e2ff09592aa841d67a19eb617df3cf182a1c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51142","feedbackHash":"93dfd1de1ffecc910866ef87e7a602183f44cd9d25249d02247092a20f4a6e23","nodeKey":"audit_math","submissionHash":"6c2af6e89198e30fe46fb1385724e8a20411bedf9d43629e8072f7c3a2933fb5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51423","feedbackHash":"000bc8b148eb5e08f4b049b95a4810c4e6ee2e575aca2a5e0b9c5383436b03c5","nodeKey":"audit_permissions","submissionHash":"ebf8eb1b4fe5b141e3a25ff3814860180a1075d8a5a7e8f3ed075a634ddad155","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"05f4b9433a6afe2f8c402743881e44a69c02e0d661e741e9e278d620fb820748","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"45aa937328087de3","findings":[{"citation":"resolved","description":"The final sweep panel's low #2 (anyone could CREATE2 the vault between the stages from the public salt) was answered by making the salt a secret read from VAULT_SALT and broadcasting stage two 'through a private relay (MEV Blocker), so the salt is not public before the vault exists' (script/DeployMainnet.s.sol:76-82 and 193-194, src/SwarmFeed.sol:239 'from a salt no one else knows, so no one else can deploy it first', docs/MAINNET-RUNBOOK.md:247-252, 331-334, 350-353). The runbook's concrete instruction is `--rpc-url https://rpc.mevblocker.io`. Per MEV Blocker's own documentation (docs.mevblocker.io, transaction endpoints) the bare endpoint is the `/fast` endpoint, whose order-flow auction SHARES every received transaction with the connected searchers so they can bid to backrun it; only `https://rpc.mevblocker.io/fullprivacy` ('maximum privacy, no rebates') withholds the transaction from searchers (builders still see it). The stage-two transaction is `CREATE2_FACTORY.call(bytes.concat(salt, initcode))` (DeployMainnet.sol:268); the canonical proxy binds the address to (salt, initcode) alone, so anyone holding those 32 bytes plus the public initcode can replay the identical calldata from any account and land the vault first. Searchers are bound by MEV Blocker's no-frontrun rule, but that rule is a policy of a third party the deployment has no relationship with, not a property of the system, and the comments state it as a property ('the salt is not public before the vault exists', 'no one else knows'). Reachable with the runbook as committed. Impact if it is exploited (same as the panel's #2 but AFTER verifySeeded passed, so the feeds are honest): the stranger's vault is byte-identical and correctly wired (every child contract is CREATEd by the vault itself, so Parameters, Treasury, imdUSD, the feeds and the work oracle are the same addresses); the only thing the stranger can change before the operator's verify is to lock and draw at the honest price. Then `_deploy` logs 'exists, skipped' (263-266) and `verify(p)` reverts at 'imdUSD: nonzero opening supply' (290) or 'vault: opens with debt or a work ceiling' (319), so `_record(p)` (228) never runs and deployment.json never receives the vault, stablecoin, parameters, treasury or workOracle addresses the keeper reads. There is no entry point that reruns verify or record for an existing vault: `verify(Plan)` takes a struct and `_record` is internal, so completing the deployment means editing the script. No funds are at risk and the vault is the intended one, hence low. Also unaddressed: nothing says how VAULT_SALT is generated; a salt derived from a word or phrase (the rehearsal uses `cast keccak \"rehearsal-$(date)-$RANDOM\"`) is guessable offline, and a guessed salt is a public salt.","line":351,"path":"docs/MAINNET-RUNBOOK.md","reproduction":"State: stage one landed, first values relayed, verifySeeded passed. Operator runs `VAULT_SALT=0x<secret> ... forge script script/DeployMainnet.s.sol --sig runVault() --rpc-url https://rpc.mevblocker.io --broadcast`. EXPECTED (DeployMainnet.sol:81-82, runbook 351-352): no one sees the salt until the vault exists. ACTUAL: the raw transaction, calldata `salt ++ type(ParameterizedVault).creationCode ++ abi.encode(STAKED_IMD, 0, WORK_ORACLE_SENTINEL, price, nhi, spot)` to 0x4e59b44847b379578588920cA78FbF26c0B4956C, is forwarded to MEV Blocker's searcher auction before inclusion (default endpoint = /fast). A searcher that replays the same calldata from its own account (the panel's test/scratch/VaultFrontRun.t.sol sequence, 13.8M gas) lands the vault at the planned address one block earlier; if it also calls lock + draw(1 wei) in that block, the operator's run prints 'exists, skipped' and reverts in verify at 'imdUSD: nonzero opening supply', and deployment.json is left without the vault. Smallest fix: (1) runbook 351 and DeployMainnet 81: `--rpc-url https://rpc.mevblocker.io/fullprivacy` (or Flashbots Protect with hints disabled), and state that builders still see the transaction; (2) say how to make the salt: 32 random bytes (`openssl rand -hex 32`), never a keccak of a phrase; (3) give runVault a completion path for a vault that already exists: when `p.vault.code.length != 0`, require `p.vault.codehash` equals the hash of the expected runtime and skip the two opening-state requires (290, 319), or add a `--sig finish()` that runs verify with those relaxed and writes the record. Alternatively, as the panel proposed, deploy the vault with plain CREATE from the deployer EOA, which no one can front-run and needs no secret.","severity":"low","snippet":"   (`--rpc-url https://rpc.mevblocker.io`), never a public mempool: the transaction carries the salt, and","title":"Runbook §7 step 2 names MEV Blocker's default endpoint, which shares the stage-two transaction (salt ++ initcode) with searchers before inclusion; the salt-secrecy property the fix rests on is not pro"},{"citation":"resolved","description":"The resolution of final-sweep #1 and this NatSpec price the launch pin at a 9,000 imdUSD burn with the 5% cap paid on all of it (450 imdUSD of fee). The 9,000 figure is right: with prior = 100,000e18 and divisor 2, _redemptionRate (903-909) needs amount/prior/2 = 4.5% to reach the cap. The cost is not: the base rate accumulates across calls (decayedRedemptionBaseRate() + increase), so n slices of 9,000/n in one transaction (cash is nonReentrant but re-callable from a contract) pay fees of 50 bps + 450k/n bps on the k-th slice, i.e. 45 + 202.5 x (n+1)/n imdUSD in total, about 250 for n = 100, and the stored rate is the cap all the same. Separately, a pinner who redeems against their OWN position in the 170-220% band after holding 9,000 of principal for 12 hours (FRESH_DEBT_WINDOW) keeps the whole fee as collateral in that position (the accepted b952037a cost at 835-843), so the stored cap then costs gas and 12 hours of DUTY on 9,000 (about 1.3 imdUSD). Not a vulnerability (the panel rated the 90-imdUSD version low because it was cheap; at 9,000 of principal-time or 250 imdUSD of fee it is a nuisance, not a lever), but the comment and the Resolution table overstate the pin's price by about 1.8x and omit the self-candidate route. Cheapest way to store the cap for everyone at launch, now: 9,000 imdUSD burned in many slices in one transaction against the Treasury's sIMD (if it holds any) or a band position, about 250 imdUSD of fee; or free with a 12-hour-seasoned own position in the band.","line":1066,"path":"src/CDPVault.sol","reproduction":"test/scratch/Economics.t.sol test_storingTheCapFromTheFloor_oneBurnVersusSliced (passes, logs): ParameterizedVault over MockIMD at $1, mat 170, Treasury holding 20,000 IMD. BORROWER lock(400_000e18), draw(100_000e18), 12 s later PINNER (a) cash(9_000e18, 0, 0) once: redemptionBaseRate == 0.045e18 (the cap), fee = 9,000 - gemOut = 450 imdUSD; (b) from a snapshot, the same 9,000 through a helper contract as 100 cash(90e18) calls in one transaction: redemptionBaseRate == 0.045e18, fee 249 imdUSD. EXPECTED by the comment: the cap paid on all of it (450) whichever way. ACTUAL: 249. Fix: reword 1065-1066 to 'a burn of 9,000 at divisor 2, paying about half the cap on it in slices, or nothing against a 12-hour-seasoned own position (b952037a)'; the same sentence in docs/AUDIT-FINAL-SWEEP-PANEL-2026-10-08.md's Resolution row 1.","severity":"info","snippet":"    /// of it times the divisor (9,000 at 2), the cap paid on all of it. Under the floor a redemption's","title":"CDPVault._feeBase NatSpec: storing the cap from the 100,000 floor does not cost 'the cap paid on all of it' (450 imdUSD); sliced inside one transaction the same 9,000 burn pays 249, and a 12-hour-seas"},{"citation":"resolved","description":"(1) CDPVault 762-767: 'A large new borrower therefore dilutes the reserve's part for a warm redeemer until its supply warms ... The lag underpays honest redemptions for hours in two accepted cases: a price fall's re-pricing counts as cold, and a stale position's share can stay in the total.' The dilution just introduced is a third case, and it is the largest of the three when the Treasury holds sIMD: the lagged figure is reserve/supply + warmSecured/warm, so a fresh loan of F against a book of supply S with reserve per unit r lowers the payout by r x F/(S+F) for about a day (3.0 points of par in the reproduction: book 200,000 at 80% after a fall, reserve 8,000, newcomer 800,000, the newcomer's own 12-second-old collateral giving 0.2 of the 3.2 back; the newcomer's cost is DUTY on 800,000 for a day, about 97 imdUSD, plus the collateral lock-up). Warm supply with no collateral behind it (work-minted imdUSD, which is warm at once because only cold DEBT is subtracted from supply) is a fourth: with every position cold and W of work supply warm, the lagged figure is reserve/supply exactly, zero collateral credit, which test/Redemption.invariant.t.sol:497-500 acknowledges; unreachable at launch (wage 0, earn refused) and otherwise needs the whole book to be younger than a day. Both are the direction the design chose and bounded; the count in the comment is what is wrong. (2) src/SwarmFeed.sol:239 'from a salt no one else knows, so no one else can deploy it first' and script/DeployMainnet.s.sol:81-82 'stage two is sent through a private relay (MEV Blocker), so the salt is not public before the vault exists' and docs/MAINNET-RUNBOOK.md:251-252, 332-334: true of the /fullprivacy endpoint, not of the endpoint the runbook names (see the low finding). (3) docs/MAINNET-RUNBOOK.md:252 'deployment.json records the vault only once it is deployed' and script/DeployMainnet.s.sol:456-457: true, but after a front-run plus one draw it never records it (verify reverts first), which the runbook's 'exists, skipped' resumability paragraph (238-240) does not cover. Everything else changed in the diff was checked and holds: the divisor NatSpec in CDPVault 183-185, Parameters 110-113 and DeploymentConfig 128-130 (9% of the fee base at 2, 4.5% at 1, 36% at 8, 18% at the former 4); BACKING_WARMUP 331-332 (a bank untouched a day credits nothing: _cool returns 0 at elapsed >= 1 day for both); cover 590-596 (the burn reverts when the Treasury holds less imdUSD than the re-lock's value); cash's @notice 669-670; the premium figures 773-777 (half the principal at 100%, 60% at 80%: term stays collateral-bound while 2(D-w) >= C x p); _cool 1035-1038 (a day-old orphan is 2^-4 of the stale position's cold principal, halving every six hours); _redemptionRate 901-902 (prior never zero: the floor is 100,000e18); _feeBaseFloor 1077; tail() 1346-1347; ParameterizedVault 155-157 (redemptionReserve and redeemIMD move gem only; plain IMD leaves only through fundOracle); Treasury.fundOracle 501-503 (the unwrap is in try/catch at 555-558, plain IMD already sent at 542, no revert); DeploymentConfig 36-38 (OracleAsker.ask 162 pays for any wideOpen feed); the runbook's 5% fall trigger (2000 x 2500 / 10000 = 500 bps, rise 0, verified by DeployMainnet.verify 334); the runbook's fundOracle sentence at 357-359; the test count (601 passed here); ParameterizedVault initcode 46,679 of 49,152 (forge build --sizes, runtime 22,466).","line":765,"path":"src/CDPVault.sol","reproduction":"test/scratch/Economics.t.sol test_largeNewLoanDilutesReserveShare (passes, logs): BORROWER lock(400_000e18) draw(200_000e18), two days later IMD to $0.40: backingPerUnit() == 0.84e18 (160,000 + 8,000 over 200,000). NEWCOMER lock(4_000_000e18) draw(800_000e18); next block backingPerUnit() == 0.8098e18: the reserve's part fell from 8,000/200,000 to 8,000/1,000,000 while the collateral part (160,000/200,000) is unchanged bar the sliver of the newcomer's collateral warmed in 12 seconds. EXPECTED by 765 ('two accepted cases'): this case is not among them. ACTUAL: a third accepted case, 3.0 points of par for about a day. For (2) and (3) see the low finding. Fix: reword 762-767 to count the dilution (and work-minted warm supply) among the accepted underpayments with their bounds (reserve per unit times the fresh share of supply; reserve per unit alone when the whole book is fresh); reword SwarmFeed 239 and DeployMainnet 81-82 to name the /fullprivacy endpoint and that builders still see the transaction.","severity":"info","snippet":"    /// figure and the live one stands. The lag underpays honest redemptions for hours in two accepted cases:","title":"Comments in the diff that claim more than the code has: 'two accepted cases' of underpayment while the sentence before adds a third (and work-minted warm supply is a fourth), and the 'salt no one else"}],"hash":"13e4785ce845a16b7646d70b2a3b6ca493481d5539e5f3fade2da0c64e8a6455","nodeId":"0365cf79-f3a5-4c65-a388-86906f63e1e7","outcome":"completed","summary":"The review is complete. The findings file holds three entries (one low, two info), every snippet matches the tree, the baseline suite is green (601 passed, 0 failed, 4 skipped), and the two scratch tests that back the numbers pass. No tracked file was changed.\n\n## Findings\n\n- **Low, runbook §7 step 2 (docs/MAINNET-RUNBOOK.md:351).** The salt-secrecy fix rests on broadcasting stage two through `https://rpc.mevblocker.io`. Per MEV Blocker's own docs that bare endpoint is `/fast`, whose order-flow auction shares every transaction with connected searchers before inclusion. The stage-two calldata is `salt ++ initcode` to the canonical proxy, which binds the address to nothing but those bytes, so anyone holding them can replay it from any account and land the vault first. Only `/fullprivacy` withholds the transaction from searchers. Impact is griefing only: after verifySeeded the feeds are honest and the stranger's vault is byte-identical and correctly wired. But one lock-and-draw by the stranger makes the operator's run print \"exists, skipped\" and then revert in verify at the nonzero-supply check, so deployment.json never receives the vault addresses and no entry point can rerun verify or record. Also, nothing says how VAULT_SALT is generated; a keccak of a phrase is guessable offline. Fix: name the `/fullprivacy` endpoint, mandate 32 random bytes, and let runVault complete against an existing vault by checking its code hash and relaxing the two opening-state requires. Plain CREATE from the deployer would remove the whole class.\n- **Info, CDPVault._feeBase NatSpec (line 1066).** \"9,000 at 2, the cap paid on all of it\" prices the launch pin at 450 imdUSD. The base rate accumulates across calls, so the same 9,000 in 100 slices inside one transaction stores the cap for 249 imdUSD of fee. Against a 12-hour-seasoned own position in the band it costs only gas, the accepted b952037a cost. Both measured in test/scratch/Economics.t.sol.\n- **Info, CDPVault._backingPerUnit NatSpec (line 765).** The comment counts \"two accepted cases\" of underpayment one sentence after introducing a third, the reserve dilution by a fresh loan. Measured: book 200,000 at 80% after a fall with an 8,000 reserve reads 0.84; an 800,000 cold loan drops it to 0.8098 for about a day, at a cost to the newcomer of about 97 imdUSD of stability fee. Work-minted warm supply over an all-cold book is a fourth, with zero collateral credit, which the invariant test's comment admits; unreachable at launch at wage 0.\n\n## Answers to the numbered questions\n\n1. **Reserve share.** No sequence found that raises a payout above the honest book. The lagged figure is reserve/supply plus warmSecured/warm, and for a single-cohort book warmSecured/warm equals the old book's collateral backing at any age, so the fix works from the first block, not only after hours. Lock, draw, wipe, cover, bite, work minting and a Treasury donation each leave the lagged figure at or below honest; a donation is honest backing and is never profitable to the donor. REPAID_THIS_TX_SLOT inflates both supply and warm, shrinking the reserve's share and the collateral share together, the safe direction. Underpayment by a fresh loan is reserve per unit times the fresh share of supply, roughly zero at launch since the Treasury holds no sIMD, and a few points later for about $100 a day plus collateral lock-up. All three payout routes read one `payoutScale`.\n2. **Fee floor.** Cheapest cap store today is 9,000 imdUSD burned in slices for about 250 of fee, or free with a seasoned self-candidate. Above the floor it is a max, so it dilutes nothing. Below it the brake reaches the cap after 9,000 instead of 9% of warm supply, so a run on a 20,000 book can take 45% before the cap; since payouts are pro rata and backing-neutral this costs fee revenue, not solvency.\n3. **Deploy salt.** Nothing committed or recorded reveals the salt; stage one's record omits the vault, check() prints only the address, plan.py still converges on th","treeHash":null,"usage":{"cachedInputTokens":2571082,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":56498,"runtime":"claude","turns":39,"wallClockMs":1002871}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1c29c7c6e4cf57eb","findings":[{"citation":"resolved","description":"Root cause. draw() records the new principal as cold debt (line 492), then _resecure re-derives the term as min(collateral, 2 x principal / price). For any position whose term is already its whole collateral (ratio <= 200% at the term's price, which is every position in the 170-200% band) the term does not change, so _lag(position, true, before, current) returns at after_ == before and no cold secured is recorded. _backingPerUnit's lagged figure (line 790) is reserve x warm / supply + lagSecured / warm with warm = supply - fresh: the new debt and its imdUSD leave the warm supply, but the collateral that now also stands behind them stays whole in lagSecured. The lagged figure therefore keeps the pre-draw backing while the honest (live) figure falls by up to fresh_band / supply (a draw from 200% to 170% adds 17.6% of the principal). While the live figure binds nothing happens; but the live figure is exactly what fresh capital raises: a newcomer's lock + draw at or above 200% in one transaction lifts the live figure above the lagged one (its own collateral and debt are cold, so the lagged figure is untouched), and a cash() in the next transaction is paid min(live, lagged) = the overstated lagged figure, capped at par, from the reserve (or a candidate). The newcomer unwinds afterwards: the D1 round trip the lag exists to close. This is not any of the three accepted items: the repay-then-redeem premium is the repay direction; the _cool item (final sweep panel #4) is the same arithmetic only after the position has gone stale for a day (a sixteenth of it); here the full fresh draw counts, decaying with the 6-hour half-life. The NatSpec at 757-759 ('An attacker's capital can raise the live figure but not the lagged one ... new debt and the imdUSD minted against it are excluded together') is the property the code lacks: the imdUSD is excluded, the collateral it was drawn against is not. Preconditions: a band position's draw within the last hours and a book below par (a price fall after the draw, or a book already below par from underwater positions/bad debt, in which case a warm band borrower can run the whole sequence itself); a reserve (Treasury sIMD) or an eligible candidate. Bounded by fresh_band / warm and by the gap to par; the fee (>= 0.5%) is the only cost besides gas and a one-block collateral lock. Loser: every holder (reserve) or the candidate. Reachable with the committed constants (mat 170 at NHI >= 0.85, LINE 1M, wage 0). The redemption invariant cannot catch it: its _laggedPerUnit (test/Redemption.invariant.t.sol:277) mirrors the code's formula. Smallest fix (write side, in draw): remember termBefore = position.secured before _resecure; if the term is unchanged and nonzero, cool a pro-rata slice of it with the new debt: `if (position.secured == termBefore && termBefore > position.coldSecured) _lag(position, true, termBefore, termBefore + Math.min(Math.mulDiv(termBefore, amount, position.debt), termBefore - position.coldSecured));` (moves the position's and the vault's cold secured, not securedCollateral). Checked on a copy: with it the proof passes (after the newcomer 0.878 against an honest 0.897; paid 8,518 raw IMD against at most 8,705). A read-side fix (scaling lagSecured by lagDebt/totalDebt) would double-exclude a newcomer whose collateral is already cold.","line":492,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// A band position's draw (170-200%: its term is its whole collateral) adds COLD debt and no cold secured term.\n// _backingPerUnit's lagged figure takes that debt out of the supply but leaves the collateral that now stands\n// behind it in the lagged secured term, so the lagged figure reads ABOVE the honest backing of the book, by up to\n// fresh/(supply - fresh) (17.6% for a position drawn from 200% to 170%). The live figure catches it, but the live\n// figure is what a newcomer's one-transaction-old capital raises, so lock+draw in one transaction and cash in the\n// next is paid the overstated lagged figure: the D1 round trip the lag exists to close.\n//\n// Fails on 07905bb: honest 0.897, after the newcomer 1.000, and 5,000 imdUSD is paid 9,700 raw IMD from the\n// reserve where the honest payout is at most 8,705. Passes once a debt-only draw in a collateral-bound position\n// cools a pro-rata slice of the term (see the finding's fix).\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 BdFeed 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 BdMirror is ISwarmFeed {\n    ISwarmFeed private immutable primary;\n\n    constructor(ISwarmFeed p) {\n        primary = p;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return primary.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return primary.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return primary.maxAge();\n    }\n}\n\ncontract BdAggregator {\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 BandDrawLagTest is Test {\n    address private constant BORROWER = address(0xB0B);\n    address private constant NEWCOMER = address(0xC0DE);\n\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    BdFeed private primary;\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 BdAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new BdFeed(DOLLAR);\n        BdFeed health = new BdFeed(0.85 ether); // mat 170\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new BdMirror(primary))\n        );\n        stable = vault.stablecoin();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 200_000 ether);\n        imd.mint(NEWCOMER, 400_000 ether);\n        imd.mint(address(vault.treasury()), 10_000 ether); // the reserve\n        vm.stopPrank();\n    }\n\n    function _next() private {\n        vm.roll(block.number + 1);\n        vm.warp(block.timestamp + 12);\n    }\n\n    function test_bandDrawLetsNewcomerLiftRedemptionToPar() public {\n        // A warm book: one borrower at 200% (term = its whole collateral = 2 x principal).\n        vm.startPrank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(200_000 ether);\n        vault.draw(100_000 ether);\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days);\n        _next();\n        // The borrower draws down to ~171%: 17,000 of cold debt, and the term (its whole collateral) is unchanged.\n        vm.prank(BORROWER);\n        vault.draw(17_000 ether);\n        _next();\n        // IMD halves: the book is below par.\n        primary.set(DOLLAR / 2);\n        _next();\n        uint256 honest = vault.backingPerUnit();\n        emit log_named_uint(\"honest, the live figure (5,000 + 100,000) / 117,000\", honest);\n        assertLt(honest, 1e18, \"the scenario needs a book below par\");\n        uint256 feeBps = vault.redemptionFeeBps(5_000 ether);\n        (uint256 price,) = vault.collateralPriceFeed().latestValue();\n        uint256 honestPay = Math.mulDiv(Math.mulDiv(5_000 ether, honest, 1e18) * (10_000 - feeBps) / 10_000, 1e18, price);\n\n        // A newcomer brings capital in one transaction ...\n        vm.startPrank(NEWCOMER);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(400_000 ether);\n        vault.draw(100_000 ether);\n        vm.stopPrank();\n        _next();\n        uint256 lifted = vault.backingPerUnit();\n        emit log_named_uint(\"after the newcomer\", lifted);\n        // ... and in the next is paid from the reserve at that figure.\n        vm.prank(NEWCOMER);\n        uint256 paid = vault.cash(5_000 ether, 0, address(0));\n        emit log_named_uint(\"paid for 5,000 imdUSD (raw IMD)\", paid);\n        emit log_named_uint(\"honest payout at most (raw IMD)\", honestPay);\n\n        assertLe(lifted, honest, \"fresh capital lifted a redemption's backing\");\n        assertLe(paid, honestPay, \"a redemption was paid above the honest backing of the book it found\");\n    }\n}","reproduction":"forge test --match-path test/scratch/BandDraw.t.sol (the proof below; specialist proof Proof_75bcc4265fd5 re-run unchanged). ParameterizedVault over MockIMD at $1 (Chainlink 2000e8 etched), NHI 0.85 (mat 170), Treasury holds 10,000 IMD. BORROWER lock(200_000e18), draw(100_000e18); +2 days. BORROWER draw(17_000e18) (term stays 200,000). Next block IMD to $0.50: backingPerUnit() == 897435897435897435 = (5,000 + 100,000)/117,000 (lagged reads 1.043). NEWCOMER lock(400_000e18), draw(100_000e18) in one tx; next block backingPerUnit() == 1e18 and NEWCOMER cash(5_000e18, 0, address(0)) is paid 9,700e18 raw IMD from the reserve. EXPECTED: at most 0.897e18 and 8,705.1e18 raw IMD. ACTUAL: 1.0e18 and 9,700e18 (995 IMD, $497, over the honest payout). Test fails on 07905bb with 'fresh capital lifted a redemption's backing: 1000000000000000000 > 897435897435897435'; passes with the fix above applied in a scratch copy.","severity":"medium","snippet":"        _lag(position, false, position.debt, position.debt + amount);","title":"CDPVault.draw/_backingPerUnit: a band position's debt-only draw adds cold debt but no cold secured term, so the lagged figure keeps that collateral against the warm supply and overstates backing; a on"},{"citation":"resolved","description":"Merged from audit_math (low), audit_flow (info), audit_economics (low), audit_permissions (low). runVault() calls CREATE2_FACTORY with bytes.concat(salt, _vaultInit(p)) (script/DeployMainnet.s.sol:268); the canonical deployer binds the address to (salt, initcode) only, so anyone holding the calldata can land the identical vault from any account. The runbook names https://rpc.mevblocker.io. MEV Blocker's own documentation (docs.mevblocker.io/concepts/order-flow-auction, fetched 2026-10-08): 'MEV Blocker RPC shares the transaction (without signature) with a permissioned or permissionless set of searchers'; the endpoint list (reference/api/transaction-endpoints) offers https://rpc.mevblocker.io/fullprivacy, 'Maximum privacy, no rebates', as the private option. An unsigned copy is enough: the salt is in the calldata. So the claims at script/DeployMainnet.s.sol:81-82 ('stage two is sent through a private relay (MEV Blocker), so the salt is not public before the vault exists'), src/SwarmFeed.sol:239 ('from a salt no one else knows, so no one else can deploy it first') and runbook 251-252 / 331-334 rest on a third party's no-frontrun policy, not on the transaction being private. Consequence if a recipient front-runs (after verifySeeded passed, so the feeds are honest): the copy is byte-identical and correctly wired (every child is created by the vault), the operator's transaction reverts inside the deployer (CREATE2 collision) after spending its gas, a rerun prints 'exists, skipped'; if the front-runner also locked and drew 1 wei in its bundle, verify() reverts at line 290 ('imdUSD: nonzero opening supply') or 319 and _record never writes the vault, parameters, treasury or stablecoin into deployment.json, which the keeper runs from; no entry point re-records an existing vault. No funds at risk, hence low. Also unstated: how to make the salt (the rehearsal uses cast keccak of a date and $RANDOM; a salt derived from a phrase is guessable). Smallest fix: runbook 7.2 and the script header name https://rpc.mevblocker.io/fullprivacy (or Flashbots Protect with hints off) and say builders still see it; generate VAULT_SALT as 32 random bytes (openssl rand -hex 32); give runVault a path that, when p.vault already has code, checks the codehash and records it without the opening-state requires. Or deploy the vault with plain CREATE from the deployer, which nobody can pre-empt.","line":351,"path":"docs/MAINNET-RUNBOOK.md","reproduction":"State: stage one landed, first values relayed, verifySeeded passed. Operator runs, per runbook 7.2: VAULT_SALT=0x<secret> REFERENCE_IMD_ETH_WEI=... FOUNDRY_PROFILE=deploy forge script script/DeployMainnet.s.sol --sig runVault() --rpc-url https://rpc.mevblocker.io --broadcast. EXPECTED (DeployMainnet.s.sol:81-82, runbook 351-352): no one but the operator sees the salt until the vault exists. ACTUAL: the default endpoint forwards the transaction (unsigned) to its searcher set; calldata = salt ++ ParameterizedVault initcode to 0x4e59b44847b379578588920cA78FbF26c0B4956C. A recipient replaying it plus lock and draw(1) lands first; the operator's run then logs 'exists, skipped' (DeployMainnet.s.sol:263-266) and reverts in verify at 'imdUSD: nonzero opening supply' (line 290), so deployment.json gets no vault. Not a Foundry-reproducible property (off-chain relay policy); verified against docs.mevblocker.io on 2026-10-08, and the deploy-side consequences by reading _deploy, verify and _record (lines 262-272, 276-319, 458).","severity":"low","snippet":"   (`--rpc-url https://rpc.mevblocker.io`), never a public mempool: the transaction carries the salt, and","title":"Runbook 7.2 sends the salt-carrying stage-two transaction to MEV Blocker's default endpoint, which shares transactions with searchers; the 'salt is not public before the vault exists' property the fin"},{"citation":"resolved","description":"From audit_permissions (low), reproduced. The lagged figure is (reserve x warm/supply + warm secured)/warm. A newcomer's cold capital halves every 6 hours, so after t seconds a fraction 1 - 2^(-t/6h) of both its debt and its secured term is warm. A newcomer at >= 200% contributes about 2 of secured value per warm unit of its debt, so averaged with a warm book backed at b < 1 the lagged figure reaches par once its warm fraction reaches about (1-b)/((2-b)k) for a loan k times the warm book: about 1.5% for k = 9 at b = 0.84, i.e. 10 minutes. The live figure is above par throughout (the newcomer's collateral), so min(live, lagged) = par and a reserve-funded cash() in that window pays par; the newcomer then wipes and frees. The NatSpec at 321-322 ('capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par') and at 757-758 ('An attacker's capital can raise the live figure but not the lagged one') state it as a property; 'a day' (316-320) is how long the newcomer takes to count in full, not how long it takes the payout to reach its cap. Gain bounded by (1 - b - fee) x the Treasury's sIMD (position-funded payouts stay pro rata via RedemptionWorsensRatio); cost: collateral worth ~2k times the warm book locked for the window (9,000,000 IMD, about $3.8M at $0.42, against a $2.3M-a-side market, so k = 9 is hard to source; k = 3 takes 29 minutes per the specialist), price exposure and duty. Hence low. Smallest fix: state the warm-fraction behaviour and its k-dependence at the lines above and in runbook section 7 (keep Treasury sIMD small while the book is thin); a code change (e.g. capping each fresh unit's lagged contribution at par, or counting new capital only after a half-life) is an economic-rule decision for the requester.","line":321,"path":"src/CDPVault.sol","reproduction":"test/scratch/Q1Probe.t.sol::test_timeToPar (passes, logs): ParameterizedVault over MockIMD at $1, NHI 0.85, no reserve. OLD lock(200_000e18), draw(100_000e18); +2 days; IMD to $0.42: backingPerUnit() == 0.84e18. NEW lock(9_000_000e18), draw(900_000e18) in one tx; then backingPerUnit() is read every 60 s. EXPECTED per lines 321-322: capital brought in and withdrawn a few transactions later cannot authorise a redemption at par (the figure stays near 0.84 for hours). ACTUAL: backingPerUnit() == 1e18 after 10 minutes.","severity":"low","snippet":"    /// position's own cold first and counts at once. So capital brought in one transaction and withdrawn a","title":"CDPVault lag: new capital is counted by its warmed fraction in an average, so a loan k times the warm book lifts a below-par book's redemption figure to par in minutes (10 min at k = 9), contradicting"},{"citation":"resolved","description":"Merged from audit_flow (2 infos), audit_economics (info), audit_permissions (info). Q1's underpayment side: a fresh loan D dilutes the lagged figure's reserve term by D/(supply+D) while adding no warm collateral, so an honest reserve-funded redeemer is underpaid by about (reserve/supply_before) x D/(supply_before + D) until the loan warms. In the live figure the same dilution is outweighed by the newcomer's collateral, so the live figure RISES; 'as it does in the live figure' (line 764) is true of the reserve term only. The sentence at 765 then counts 'two accepted cases' of underpayment, leaving out this third (work-minted warm supply with no collateral would be a fourth, unreachable at launch with wage 0). Not a cheap grief: it needs a loan comparable to the whole supply with collateral at >= 170%, it only bites below par, and it reverses within a couple of hours as the same loan warms (and then lifts the figure, see the low finding on warm-up). The payout stays capped at backing, so the peg floor is not harmed. Q1 otherwise holds: REPAID_THIS_TX_SLOT is added to supply in both the reserve share and the divisor, so the reserve term stays reserve/supply exactly as in the live figure; cash() computes payoutScale once (line 716) and the reserve route and mixed route both read it. Smallest fix: reword 762-767 to count the dilution among the accepted underpayments with its bound, and drop 'as it does in the live figure'.","line":765,"path":"src/CDPVault.sol","reproduction":"test/scratch/Q1Probe.t.sol::test_dilution (passes, logs): Treasury holds 20,000 IMD; OLD lock(200_000e18), draw(100_000e18); +2 days; IMD to $0.40: backingPerUnit() == 0.88e18. NEW lock(5_000_000e18), draw(900_000e18); next block backingPerUnit() == 812143724142315685 (lagged 8,000/1,000,000 + 80,000/100,000 plus one block of warming) while the live figure is par; +2 h: 1e18. EXPECTED per 764-765: the dilution is as in the live figure and not among the accepted underpayments. ACTUAL: the live figure is par, the payout figure 0.812, 7.7% under the book the redeemer found.","severity":"info","snippet":"    /// figure and the live one stands. The lag underpays honest redemptions for hours in two accepted cases:","title":"CDPVault._backingPerUnit NatSpec: the new-borrower dilution is not 'as it does in the live figure' (the live figure rises), and 'two accepted cases' omits it; measured 0.88 -> 0.812 for an honest rede"},{"citation":"resolved","description":"Merged from audit_math and audit_economics (info). _redemptionRate adds each burn's increase to the decayed stored rate, and each burn pays the floor plus the rate including only its own increase, so slices pay 50, 55, ... 500 bps and store the same 0.045e18. The 9,000 threshold is right; the price is about 1.8x overstated (and the same row in docs/AUDIT-FINAL-SWEEP-PANEL-2026-10-08.md Resolution #1). With a 12-hour-seasoned own position in the 170-220% band as the candidate (accepted b952037a) the fee stays in the pinner's collateral. Q2 otherwise: the floor is a max, inert once the warm base passes 100,000, so it dilutes nobody's fee; while the warm supply W is under 100,000 a run raises the rate by W/200,000 at most, a weaker brake, but the payout is capped at backing so remaining holders lose nothing. Fix: reword to 'about 250 imdUSD of fee in small burns, 450 in one'.","line":1066,"path":"src/CDPVault.sol","reproduction":"test/scratch/PinSplit.t.sol (passes, logs): ParameterizedVault at $1, B lock(400_000e18) draw(100_000e18), Treasury 100,000 IMD, +12 s. test_oneBurn: cash(9_000e18) -> redemptionBaseRate 45000000000000000, fee 450.0. test_ninetyBurns: 90 x cash(100e18) in one block -> redemptionBaseRate 45000000000000000, fee 249.75. EXPECTED per line 1066: the cap paid on all of it (450). ACTUAL: 249.75.","severity":"info","snippet":"    /// of it times the divisor (9,000 at 2), the cap paid on all of it. Under the floor a redemption's","title":"CDPVault._feeBase NatSpec: storing the cap from the 100,000 floor does not cost 'the cap paid on all of it'; split into ninety 100 imdUSD burns the same 9,000 pays 249.75 of fee, not 450"},{"citation":"resolved","description":"From audit_permissions (info) and audit_flow. plan() derives p.vault from the environment; _deploy skips only when that address has code; nothing reads the stage-two record back. Same salt: 'exists, skipped', verify, record (fine). Any other nonzero salt (lost, retyped, regenerated as rehearse-fork.sh does per run) passes verifySeeded, deploys a second correct ParameterizedVault with its own imdUSD, Parameters, Treasury, oracle and feeds (~12.7M gas), and _record overwrites deployment.json, so the keeper follows the second vault while the first stays live. Operator-only, no funds lost. Fix: in runVault refuse when deployment.json already records a vault with code at a different address, or record keccak256(salt) in stage one and check it; add 'rerun with the same VAULT_SALT' to runbook 7.2.","line":160,"path":"script/DeployMainnet.s.sol","reproduction":"On a fork after a successful stage two: VAULT_SALT=0xaa..aa forge script ... --sig runVault() --broadcast (vault A, deployment.json vault = A); then VAULT_SALT=0xbb..bb ... --sig runVault() --broadcast. EXPECTED per header lines 89-91: 'exists, skipped'. ACTUAL by the code path (lines 160-161, 224, 262-271, 458): p.vault is a new address with no code, the deployer is called, vault B is created, verify(p) passes, deployment.json now names B.","severity":"info","snippet":"        bytes32 salt = vm.envOr(\"VAULT_SALT\", bytes32(0));","title":"DeployMainnet.runVault: a rerun with a different VAULT_SALT deploys a second vault stack and overwrites deployment.json; the header's 'resumable ... never redeployed' no longer holds for the vault, an"},{"citation":"resolved","description":"(1) CDPVault 1347: tail() = min(price, NHI maxAge) = 1 hour; the four lifetimes do not agree (PRICE 1 h, SPOT 1 h, NHI 1 day, ETH_USD 2 h, DeploymentConfig 30, 39-41); presumably 'the minimum would be the same' was meant. (2) docs/MAINNET-RUNBOOK.md 242: 'salts infer-protocol/mainnet/v1/<Contract>' is now false for the vault, whose salt is the operator's secret (the paragraph below it says so). (3) runbook 238-240 and DeployMainnet header 89-92: run() 'reads the whole stack back off chain before writing deployment.json, which is what the keeper runs from'; since the split run() reads back only the feeds and asker (verifyFeeds, line 209) and records no vault; the keeper's record comes from runVault. Everything else changed in the diff was checked and holds: divisor NatSpec (CDPVault 183-185, Parameters 110-113, DeploymentConfig 128-130); BACKING_WARMUP 331-332 (_cool returns 0 at a day for banks too); cover 590-596 against the burn at 616; cash @notice; the premium figures 773-777; _cool 1035-1038 for the stale orphan (but see the medium finding for the unstale case); redemptionReserve (gem only); Treasury.fundOracle NatSpec against its try/catch; 'prior is never zero' (floor). Contract size: ParameterizedVault initcode 46,679 B (2,473 under EIP-3860), runtime 22,466 B (forge build --sizes). forge test (excluding test/scratch): 601 passed, 0 failed, 4 skipped. The redemption invariant's _laggedPerUnit mirrors the new formula exactly, so it cannot detect the band-draw gap. Read in full: src/CDPVault.sol (the lag, backing, fee, draw/wipe/cover/cash paths), script/DeployMainnet.s.sol (plan, run, runVault, _deploy, verify, _record), the diff 6085c8a..07905bb, runbook sections 6-7. Read in part: ParameterizedVault, Treasury.fundOracle, DeploymentConfig, Parameters, SwarmFeed (changed lines). Not reached: UsdPriceFeed, SharePriceFeed, ImdUSD, the factories, SwarmWorkOracle, OracleAsker beyond ask, deploy/mainnet/plan.py beyond the vault regex.","line":1347,"path":"src/CDPVault.sol","reproduction":"(1) read CDPVault.sol 1350-1352 against DeploymentConfig.sol 30, 39-41: min(1 hours, 1 days) = 1 hour while ETH_USD_MAX_AGE = 2 hours and NHI_MAX_AGE = 1 day. (2) runbook 242 against DeployMainnet.s.sol 220-224 (vault salt from VAULT_SALT). (3) DeployMainnet.s.sol 195-213 (run(): verifyFeeds(p, true); _record; no vault) against runbook 238-240.","severity":"info","snippet":"    /// feeds' lifetimes (the spot feed and Chainlink are not read; at the shipped constants all agree).","title":"Changed or adjacent comments that claim what the code does not: tail() 'all agree', runbook 'salts infer-protocol/mainnet/v1/<Contract>' for the vault, and run() 'reads the whole stack back' after the"}],"hash":"2b3f8953da425f5a057b4fa2f8a1e2ff09592aa841d67a19eb617df3cf182a1c","nodeId":"dcc5ce05-0643-44eb-a050-cfc46884cdd9","outcome":"completed","summary":"I found one medium, two lows and four informational items. All seven are in `.imd-findings.json`, and each was reproduced against 07905bb. The full suite passes (601 passed, 0 failed, 4 skipped, excluding my scratch tests). `ParameterizedVault` initcode is 46,679 bytes, 2,473 under the limit. I've deleted my scratch tests, but the medium finding's proof is embedded in the JSON.\n\n**Medium: a band position's draw lets a redemption be paid more than the book's real backing** (`src/CDPVault.sol` `draw`, line 492; affects `_backingPerUnit`)\n- **Cause:** a position at 170–200% that draws gets its new debt marked \"cold\" (excluded from the lagged figure until it ages). Its collateral term doesn't change, so none of the collateral is marked cold. The lagged figure therefore drops the new imdUSD but keeps all the collateral behind it, and reads high.\n- **Exploit:** a newcomer locks and draws at 200% in one transaction, which raises the live figure. A redemption in the next transaction is then paid the inflated lagged figure, and the newcomer unwinds. This is the round trip the lag (the D1 fix) was meant to close.\n- **Not already accepted:** the `_cool` item covers the same arithmetic only after the position has sat untouched for a day. Here the whole fresh draw counts.\n- **Reproduction:** the attached proof, re-run unchanged, fails as claimed. Honest backing is 0.897; after the newcomer it reads 1.0. Redeeming 5,000 imdUSD pays 9,700 IMD from the reserve where at most 8,705 is honest, about $497 over.\n- **Fix:** in `draw`, when the collateral term is unchanged, mark a matching share of it cold along with the new debt. I applied this in a scratch copy and the proof passes (0.878 against an honest 0.897).\n\n**Low**\n1. **The vault's salt is not private** (`docs/MAINNET-RUNBOOK.md:351`). The runbook names MEV Blocker's default endpoint. MEV Blocker's docs say it \"shares the transaction (without signature) with a permissioned or permissionless set of searchers\", and the calldata contains the salt.\n   - **Consequence:** whoever front-runs gets an identical, correctly wired vault, so no funds are at risk. But if they also draw 1 wei, the operator's `verify` reverts and `deployment.json`, which the keeper runs from, never records the vault.\n   - **Fix:** use `/fullprivacy` or Flashbots Protect, generate the salt with `openssl rand -hex 32`, and add a path that records a vault that already exists. Four specialists reported this; it's merged into one finding.\n2. **New capital lifts the payout to par within minutes** (`src/CDPVault.sol:321`). A loan nine times the warm book takes a book backed at 0.84 to par in 10 minutes in my test. That contradicts the comments saying capital brought in and withdrawn a few transactions later can't do this. The gain is limited by how much sIMD the Treasury holds, and it needs about $3.8M of collateral.\n\n**Info**\n- **Dilution not documented:** a 900,000 loan cuts an honest redeemer's figure from 0.88 to 0.812 for about two hours. The comment says this happens \"as it does in the live figure\" (the live figure actually rises), and its \"two accepted cases\" leaves this one out.\n- **Fee-pin cost overstated:** the `_feeBase` comment says pinning the fee at the cap costs 450 imdUSD of fee. Split into 90 small burns it costs 249.75.\n- **Second vault on rerun:** running `runVault` again with a different `VAULT_SALT` deploys a second vault and overwrites `deployment.json`.\n- **Stale comments:** `tail()` says the feed lifetimes \"all agree\" (they don't). The runbook still lists the vault's salt as `infer-protocol/mainnet/v1/<Contract>`. It also says `run()` reads the whole stack back, which stopped being true after the two-stage split.\n\n**Questions where nothing is wrong:**\n- The repaid-this-transaction supply and the reserve share interact correctly.\n- `cash` computes the payout figure once and every route reads it.\n- The fee-base floor only applies while the warm base is under 100,000, so it can't dilute anyone's fee once pas","treeHash":null,"usage":{"cachedInputTokens":2714255,"inputTokens":52,"model":"claude-opus-5-5","outputTokens":32934,"runtime":"claude","turns":27,"wallClockMs":459746}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e57a8e639cccfbab","findings":[{"citation":"resolved","description":"The lagged figure is reserve/supply + lagSecured/warm with warm = supply - fresh and fresh = totalDebt - lagDebt (the cold principal). That is the warm book's own ratio only if every unit of cold principal also took its collateral out of lagSecured. For a position whose term is its whole collateral (170% to 200%, the band every borrower near the minimum sits in) a draw changes the principal and not the term: `draw` calls `_lag(position, false, debt, debt + amount)` (cold debt grows) and then `_resecure`, where `_secured` returns min(collateral, 2 x principal / price) = collateral as before, so `_lag(position, true, before, current)` returns at `after_ == before` and no cold secured term is recorded. The new debt is taken out of the lagged supply but the collateral that now stands behind it stays in lagSecured in full, so the lagged figure stays where it was while the honest backing falls. The live figure does fall, and binds, until something that does not warm the lagged figure raises the live one: a newcomer's lock+draw at 200% in one transaction (its collateral and debt are both cold, so the lagged figure is untouched and the live one rises). Then min(live, lagged) is the overstated lagged figure, capped at par, and a redemption in the next transaction is paid it, from the reserve or from a candidate's collateral, after which the newcomer wipes and frees: the D1 round trip the lag exists to close, and the sequence the final sweep panel's low F3 fix (the reserve share, this line) was meant to end. The overstatement is fresh_band / warm: a position drawn from 200% to mat (170) adds 17.6% of its prior principal as debt-only cold debt, so a book dominated by such positions reads up to 1.176x its honest backing, bounded by the gap to par; the fee is at most 5%. The NatSpec at lines 757-759 ('An attacker's capital can raise the live figure but not the lagged one, whichever position it sits in: new debt and the imdUSD minted against it are excluded together') is the claim the code does not have: the imdUSD is excluded, the collateral it was drawn against is not. The accepted `_cool` item (final sweep panel info F4, lines 1035-1038) is the same arithmetic after the band position has gone stale for a day, where only a sixteenth survives; here nothing is stale and all of it counts for the first hours (halving every six). Reachable with the constants as committed: mat 170 at NHI >= 0.85, LINE $1M, wage 0 (work minting is not involved); it needs a band position's draw within the last hours, a book below par (a fall of more than 41% from 170%, which the runbook records IMD doing in a day), and either Treasury sIMD or a candidate whose ratio is at or above the payout. Who loses: the reserve (every holder) or the candidate. Smallest fix, write side, in `draw`: when the term is collateral-bound before and after the draw, cool a pro-rata slice of it along with the new debt, i.e. keep `uint256 termBefore = position.secured;` before `_resecure(position, _priceOrZero());` and after it `if (position.secured == termBefore && termBefore != 0) _lag(position, true, termBefore, termBefore + Math.min(Math.mulDiv(termBefore, amount, position.debt), termBefore - position.coldSecured));` (one `_lag` call that adds to the position's and the vault's cold secured without moving `securedCollateral`; its bank credit only returns warmth this position itself lost; a few dozen bytes against the 2,473-byte initcode margin). With it the proof's figure after the newcomer is about 0.878 against an honest 0.897 (the lag's underpaying direction) and the reserve pays at most the honest figure. The read side cannot fix it: scaling lagSecured by lagDebt/totalDebt would double-exclude a newcomer whose collateral is already out and underpay every redeemer by the newcomer's whole share.","line":790,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// A band position's draw (170-200%: its term is its whole collateral) adds COLD debt and no cold secured term.\n// _backingPerUnit's lagged figure takes that debt out of the supply but leaves the collateral that now stands\n// behind it in the lagged secured term, so the lagged figure reads ABOVE the honest backing of the book, by up to\n// fresh/(supply - fresh) (17.6% for a position drawn from 200% to 170%). The live figure catches it, but the live\n// figure is what a newcomer's one-transaction-old capital raises, so lock+draw in one transaction and cash in the\n// next is paid the overstated lagged figure: the D1 round trip the lag exists to close.\n//\n// Fails on 07905bb: honest 0.897, after the newcomer 1.000, and 5,000 imdUSD is paid 9,700 raw IMD from the\n// reserve where the honest payout is at most 8,705. Passes once a debt-only draw in a collateral-bound position\n// cools a pro-rata slice of the term (see the finding's fix).\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 BdFeed 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 BdMirror is ISwarmFeed {\n    ISwarmFeed private immutable primary;\n\n    constructor(ISwarmFeed p) {\n        primary = p;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return primary.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return primary.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return primary.maxAge();\n    }\n}\n\ncontract BdAggregator {\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 BandDrawLagTest is Test {\n    address private constant BORROWER = address(0xB0B);\n    address private constant NEWCOMER = address(0xC0DE);\n\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    BdFeed private primary;\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 BdAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new BdFeed(DOLLAR);\n        BdFeed health = new BdFeed(0.85 ether); // mat 170\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new BdMirror(primary))\n        );\n        stable = vault.stablecoin();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 200_000 ether);\n        imd.mint(NEWCOMER, 400_000 ether);\n        imd.mint(address(vault.treasury()), 10_000 ether); // the reserve\n        vm.stopPrank();\n    }\n\n    function _next() private {\n        vm.roll(block.number + 1);\n        vm.warp(block.timestamp + 12);\n    }\n\n    function test_bandDrawLetsNewcomerLiftRedemptionToPar() public {\n        // A warm book: one borrower at 200% (term = its whole collateral = 2 x principal).\n        vm.startPrank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(200_000 ether);\n        vault.draw(100_000 ether);\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days);\n        _next();\n        // The borrower draws down to ~171%: 17,000 of cold debt, and the term (its whole collateral) is unchanged.\n        vm.prank(BORROWER);\n        vault.draw(17_000 ether);\n        _next();\n        // IMD halves: the book is below par.\n        primary.set(DOLLAR / 2);\n        _next();\n        uint256 honest = vault.backingPerUnit();\n        emit log_named_uint(\"honest, the live figure (5,000 + 100,000) / 117,000\", honest);\n        assertLt(honest, 1e18, \"the scenario needs a book below par\");\n        uint256 feeBps = vault.redemptionFeeBps(5_000 ether);\n        (uint256 price,) = vault.collateralPriceFeed().latestValue();\n        uint256 honestPay = Math.mulDiv(Math.mulDiv(5_000 ether, honest, 1e18) * (10_000 - feeBps) / 10_000, 1e18, price);\n\n        // A newcomer brings capital in one transaction ...\n        vm.startPrank(NEWCOMER);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(400_000 ether);\n        vault.draw(100_000 ether);\n        vm.stopPrank();\n        _next();\n        uint256 lifted = vault.backingPerUnit();\n        emit log_named_uint(\"after the newcomer\", lifted);\n        // ... and in the next is paid from the reserve at that figure.\n        vm.prank(NEWCOMER);\n        uint256 paid = vault.cash(5_000 ether, 0, address(0));\n        emit log_named_uint(\"paid for 5,000 imdUSD (raw IMD)\", paid);\n        emit log_named_uint(\"honest payout at most (raw IMD)\", honestPay);\n\n        assertLe(lifted, honest, \"fresh capital lifted a redemption's backing\");\n        assertLe(paid, honestPay, \"a redemption was paid above the honest backing of the book it found\");\n    }\n}","reproduction":"ParameterizedVault over an 18-decimal MockIMD at $1 (IMD/ETH 1/2000 x a Chainlink ETH/USD of 2000e8 etched at CHAINLINK_ETH_USD), NHI 0.85 (mat 170), TreasuryFactory etched at TREASURY_FACTORY, the Treasury given 10,000 IMD (the reserve). BORROWER lock(200_000e18), draw(100_000e18); two days (warm). BORROWER draw(17_000e18): 170.9%, cold debt 17,000, term unchanged at 200,000. Next block IMD to $0.50. backingPerUnit() == 897435897435897435: the live figure (5,000 + 100,000) / 117,000, the lagged one reading 1.0427 (5,000/117,000 + 100,000/100,000). NEWCOMER lock(400_000e18), draw(100_000e18) in one transaction; 12 s later backingPerUnit() == 1000000000000000000 and NEWCOMER cash(5_000e18, 0, address(0)) is paid 9,700e18 raw IMD from the reserve (par less the 3% fee). EXPECTED: at most the honest figure, 0.897e18, and at most 8,705e18 raw IMD. ACTUAL: 1.0e18 and 9,700e18: 995 IMD ($497) over the honest payout, from every holder's reserve, for a position the newcomer unwinds in the next transaction. The attached test fails on 07905bb with 'fresh capital lifted a redemption's backing: 1000000000000000000 > 897435897435897435'.","severity":"medium","snippet":"            uint256 lagged = Math.mulDiv(Math.mulDiv(reserve, warm, supply) + _securedCollateralValue(price, true), 1e18, warm);","title":"CDPVault._backingPerUnit: a band position's debt-only draw leaves its collateral whole in the lagged secured term while its new debt leaves the warm supply, so the lagged figure overstates the book by"},{"citation":"resolved","description":"The secret-salt fix (final sweep panel low F2) rests on the salt never being public before the vault lands: `runVault` broadcasts `salt ++ initcode` to the canonical CREATE2 proxy, which binds the address to (salt, initcode) and not to the sender, so whoever sees the transaction first can land the same vault at the same address first. The runbook (line 351) and the script header (script/DeployMainnet.s.sol:81, 'stage two is sent through a private relay (MEV Blocker), so the salt is not public before the vault exists') name MEV Blocker's default endpoint. MEV Blocker's documentation (docs.mevblocker.io, 'how it works') states that it 'shares transactions with searchers via backrun bundles', and lists a separate 'Full privacy' endpoint ('Maximum privacy, no rebates'); the default is an order-flow auction in which searchers receive the transaction to bid on backrunning it. A searcher that receives it holds the salt and the full initcode and can submit a bundle with its own copy of the call ahead of the operator's. What then happens with the script as committed: the operator's transaction reverts inside the proxy (the address is occupied; the deploy gas, about 13M, is paid), the vault at the planned address is the correct one, and on a rerun `_deploy` prints 'exists, skipped' and `verify(p)` runs; if the searcher also locked and drew one wei in its bundle (at the first values the operator had just verified, so no mispricing), `verify` reverts at `totalSupply() == 0` / `totalDebt() == 0` and `_record` never writes deployment.json for the vault, so the keeper's configuration must be assembled by hand. No user funds are at risk (nothing is announced) and the searcher gains nothing but a loan; the property the two documents claim is simply not what the named endpoint provides. Reachable with the runbook as written on mainnet. Smallest fix: name the full-privacy endpoint (rpc.mevblocker.io/fullprivacy) or Flashbots Protect with hints disabled in runbook 7.2 and the script header, and say that the default MEV Blocker endpoint shares the transaction with searchers. Everything else about the salt holds: stage one's broadcast, record and bodies carry nothing derived from it; `broadcast/` and `deploy/mainnet/out/*.json` are gitignored; `check()` prints the planned vault only when VAULT_SALT is set (plan.py's regex does not pick it up); `vm.envBytes32` refuses a short hex or a passphrase ('invalid string length') and `vm.envOr` maps a malformed salt to zero in `plan()` so `runVault` then reverts at `envBytes32`; a rerun with the same salt is 'exists, skipped' then `verify`; a rerun with a different salt deploys a second vault.","line":351,"path":"docs/MAINNET-RUNBOOK.md","reproduction":"State: stage one landed, first values verified, the operator runs `VAULT_SALT=0x<secret> ... forge script script/DeployMainnet.s.sol --sig \"runVault()\" --rpc-url https://rpc.mevblocker.io --broadcast`. The default endpoint forwards the raw transaction (calldata = salt ++ _vaultInit(p), about 22 KB) to searchers for backrun bidding. A searcher sends `CREATE2_FACTORY.call(bytes.concat(salt, _vaultInit(p)))` in a bundle ahead of it (the panel's test/scratch/VaultFrontRun.t.sol sequence, 13.8M gas, lands at exactly `_at(salt, _vaultInit(p))`). EXPECTED (runbook 351-352, script header 81-82): the salt is not public before the vault exists, so nobody else can deploy it first. ACTUAL: the searcher's copy lands first; the operator's transaction reverts in the proxy and pays its gas; a rerun prints 'exists, skipped'; with one wei drawn by the searcher in the same bundle, `verify` reverts at line 290 ('imdUSD: nonzero opening supply') and deployment.json is never written for the vault. Not reproducible in Foundry (it is an off-chain relay property); checked against docs.mevblocker.io on 2026-10-08.","severity":"low","snippet":"   (`--rpc-url https://rpc.mevblocker.io`), never a public mempool: the transaction carries the salt, and","title":"Runbook section 7 and the DeployMainnet header say the stage-two transaction is not public before the vault exists, but the endpoint they name (rpc.mevblocker.io) shares transactions with searchers; t"},{"citation":"resolved","description":"The increase accumulates on the decayed base (`_redemptionRate`: min(decayedRedemptionBaseRate() + amount / prior / divisor, cap)) and each burn's fee is the floor plus the rate stored BEFORE it, so a pinner who splits the 9,000 imdUSD into burns of 100 pays 50, 55, 60 ... 495 bps on successive burns (average 277.5 bps) and stores the same 0.045e18 rate as one 9,000 burn paying 500 bps on all of it. The NatSpec's figure for the pin's cost is therefore 1.8x too high; the burn total (9,000 at divisor 2) is right. The rest of the floor holds: once the warm base is past 100,000 the floor is a max and dilutes nothing; `prior` is read once before the candidate is touched so the quote and the stored rate agree; honest redeemers while the warm supply is under 100,000 pay less than the un-floored formula would charge (a run through the whole warm supply W raises the rate by W/200,000, so the brake is weaker while the protocol is small), a cost to the brake and not to them, and the payout stays capped at backing so the peg floor is unaffected. Smallest fix: reword to 'about 250 imdUSD of fee split into small burns, 450 in one'.","line":1066,"path":"src/CDPVault.sol","reproduction":"test/scratch/PinSplit.t.sol (logs): ParameterizedVault at $1 (Chainlink 2000e8 etched), BORROWER lock(400_000e18), draw(100_000e18), PINNER holds 9,000 imdUSD, Treasury holds 100,000 IMD so every burn is reserve-funded; 12 s later (base = the 100,000 floor). test_oneBurn: cash(9_000e18) stores redemptionBaseRate 45000000000000000 and pays 450e18 of fee. test_ninetyBurns: ninety cash(100e18) in one block store the same 45000000000000000 and pay 249.75e18 of fee in total. EXPECTED per the NatSpec: the cap paid on all of it (450). ACTUAL: 249.75.","severity":"info","snippet":"    /// of it times the divisor (9,000 at 2), the cap paid on all of it. Under the floor a redemption's","title":"CDPVault._feeBase NatSpec: storing the cap from the 100,000 floor does not cost 'the cap paid on all of it'; split into ninety 100 imdUSD burns the 9,000 burn pays 249.75 of fee, not 450"}],"hash":"6c2af6e89198e30fe46fb1385724e8a20411bedf9d43629e8072f7c3a2933fb5","nodeId":"3d2944ff-30c0-4f20-bbf5-59c329230717","outcome":"completed","summary":"The working tree is clean apart from the findings file (which is listed in neither `git status` because it is untracked-and-ignored nor as a stray file), and `.imd-findings.json` is valid. The review is complete.\n\n## Result\n\nThree findings are in `.imd-findings.json`: one medium with a Foundry proof, one low, one info. The diff since 6085c8a fixes what the final sweep panel reported, but the reserve-share fix leaves a gap in the same figure it changed.\n\n**Medium, `src/CDPVault.sol:790`, `_backingPerUnit`.** A borrower whose term is its whole collateral (the 170 to 200% band) adds cold debt and no cold secured term when it draws, because `_secured` returns the same collateral before and after. The lagged figure drops that debt from the warm supply but keeps the collateral now standing behind it, so it reads above the honest backing by up to fresh/warm (17.6% for a draw from 200% to 170%). The live figure catches it until a newcomer's one-transaction lock and draw at 200% lifts the live figure, which is the D1 sequence the lag exists to close. Proof: book of 200,000 IMD against 100,000 imdUSD warms two days, draws 17,000 more, IMD halves. Honest backing reads 0.897. After the newcomer it reads 1.000 and a 5,000 imdUSD reserve redemption pays 9,700 raw IMD where the honest ceiling is 8,705. The fix is write side in `draw`: when the term is collateral-bound before and after, cool a pro-rata slice of it along with the new debt through one `_lag` call. The attached test fails on 07905bb and passes under that fix by the lag's underpaying direction.\n\n**Low, `docs/MAINNET-RUNBOOK.md:351` and the script header.** Both claim the stage-two transaction is not public before the vault exists, but the named endpoint is MEV Blocker's default, whose own documentation says it shares transactions with searchers via backrun bundles; the \"Full privacy\" endpoint is separate. A searcher holding the salt and initcode can place the vault first; the operator's transaction then reverts in the proxy, and if the searcher also drew one wei, `verify` reverts on nonzero supply and `deployment.json` is never written. Nothing else about the salt leaks: stage one records nothing derived from it, `broadcast/` and the out directory are ignored, and `envBytes32` refuses a short or non-hex salt loudly.\n\n**Info, `src/CDPVault.sol:1066`.** The `_feeBase` NatSpec says storing the cap from the 100,000 floor pays \"the cap on all of it\". Split into ninety 100 imdUSD burns, the same 9,000 stores the cap for 249.75 of fee rather than 450, because the increase accumulates and each burn pays the rate stored before it.\n\n## Answers where nothing is wrong\n\n- **Q1, reserve share.** The reserve enters both figures as reserve/supply, so the slot inflating supply shrinks it in both, and min(live, lagged) is the one figure every route (cash, reserve, mixed) reads. A donation to the Treasury is permanent, non-withdrawable backing, so it raises payouts honestly. Cover and bite burn into the slot and lower lagged or leave it. A large new loan D dilutes a warm redeemer's reserve part by D/(S+D) for the hours it stays cold, bounded by the reserve's share of supply, which is near zero at launch and a few percent later; it costs 1.7x D in sIMD held across transactions and nobody gains, so it is not a cheap grief. Work-minted supply is unreachable at wage 0 and dilutes both figures honestly when on. The three accepted items hold as stated; the band-draw finding is the same arithmetic as the accepted `_cool` orphan but sixteen times larger and needing no staleness.\n- **Q2, floor.** Once the warm base passes 100,000 the floor is a max and dilutes nothing. Below it honest redeemers pay less than the un-floored formula would charge, which weakens the brake while the protocol is small but leaves the backing cap on payouts untouched. The single `prior` read keeps quote and stored rate consistent.\n- **Q3, salt.** A rerun with the same salt is \"exists, skipped\" then verify; a rerun with a different ","treeHash":null,"usage":{"cachedInputTokens":4106891,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":78547,"runtime":"claude","turns":45,"wallClockMs":1209640}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a4c81f495eb81dd0","findings":[{"citation":"resolved","description":"Q1. The reserve's warm share is reserve x warm / supply, so a fresh loan D dilutes the reserve term of the lagged figure by D / (supply + D) while contributing no collateral to it (its collateral is cold). The live figure is NOT lowered by the same loan: there the newcomer's collateral (up to mat x debt) more than offsets the dilution, so the comment's 'as it does in the live figure' holds for the reserve term alone, not for the figure a redeemer is paid. Measured on ParameterizedVault over MockIMD at $0.50 (test/scratch/Probe.t.sol): a warm book of 100,000 imdUSD against 170,000 IMD plus a 20,000 IMD Treasury reserve reads 0.95; 12 s after a newcomer locks 3,200,000 IMD and draws 900,000 (LINE allows it) it reads 0.863, back to 1.0 by six hours as the loan's own collateral warms. With a reserve-heavy book (60,000 IMD reserve, capped live 1.0) the same loan drops the figure to 0.883 and a holder redeeming 10,000 imdUSD from the reserve is paid 16,781 raw IMD where par less the 50 bps fee pays 19,900: 15.7% under. The borrower's cost is 4.44% duty on 900,000 for a few hours (about $14) plus gas, but it needs collateral comparable to the whole supply and only bites when the book is already below par on collateral alone, so it is a bounded, transient cost of the accepted fix rather than a cheap peg grief. The other direction (a newcomer LIFTING a reserve-funded redemption, the panel's F3) is closed except in the first minutes after the first draw, when every unit of supply is as fresh as the newcomer's: honest 0.95 reads 1.0 at 12 s and 60 s of launch-supply age, 0.996 at 120 s, 0.943 at 300 s, 0.908 at 1,800 s. Nothing else found: lock, draw, wipe, cover, a Treasury donation and work-minted supply each leave min(live, lagged) at or below the honest book across transactions; REPAID_THIS_TX_SLOT inflates supply in both the reserve share and the divisor so the reserve term stays reserve/supply exactly as in the live figure; cash computes payoutScale once and both routes and the mixed route read it. No code fix is required; if the dilution is unwanted, the alternative is to accept the launch vacuity instead, since any formula that credits fresh units' collateral reopens D1. At minimum the NatSpec at 763-767 should count this as a third accepted underpayment (it says 'two accepted cases' one sentence after describing it).","line":790,"path":"src/CDPVault.sol","reproduction":"test/scratch/Probe.t.sol test_q1_largeLoanDilutesReserveShare and test_q1_largeLoanReserveHeavyBook (logs above); test_q1_launchWindowVacuity for the launch minutes. Expected for an honest redeemer: the book it found (0.95, or par on the reserve-heavy book). Actual: 0.863 / 0.883 twelve seconds after the 900,000 loan; 16,781 raw IMD paid for 10,000 imdUSD against 19,900 at par less fee.","severity":"info","snippet":"            uint256 lagged = Math.mulDiv(Math.mulDiv(reserve, warm, supply) + _securedCollateralValue(price, true), 1e18, warm);","title":"CDPVault._backingPerUnit: the warm-share reserve fix underpays honest redeemers by (reserve/supply) x D/(supply+D) for about two hours after any large new loan D, and still reads par for a newcomer in"},{"citation":"resolved","description":"Q3 and Q5. runVault() sends salt ++ initcode to the canonical CREATE2 proxy, whose address depends on nothing but (salt, initcode), so whoever sees the pending transaction can land the vault first from any account. The runbook names https://rpc.mevblocker.io. MEV Blocker's default endpoint is the backrun-rebate endpoint: it shares the pending transaction with its searcher network (searchers agree not to frontrun and are removed if they do); the endpoint that does not share with searchers is https://rpc.mevblocker.io/fullprivacy. So the salt is seen by third parties before the vault exists, under a policy rather than a cryptographic or protocol guarantee, and the claims at script/DeployMainnet.s.sol:81 ('so the salt is not public before the vault exists') and src/SwarmFeed.sol:239 ('from a salt no one else knows, so no one else can deploy it first') describe the fullprivacy endpoint, not the one the runbook gives. Impact if a searcher defects: the operator's transaction hits a CREATE2 collision (consuming the gas forwarded to the create, about 13.8M) and reverts; a rerun prints 'exists, skipped' and verify() passes if the stranger touched nothing, or fails on totalSupply()/totalDebt() if they drew, forcing a new salt. Griefing only, no funds; the same outcome the panel rated low for the public salt, here conditional on a relay participant misbehaving, hence info. Also noted for Q3: the forge simulation (eth_call, eth_estimateGas) for runVault carries the salt to whichever RPC is configured, the same endpoint; stage one's record, check() and plan.py never emit the vault address unless VAULT_SALT is in the environment, in which case check() prints it to the console (not to any file); deployment.json gains the vault only once code sits at the address; a rerun after a relay delay repeats verifySeeded, so it needs fresh feed values (price and spot lifetimes are one hour) and REFERENCE_IMD_ETH_WEI again before it can record, and the runbook does not say the rerun must use the SAME salt (a second salt deploys a second vault). Smallest fix: name https://rpc.mevblocker.io/fullprivacy (or Flashbots Protect with hints off) at runbook 7.2, and reword DeployMainnet 76-82 and SwarmFeed 239 to 'shared only with the private relay'; add 'rerun with the same VAULT_SALT' to 7.2.","line":351,"path":"docs/MAINNET-RUNBOOK.md","reproduction":"State: operator runs `VAULT_SALT=<s> ... forge script script/DeployMainnet.s.sol --sig runVault() --rpc-url https://rpc.mevblocker.io --broadcast`. Expected per the runbook and the two comments: no party other than the builder sees the salt before the vault exists. Actual: MEV Blocker's default endpoint forwards the pending transaction to its searcher network; any recipient can send CREATE2_FACTORY.call(bytes.concat(<s>, _vaultInit(p))) with a higher tip and land the vault at p.vault first (the mechanism the panel's test/scratch/VaultFrontRun.t.sol demonstrated for the public salt, 13.8M gas); the operator's transaction then reverts at the proxy and `_deploy` on rerun prints 'exists, skipped'. Not reproducible in a Foundry test: it is a property of the relay, verified from MEV Blocker's documented endpoint policy.","severity":"info","snippet":"   (`--rpc-url https://rpc.mevblocker.io`), never a public mempool: the transaction carries the salt, and","title":"Deploy stage two: the runbook routes the salt-carrying transaction through MEV Blocker's DEFAULT endpoint, which forwards transactions to its searcher network for backrunning before inclusion; 'a salt"},{"citation":"resolved","description":"(1) CDPVault 765: 'two accepted cases' of honest underpayment, immediately after the sentence that introduces a third, the fresh-loan dilution of the reserve share (finding 1; 0.95 to 0.863 for hours). (2) CDPVault 1347, tail(): '(the spot feed and Chainlink are not read; at the shipped constants all agree)': SPOT_MAX_AGE equals PRICE_MAX_AGE (1 hour) but ETH_USD_MAX_AGE is 2 hours, so the four lifetimes do not agree; the minimum is unchanged by excluding them, which is presumably what was meant. (3) docs/MAINNET-RUNBOOK.md 239-240 and script/DeployMainnet.s.sol 89-92: run() 'reads the whole stack back off chain before writing deployment.json, which is what the keeper runs from': since the split, run() reads back only the feeds and the asker (verifyFeeds) and its deployment.json has no vault, stablecoin or treasury; the keeper runs from stage two's record (runbook 7.4). (4) DeployMainnet 81 and SwarmFeed 239, the relay claims: see finding 2. Verified TRUE by reading or test: the fee-base NatSpec (1062-1068: 9,000 at divisor 2 stores exactly the cap, test_q2_floorThreshold: 8,999 stores 0.044995e18, 9,000 stores 0.045e18, paid 8,550 for 9,000 at $1 so the 5% cap on all of it; the pin then decays 500 -> 276 bps at 12 h -> 163 at 24 h on a 1 imdUSD quote); 'prior is never zero on a deployed vault' (the floor is a max); DeploymentConfig 127-129 and Parameters 110-113 (9% / 4.5% / 36% / 18% all equal 0.045 x divisor); the premium magnitudes at 774-777 (repay bounded by principal above half the collateral's value: 15% at 170%, 50% at 100%, 60% at 80%; the reserve term's premium supply/(supply-repaid) is below the stated warm/(warm-repaid) bound; never past par); cover 591-596 against the burn at 616 and _coverDust; redemptionReserve (redeemIMD moves gem only, fundOracle moves plain IMD); BACKING_WARMUP (both _cool branches zero at a day); Treasury.fundOracle 501-503 against the try/catch at 555-558 (the unwrap is caught, plain IMD is still sent, oracleSpent counts only what was sent); DeploymentConfig 36-38 against OracleAsker.ask 162 and wideOpen 328 (any stale feed whose epoch allowance reached WIDE_ALLOWANCE_BPS is paid for). Q2 answers: the floor is a max so it is inert past 100,000 and cannot dilute anyone's fee; under it a run on a 20,000 warm supply pays 300 bps on the first 5,000 and the cap from the second (test_q2_runUnderTheFloor), a weaker brake than the honest base but the payout is capped at backing so the remaining holders lose nothing; the self-candidate route (b952037a, accepted) still lets a pinner who seasoned 9,000 of principal twelve hours in the 170-220% band store the cap for the stability fee and gas, with the 450 imdUSD fee staying in their own collateral. Q4: forge test 601 passed, 0 failed, 4 skipped on 07905bb; the redemption invariant's _laggedPerUnit mirrors the code (reserve x (supply - fresh) / supply, held = min(securedCollateral, lagSecured), prior = min(debt, lagDebt) - bad); cover and bite are untouched by the diff and their REPAID_THIS_TX_SLOT writes still feed _backingPerUnit's supply; ParameterizedVault initcode is 46,679 bytes (2,473 under EIP-3860), runtime 22,466 (under 24,576); Treasury.fundOracle code unchanged, NatSpec now matches. Read in full: src/CDPVault.sol, src/ParameterizedVault.sol, src/Treasury.sol, script/DeployMainnet.s.sol, deploy/mainnet/plan.py, deploy/mainnet/rehearse-fork.sh (lines 40-99), docs/MAINNET-RUNBOOK.md sections 6-7b, test/Redemption.invariant.t.sol, test/RedemptionLagAtLaunch.t.sol, test/final-sweep-panel/*. Read in part: src/DeploymentConfig.sol (constants), src/OracleAsker.sol (ask, wideOpen), src/SwarmFeed.sol and src/Parameters.sol (the changed lines only). Not reached: UsdPriceFeed, SharePriceFeed, ImdUSD, the factories, SwarmWorkOracle, DeployPreflight, script/checks, web, points.","line":765,"path":"src/CDPVault.sol","reproduction":"(1) read CDPVault 759-767 against the measured 0.863 in test/scratch/Probe.t.sol. (2) read CDPVault 1350-1351 against DeploymentConfig 30 and 39-41: min(1 hours, 1 days) = 1 hour; ETH_USD_MAX_AGE = 2 hours. (3) read DeployMainnet.run 195-213 (verifyFeeds(p, true), _record) and _record 456-466 (vault serialized only when p.vault has code) against runbook 236-240. (4) see finding 2. Each 'verified true' item: the cited lines, plus `forge test` (601/0/4) and `forge inspect ParameterizedVault bytecode`.","severity":"info","snippet":"    /// figure and the live one stands. The lag underpays honest redemptions for hours in two accepted cases:","title":"Comments and NatSpec changed in the diff that claim more than the code or the configuration has (Q5), and the Q1 to Q4 answers where nothing is wrong"}],"hash":"c3764890eb287fe3e4fbd996a4317194e292df369fe0942974129b0140fa9376","nodeId":"b5dc4431-db99-4dff-a2b1-2961be030fa3","outcome":"completed","summary":"Review complete. The findings file holds three entries, all info-level, and the working tree has no stray files (only the ignored findings file, the pinned reads, and my disposable scratch test).\n\n**Verdict on the diff since 6085c8a:** nothing critical, high, medium or low. The three fixes do what the resolution says, the suite is green on 07905bb (601 passed, 0 failed, 4 skipped), and ParameterizedVault's initcode is 46,679 bytes with runtime 22,466. What I recorded is one quantified cost of the reserve-share fix and two places where comments or the runbook claim more than the code or configuration gives.\n\n**Answers to the numbered questions**\n\n1. **Reserve share.** No sequence I could construct lifts a redemption above the book it found across transactions. Lock, draw, wipe, cover, a Treasury donation and work-minted supply each leave min(live, lagged) at or below the honest figure, and the repaid-this-transaction slot inflates supply in both the share and the divisor, so the reserve term is exactly reserve over supply as in the live figure. One payout scale feeds the cash, reserve and mixed routes. Two quantified effects. First, a newcomer still reads par for roughly the first two minutes after the first draw (honest 0.95 reads 1.0 at 12 and 60 seconds, 0.996 at 120 seconds, honest by 300 seconds), which matches the resolution's \"first minutes\". Second, the fix introduces a new underpayment: a fresh loan dilutes the reserve term by its share of supply without adding collateral. On a 100,000 warm book with a 20,000 IMD reserve, a 900,000 loan drops the figure from 0.95 to 0.863 for about two hours. On a reserve-heavy book a holder redeeming 10,000 imdUSD was paid 16,781 raw IMD instead of 19,900. Cheap in fees but it needs collateral comparable to the whole supply and only bites below par on collateral alone, so I rated it info.\n\n2. **Fee-base floor.** The threshold is exactly 9,000 imdUSD at divisor 2 (8,999 stores just under the cap), costing 450 imdUSD of fee through the reserve route, or only duty and gas through a seasoned self-candidate, which is the already-accepted b952037a cost. Past 100,000 the floor is a max and inert. Under it a run on a 20,000 warm supply pays 300 bps on the first 5,000 and the cap thereafter. Weaker brake, but harmless to remaining holders because the payout is capped at backing.\n\n3. **Deploy salt.** Nothing committed or recorded reveals the address before stage two lands. The one gap is operational: the runbook names MEV Blocker's default endpoint, which shares pending transactions with its searcher network, so \"a salt no one else knows\" overstates it. The fix is the `/fullprivacy` endpoint. Rerun, relay-delay and \"exists, skipped\" paths all converge correctly, with two notes: a rerun repeats verifySeeded and so needs fresh one-hour feed values, and the runbook should say the rerun must use the same salt.\n\n4. **Regressions.** None found. The invariant model mirrors the new formula, cover and bite are untouched, fundOracle's code is unchanged and its NatSpec now matches.\n\n5. **Comments.** Three items: the \"two accepted cases\" sentence now follows a third, the `tail()` note says all lifetimes agree when Chainlink's is two hours, and the runbook still says stage one reads back \"the whole stack\" for the keeper.\n\nRead in full: CDPVault, ParameterizedVault, Treasury, DeployMainnet, plan.py, runbook sections 6 to 7b, the redemption invariant and the panel tests. Read in part: DeploymentConfig, OracleAsker, SwarmFeed, Parameters. Not reached: the price feed adapters, ImdUSD, the factories, SwarmWorkOracle, DeployPreflight.","treeHash":null,"usage":{"cachedInputTokens":3039950,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":66245,"runtime":"claude","turns":36,"wallClockMs":1662572}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"56e50117311155be","findings":[{"citation":"resolved","description":"Q3. The fix for final-sweep-panel #2 rests on two things: the salt is secret, and the transaction that carries it is not visible to anyone before the vault exists (DeployMainnet.s.sol header lines 80-82: 'stage two is sent through a private relay (MEV Blocker), so the salt is not public before the vault exists'; runbook 6, lines 247-252 and 7.2, lines 350-353). The runbook then tells the operator to use the DEFAULT MEV Blocker endpoint, https://rpc.mevblocker.io. MEV Blocker's default service is its rebate mode: the transaction is forwarded to its searcher network so that searchers can build backruns and pay a rebate, and the searchers receive the full transaction (they need the calldata to simulate it). MEV Blocker's own endpoint reference (docs.mevblocker.io/reference/api/transaction-endpoints, fetched 2026-10-08) distinguishes 'Fast: https://rpc.mevblocker.io/fast - Fast inclusion with rebates' from 'Full privacy: https://rpc.mevblocker.io/fullprivacy - Maximum privacy, no rebates', and says of fullprivacy that it 'doesn't offer a rebate, but it guarantees that a user's transaction remains private', while even then 'builders will still see their transaction information'. So with the URL the runbook gives, the calldata (salt ++ ParameterizedVault initcode) is in the hands of a searcher set before inclusion. The front-run is the one the panel reproduced (13.8M gas through the canonical deployer): a searcher who sees the salt deploys the identical vault at the planned address first, relays a pumped first value within the 20% allowance, and draws; the operator's transaction then reverts in the deployer (CREATE2 collision) and a rerun prints 'exists, skipped' and fails verify on totalDebt. Outcome as before the fix: a re-salt, not funds, which is why this is low; but the property the two comments claim ('not public before the vault exists') is not what the recommended endpoint provides, and the panel's finding is only partly closed. Also worth stating in the runbook: builders see every private transaction, so no relay closes the window completely; only CREATE from the deployer's own nonce (the panel's first option) does.","line":351,"path":"docs/MAINNET-RUNBOOK.md","reproduction":"State: stage one landed; operator follows runbook 7.2 as written and sends runVault() with --rpc-url https://rpc.mevblocker.io. Expected (DeployMainnet.s.sol 80-82, runbook 252): the salt is not public before the vault exists. Actual: the default endpoint is the rebate mode, in which the pending transaction, including its calldata (the 32-byte salt followed by the vault initcode), is shared with MEV Blocker's searchers for backrunning; the endpoint reference lists 'Full privacy: https://rpc.mevblocker.io/fullprivacy - Maximum privacy, no rebates' as the mode that 'guarantees that a user's transaction remains private'. Smallest fix: in runbook 7.2 (and deploy/mainnet/rehearse-fork.sh's comment) name https://rpc.mevblocker.io/fullprivacy (or Flashbots Protect with hints disabled), say that builders still see it, and add the operational check 'if runVault reverts in the deployer or prints exists, skipped for an address you did not deploy, run verify; if it fails, re-salt'.","severity":"low","snippet":"   (`--rpc-url https://rpc.mevblocker.io`), never a public mempool: the transaction carries the salt, and","title":"Runbook 7.2 names the default MEV Blocker endpoint, which shares the stage-two transaction (and so the vault salt) with searchers; only /fullprivacy keeps it private before it lands"},{"citation":"resolved","description":"Q1 (the overpayment side) and Q5. The comment says a newcomer's capital raises the live figure but not the lagged one. The lagged figure excludes only the COLD part of new capital, and cold halves every BACKING_HALF_LIFE (6 h), so after t seconds a fraction 1 - 2^(-t/6h) of a new loan and of its secured collateral is counted. The lagged figure is (reserve x warm/supply + secured_lagged)/warm, an AVERAGE over the warm units; a newcomer at 200% contributes about 2 per warm unit of its debt, so against a warm book backed at b < 1 the figure reaches par as soon as warm_new x (2 - b) >= (1 - b) x warm_old, i.e. when the newcomer's warm fraction reaches (1 - b)/((2 - b) k) for a loan k times the warm book. With b = 0.84: 13.8% warm for k = 1 (about 1.3 h), 4.6% for k = 3 (29 min), 1.5% for k = 9 (10 min). The fresh-capital test the design cites (test/RedemptionLagAtLaunch.t.sol, 'a hair above the live one', tolerance 1/10,000) measures a 100-imdUSD newcomer against a 100-imdUSD book one block later; the amplification by the newcomer's size relative to the WARM book is not stated anywhere, and 'about a day' (lines 316-320) is the time for the newcomer to be counted in full, not for the payout figure to reach its cap. Reachable with the constants as committed: LINE is 1,000,000 imdUSD, so k = 9 against a 100,000 book is one borrower locking about $1.8M of sIMD. What it buys: a reserve-funded redemption at par where the honest figure is b; the position route cannot pay above pro rata (RedemptionWorsensRatio), so the gain is bounded by (1 - b - fee) x the Treasury's sIMD, which at launch is only liquidation cuts and donations, and the newcomer carries IMD price risk on the lock for the minutes it waits. Hence low, and the same accepted shape as the slow D1 round trip, but materially faster than the comment and the 'about a day' language suggest. Smallest fix: state it at the line (the lagged figure counts new capital by its warmed fraction; a loan k times the warm book reads par after 6h x log2(1 / (1 - (1-b)/((2-b)k))) ), and in docs/MAINNET-RUNBOOK.md section 7 keep the Treasury's sIMD small while the book is thin. A code change (e.g. not counting a position's capital in the lagged figure until a whole half-life has passed) is an economic-rule change and is left to the requester.","line":757,"path":"src/CDPVault.sol","reproduction":"test/scratch/Q1Reserve.t.sol::test_timeToParByRelativeSize (passes, logs). ParameterizedVault over an 18-decimal MockIMD priced at $1 (IMD/ETH 1/2000 x Chainlink 2000e8 etched), NHI 0.85 (mat 170), no reserve. OLD lock(200_000e18), draw(100_000e18); two days later IMD falls to $0.42: backingPerUnit() == 0.84e18 (honest, warm). NEW then lock(1_000_000e18 x k), draw(100_000e18 x k) in one transaction, and backingPerUnit() is read every minute. Expected (comment at 757-758 and lines 316-320): the attacker's capital does not raise the lagged figure, and new capital warms over about a day. Actual: backingPerUnit() reads 1e18 (par) after 91 minutes for k = 1, 29 minutes for k = 3, 10 minutes for k = 9. The companion fuzz test testFuzz_newcomerNeverLifts (1,500 runs) confirms the lift is exactly the one-block warming of the newcomer's capital relative to the warm book and nothing else.","severity":"low","snippet":"    /// over supply less the principal that is still warming up. An attacker's capital can raise the live\n    /// figure but not the lagged one, whichever position it sits in (`_lag`): new debt and the imdUSD minted","title":"CDPVault._backingPerUnit: the lagged figure is raised by a newcomer's capital as it warms, and because it is an average a loan k times the warm book lifts a below-par book to par in 91 (k=1), 29 (k=3)"},{"citation":"resolved","description":"Q3, 'if stage two is rerun'. The header (lines 89-91) says the script 'is resumable: a contract whose address already holds code is skipped, never redeployed'. For the seven stage-one contracts the salts are constants, so a rerun always recomputes the same address and skips. For the vault the address is now a function of an environment variable: plan() computes p.vault from VAULT_SALT, _deploy skips only if THAT address has code, and nothing reads deploy/mainnet/out/deployment.json back. A rerun with the same salt is fine (exists, skipped; verify; record). A rerun with any other nonzero salt (a regenerated random value, as deploy/mainnet/rehearse-fork.sh does per run; a typo; a different shell) passes verifySeeded again, deploys a second ParameterizedVault with its own imdUSD, Parameters, Treasury, work oracle and feeds (about 12.7M gas), verifies it (it is a fresh, correct stack) and overwrites deployment.json, so the keeper and the announcement point at the second vault while the first, also correct, is live and may already hold deposits if the operator announced after the first run. No funds are lost and no stranger is involved, so info; but the runbook tells the operator to keep the salt out of shell history and logs, which makes losing it between a failed and a repeated run plausible. Smallest fix: in runVault, before broadcasting, refuse when deployment.json already records a vault whose code exists and differs from p.vault (vm.exists + vm.readFile + vm.parseJsonAddress, under the deploy profile's read-write permission), or record the salt's keccak in stage one's deployment.json so a rerun can check the salt it was given against the one planned.","line":224,"path":"script/DeployMainnet.s.sol","reproduction":"Sequence on a fork after a successful stage two: VAULT_SALT=0xaaaa... forge script script/DeployMainnet.s.sol --sig runVault() --broadcast (deploys vault A, writes deployment.json with vault = A); then VAULT_SALT=0xbbbb... forge script ... --sig runVault() --broadcast. Expected from the header's resumability claim: 'exists, skipped' for the vault. Actual: plan() computes a new p.vault, p.vault.code.length == 0, the deployer is called again, vault B and its whole sub-stack are created, verify(p) passes for B, _record overwrites deployment.json with vault = B. Code path: lines 160-161 (p.vault from the env), 262-266 (skip only on the computed address), 458-467 (record).","severity":"info","snippet":"        _deploy(salt, _vaultInit(p), p.vault);","title":"DeployMainnet.runVault rerun with a different VAULT_SALT deploys a second whole stack and overwrites deployment.json; the script's resumability claim does not hold for the vault"},{"citation":"resolved","description":"Q5 (comments changed in the diff). (a) 'as it does in the live figure': in the live figure the newcomer's supply dilutes the reserve's part but the newcomer's collateral (held across one transaction) is counted against it, so the live figure RISES for a newcomer at or above mat; in the lagged figure only the dilution happens. The sentence is true of the reserve term alone and misleading about the figure. Measured (test/scratch/Q1Reserve.t.sol::test_dilutionByLargeNewLoan): warm book backed at 0.88 (reserve $8,000, secured $80,000, supply 100,000); a newcomer locks 5,000,000 IMD at $0.40 and draws 900,000 (LINE); one block later backingPerUnit() reads 0.8121, i.e. an honest reserve-funded redeemer is underpaid 771 bps for the hours the new supply takes to warm, where the live figure reads par. (b) 'two accepted cases' (a price fall's re-pricing counting as cold, and a stale position's share staying in the total): the new-borrower dilution described two sentences earlier is a third, and the debt-side orphan documented at _cool overpays rather than underpays. Neither changes code behaviour; the whole-system claim a reader takes from the paragraph is what is off. Smallest fix: 'dilutes the reserve's part for a warm redeemer until its supply warms (the reserve term is diluted the same way in the live figure, which the newcomer's collateral then lifts; here nothing lifts it): the safe direction, and the third accepted underpayment below.'","line":764,"path":"src/CDPVault.sol","reproduction":"test/scratch/Q1Reserve.t.sol::test_dilutionByLargeNewLoan (passes, logs 'honest below par: 880000000000000000', 'after newcomer: 812143724142315685', 'underpayment bps: 771'). Analytic: lagged = reserve/supply + secured_old/warm_old = 8,000/1,000,000 + 80,000/100,000 = 0.808 (the measured 0.812 includes one block of the newcomer's warming); live = (8,000 + 80,000 + min(2 x 900,000, 1.7 x 1,000,000 cap))/1,000,000 = par. Read lines 759-765 against lines 785-791.","severity":"info","snippet":"    /// warm redeemer until its supply warms, as it does in the live figure: the safe direction. When every unit of supply is fresh there is no lagged\n    /// figure and the live one stands. The lag underpays honest redemptions for hours in two accepted cases:","title":"CDPVault._backingPerUnit NatSpec: the new-borrower dilution is said to happen 'as it does in the live figure', and 'two accepted cases' of underpayment omits the one just described"}],"hash":"ebf8eb1b4fe5b141e3a25ff3814860180a1075d8a5a7e8f3ed075a634ddad155","nodeId":"11e44040-bbf4-4315-9f59-b465cd7d60b5","outcome":"completed","summary":"Findings are written. The scratch suite is in `test/scratch/` (gitignored, disposable), the full suite ran green, and `.imd-findings.json` holds four findings.\n\n**Outcome.** No critical, high or medium defect in the diff since 6085c8a. Four findings: two low, two info, all in `.imd-findings.json` with reproductions. Full suite: 601 passed, 0 failed, 4 skipped. ParameterizedVault initcode measured at 46,679 bytes, 2,473 under the EIP-3860 limit.\n\n**Answers**\n\n1. **Reserve's share.** The new lagged figure is algebraically reserve/supply + warm collateral/warm supply, so the reserve term is the live one and the collateral term excludes cold capital on both sides. A fuzz over random warm books, price falls, reserves and newcomer loans (1,500 runs) found no sequence that lifts the figure beyond the newcomer's one-block warming. Same-transaction wipe then cash is neutral in the below-par regime (0.8400 to 0.8399) because the repaid slot keeps supply and warm supply at their pre-wipe values. Donations never profit the donor, since the gain is at most the donation. Underpayment by a large new loan is reserve × new/(supply × (supply + new)) per unit: a 900,000 loan against a 100,000 book backed at 0.88 pays 0.812 for a few hours, a documented acceptance that costs the griefer stability fees and price exposure. Work-minted supply dilutes the same way but the channel is closed at launch. The overpayment side is the low finding: the lag discounts rather than excludes, and a loan k times the warm book lifts a 0.84 book to par in 91, 29 or 10 minutes for k of 1, 3 or 9. Gain bounded by the Treasury's sIMD times the gap below par.\n2. **Fee-base floor.** Cheapest cap pin is now a 9,000 imdUSD burn costing 450 imdUSD of fee; 8,999 stores 4.4995%. The pin still decays over a day (210 bps quote at +18h), only its price rose 100×. Past 100,000 of warm base the floor is a no-op max and dilutes nothing. Below it the brake is weaker by floor/warm: 10% of a 50,000 book quotes 308 bps instead of the cap. That lowers the fee honest redeemers pay, and the payout is still capped at backing, so the peg floor is unaffected.\n3. **Deploy salt.** Nothing on chain or in `deployment.json` reveals the address or salt before stage two. `check()` prints the vault address only when VAULT_SALT is exported, and plan.py's regex ignores that line. A leaked or front-run transaction reverts in the deployer, and a rerun prints exists, skipped then fails verify on totalDebt. The gap is operational: the runbook names MEV Blocker's default endpoint, which shares calldata with searchers; only the fullprivacy endpoint matches the claim (low). A rerun with a different salt deploys a second stack and overwrites the record (info).\n4. **Regressions.** The invariant model mirrors the new reserve term. Cover and bite are unchanged in code and green. The rewritten fundOracle NatSpec matches the try/catch path.\n5. **Comment claims.** Line 757 (attacker's capital cannot raise the lagged figure) and lines 764 to 765 (dilution \"as in the live figure\", \"two accepted cases\") are the ones that overstate; the rest of the changed comments verify.\n\n**Coverage.** Read in full: CDPVault, ParameterizedVault, Treasury, DeployMainnet, plan.py, rehearse-fork.sh, runbook sections 6 and 7, the changed tests and helpers. Read in part: OracleAsker (ask path), the redemption invariant. Not reached: the keeper's private repository, so whether it tolerates a stage-one record without a vault key is unverified.","treeHash":null,"usage":{"cachedInputTokens":4003673,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":75243,"runtime":"claude","turns":48,"wallClockMs":1246169}}],"verification":[]}