{"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":"a2640621-925d-479e-b71d-9629899ed4c6","kind":"audit","nodes":[{"acceptedSubmissionHash":"03ec61cf44e4dcd98da640dbc0651e34951f021e76e4afded416926880975802","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":"956102e416ca7a9a6ff5b730272ec252b78d2ecbe3fef0c19d929d33e6d5ab77","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":"70fb56bba8fd577545fc1af3cce479075cbbfbd363741c8f06987df1cc3fbf71","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":"5b59f0c7a40b8563a5dad6259fa73e6a936dcb4b28cf4c4f9d018dafa1488f2a","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":"3289b01d43686a051e7fcc9ab6e2782d4d2e07bb23ca418fcaddcbf4cbc1b485","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 vault: src/CDPVault.sol, src/ParameterizedVault.sol and src/ImdUSD.sol, at the pinned commit, for a mainnet launch. Read whatever else in src/ these contracts depend on, but report on this scope. Six audit rounds and their fixes are already in (docs/AUDIT-*.md; the newest is docs/AUDIT-RETRY-PANEL-VAULT-2026-10-07.md, whose fixes are this commit: git show 24337a2, and whose Resolution section says how each finding was answered). That panel found that ONE aggregate lag could not keep warmth with the position that earned it, so the lag was redesigned: what is new is now cold PER POSITION. The redesign is what to break first. A finding of an earlier round counts only if its fix regressed or left a gap. One medium of that round is accepted with its cost stated (CDPVault._backingPerUnit NatSpec): check its bound, do not re-report it.\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. COLD CAPITAL PER POSITION (CDPVault._lag, _cool, _pow, _coldNow, laggedNow; Position.coldDebt/coldSecured/coldAt; _coldDebt/_coldSecured/_coldAt; BACKING_HALF_LIFE six hours; nothing cold after a BACKING_WARMUP with no touch). Every position's cold halves at the same rate, so the vault's total is kept as one figure. Prove or break: (a) the vault total never reads below the sum of the positions' cold (it rounds up, a position rounds down; a position's own quiet day zeroes its cold while the vault total keeps cooling), so laggedNow never exceeds honest warm-up; (b) no sequence, by one position or several, in one transaction or across many, in either order (draw then cancel another's debt through cash, bite or cover; cancel then draw), lets one position's new capital count as another's warm capital; (c) whether activity or quiet can be arranged to credit capital faster than intended (a touch restarts only the vault total's quiet day; a position's own quiet day is its own); (d) the 128-bit saturation, _pow's precision and gas over long gaps, and the unchecked block in _lag.\n2. THE BANK (Position.bankDebt/bankSecured with one date each, set only when that bank fills; expiry after BACKING_WARMUP; credit only to the position that lost the warmth, only as it grows back). Can a bank be inflated, moved to another position, kept past its day (a trickle, the other side), or credited with capital that never left warm?\n3. THE LAGGED FEE BASE (_laggedSupply, _checkpointSupply at a transaction's first mint or burn, _supplyStart, _laggedSupplyFrom, _coldRepaidCountsAtOnce with COLD_REPAID_SLOT, cash's adjustments to both). A repayment of warm principal stays in the base, fading by half every six hours; increases, redemptions and repayments of cold principal count at once. Prove or break: no sequence moves the base below the honest supply (pinning the fee at the cap cheaply) or above it (lowering every fee) without seasoned capital held for hours; the transient bookkeeping across several mints and burns in one transaction (wipe then cash, cash then wipe, bite, cover, earn, fee remints) and across transactions.\n4. BACKING PER IMDUSD'S DENOMINATOR (_backingPerUnit: the larger of the live supply and the supply the transaction began with). The cross-transaction premium is accepted: verify its stated bound, and find any OTHER path, same transaction or not, that raises backingPerUnit above honest for a redemption, or underpays honest redeemers.\n5. DRAINED POSITIONS (_relockBelowBadDebt: collateral worth less than the recorded bad debt). cover takes such a re-lock at its value, burning at least that much of the Treasury's imdUSD (CoverBelowCollateralValue); bite skips mark and grace only in that case. Can a rebuilding borrower be harmed (taken at par mid-rebuild, a price move between their deposits), can cover or bite be griefed, and do the bad-debt record and totalBadDebt stay consistent through a value-sweep?\n6. The fresh-debt record, earn's wage gate, positions, liquidation, redemption, the stability fee, price gating, arithmetic and contract size: as the previous panel's question 5, for regressions.\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":"a925cf9dea7363666762e2e441448c5fc7857e007e3b4a03d5d29311741769ee","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a2640621-925d-479e-b71d-9629899ed4c6","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":"51423","feedbackHash":"8ef9d83d5cd1dd2104a4b966c6634bed8f32f355f57ba771db293ab9fe87d1f5","nodeKey":"audit_economics","submissionHash":"03ec61cf44e4dcd98da640dbc0651e34951f021e76e4afded416926880975802","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51070","feedbackHash":"4d718801a23cbf68e57840f79e39adf4ae247c1f99769a336b7a2cc3d1b65b7d","nodeKey":"audit_flow","submissionHash":"956102e416ca7a9a6ff5b730272ec252b78d2ecbe3fef0c19d929d33e6d5ab77","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50976","feedbackHash":"14dda7ca492d0bf041dab543b921cf7ee567acdc761b316b06574a043b9ad043","nodeKey":"audit_judge","submissionHash":"70fb56bba8fd577545fc1af3cce479075cbbfbd363741c8f06987df1cc3fbf71","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51227","feedbackHash":"12c66852f034e303c6e6a75ad24fcc4bd34da81b52c83e67c5fb447545e682fd","nodeKey":"audit_math","submissionHash":"5b59f0c7a40b8563a5dad6259fa73e6a936dcb4b28cf4c4f9d018dafa1488f2a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51442","feedbackHash":"3441d42196ef699a755871b54e04e440cda5d746bf696b4500fa3bdebd2c6775","nodeKey":"audit_permissions","submissionHash":"3289b01d43686a051e7fcc9ab6e2782d4d2e07bb23ca418fcaddcbf4cbc1b485","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"85ae11b183e236127fc342d6c56cb6b87c2ad4d51fdf4b59b1d1d03a5b096d49","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"56e50117311155be","findings":[{"citation":"resolved","description":"Q3. `_laggedSupply` (lines 334-343) makes a repayment of WARM principal fade out of the fee base over hours, which closes the retry panel's medium #5 (wipe, cash, draw). It is one-sided: an increase counts at once (`_checkpointSupply` sets `_laggedSupply = _laggedSupplyFrom(start)` and the mint lands on top; `_laggedSupplyFrom` returns `start` whenever `_laggedSupply <= start`), and a repayment of COLD principal is removed at once across transactions (`_coldRepaidCountsAtOnce`) but NOT inside the transaction that redeems: `_redemptionRate` reads `prior = _laggedSupplyFrom(_supplyStart())`, `_supplyStart()` is the supply the transaction began with, cold principal included, and `_laggedSupplyFrom` floors at it. Two consequences, both the mirror image of the medium the fix answered. (1) Across transactions, in one block: tx1 `lock` + `draw(D)`; tx2 `cash(X)` charged against supply S + D; tx3 `wipe(D)` (cold, zero seconds of fee) + `free`. The increase is X / (S + D) / divisor instead of X / S / divisor and `redemptionBaseRate` is stored from it for everyone after. (2) In one transaction: tx1 `draw(D)`; tx2 `wipe(D)` then `cash(X)` in one call: the same figure, where the same two calls as two transactions charge X / S / divisor (the proof shows 0.45% against 4.5%). The `_laggedSupply` NatSpec ('a repayment of cold principal counts at once ... otherwise a draw repaid one transaction later would have inflated the base and lowered every fee', 334-337 and 1427-1429) states the property the cross-transaction path has and the same-transaction path lacks; `_redemptionRate`'s NatSpec (893-895) states the opposite ('a repayment in it ... does not shrink the base') and the project's own test test_aSameTransactionRepaymentDoesNotShrinkTheFeeBase pins the same-call figure for a COLD churner, so the two descriptions contradict each other. Cost to the attacker: gas and sIMD worth 1.7 x D held for one block (no fee, no price exposure); D is bounded by the line ($1M at launch) less the open debt, so with a small launch supply a holder of ~$1.7M sIMD can dilute the base to the line. Who loses: the redeemer's fee (up to the cap less the floor, 4.5% of X) is value that would have stayed in the reserve or the candidate position; and the stored base rate, the mechanism that slows a redemption run and makes the b952037a pump expensive, is reset low on demand (95 bps for everyone instead of 500 in the proof). Reachable with the constants as committed, at wage 0, no governance. I verified locally that lagging increases too (subtracting the vault's cold principal plus the cold principal repaid this transaction, less principal minted this transaction, from `prior`) makes the attached proof pass, but it fails ten committed redemption tests because a young vault's supply is all cold and every early redemption would then pay the cap: counting increases at once is a design choice with this cost. Smallest change if the cost is accepted: state it at 334-343 and 893-895 (a draw held across one block dilutes the base; a cold repayment counts at once only from the next transaction) and drop the claim at 1427-1429 that the cold rule prevents the inflation. If it is not: lag increases with the per-position cold (`prior -= min(prior, coldNow + coldRepaidThisTx - mintedThisTx)`), accepting the cap on redemptions while the whole supply is cold.","line":906,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract FdFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\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 FdMirror 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 FdAggregator {\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\n/// @dev A contract, so a wipe and a cash can share one transaction.\ncontract FdBorrower {\n    ParameterizedVault private immutable vault;\n\n    constructor(ParameterizedVault vault_, MockIMD imd_) {\n        vault = vault_;\n        imd_.approve(address(vault_), type(uint256).max);\n    }\n\n    function lockDraw(uint256 collateral, uint256 debt) external {\n        vault.lock(collateral);\n        vault.draw(debt);\n    }\n\n    function wipe(uint256 amount) external {\n        vault.wipe(amount);\n    }\n\n    function cash(uint256 amount) external {\n        vault.cash(amount, 0, address(0));\n    }\n\n    function wipeThenCash(uint256 repay, uint256 redeem) external {\n        vault.wipe(repay);\n        vault.cash(redeem, 0, address(0));\n    }\n}\n\n/// @notice CDPVault._redemptionRate: the lagged fee base (`_laggedSupply`) lags DECREASES only. Principal drawn\n/// counts in the base at once, so a draw one transaction before a redemption (same block), repaid and withdrawn\n/// one transaction after, is charged against the inflated supply and sets the base rate everyone pays from it;\n/// and inside one transaction a repayment of COLD principal, which the `_laggedSupply` NatSpec says counts at\n/// once, stays in the base because `_laggedSupplyFrom(_supplyStart())` floors at the start supply.\ncontract FeeBaseDilutionTest is Test {\n    address private constant OTHER = address(0x07E);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    FdBorrower private whale;\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 FdAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        FdFeed primary = new FdFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1\n        FdFeed health = new FdFeed(0.85 ether); // mat 170, gap 50\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new FdMirror(primary))\n        );\n        stable = vault.stablecoin();\n        whale = new FdBorrower(vault, imd);\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(OTHER, 10_000 ether);\n        imd.mint(address(whale), 1_000_000 ether);\n        imd.mint(address(vault.treasury()), 10_000 ether); // reserve-funded redemptions\n        vm.stopPrank();\n        vm.startPrank(OTHER);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(200 ether);\n        vault.draw(100 ether);\n        stable.transfer(address(whale), 9 ether);\n        vm.stopPrank();\n        // The honest supply is seasoned: 100 imdUSD held two quiet days.\n        vm.warp(block.timestamp + 2 days);\n    }\n\n    /// Supply 100. The whale redeems 9 (9% of supply): the base rate is the 4.5% cap. With a 900 draw one\n    /// transaction earlier (same block, zero seconds of fee) and a wipe one transaction later, EXPECTED the same\n    /// base rate: no seasoned capital was added. ACTUAL: 0.45%, a tenth, for everyone after.\n    function test_aOneBlockDrawDilutesTheFeeBaseAcrossTransactions() public {\n        uint256 snap = vm.snapshotState();\n        whale.cash(9 ether);\n        uint256 honest = vault.redemptionBaseRate();\n        assertEq(honest, 0.045e18, \"9 of 100 over divisor 2 is the 4.5% cap\");\n        vm.revertToState(snap);\n\n        whale.lockDraw(1_530 ether, 900 ether); // tx 1: 170%, the minimum\n        whale.cash(9 ether); // tx 2: charged against a supply of 1,000\n        whale.wipe(900 ether); // tx 3: the capital was in the vault for one block\n        assertEq(stable.totalSupply(), 91 ether, \"the supply is what the honest redemption left\");\n        assertEq(vault.redemptionBaseRate(), honest, \"a draw held one block must not lower the base rate everyone pays\");\n    }\n\n    /// Transaction 1: the whale draws 900 (cold). Transaction 2: wipe 900 (cold: `_coldRepaidCountsAtOnce`) and\n    /// in the SAME call redeem 9. EXPECTED: the 4.5% cap, exactly what the same wipe and cash as two\n    /// transactions produce. ACTUAL: 0.45%: the cold 900 burned moments earlier in the same call is still base.\n    function test_aColdRepaymentInTheSameTransactionStaysInTheFeeBase() public {\n        whale.lockDraw(2_000 ether, 900 ether);\n        assertEq(stable.totalSupply(), 1_000 ether);\n\n        uint256 snap = vm.snapshotState();\n        whale.wipe(900 ether);\n        whale.cash(9 ether);\n        uint256 twoTransactions = vault.redemptionBaseRate();\n        assertEq(twoTransactions, 0.045e18, \"two transactions: the cold 900 left the base at once\");\n        vm.revertToState(snap);\n\n        whale.wipeThenCash(900 ether, 9 ether);\n        assertEq(stable.totalSupply(), 91 ether);\n        assertEq(vault.redemptionBaseRate(), twoTransactions, \"one transaction must charge what two do\");\n    }\n}","reproduction":"test/scratch/FeeBaseDilution.t.sol (attached as proof; both tests fail on this code). ParameterizedVault over an 18-decimal MockIMD at $1 (IMD/ETH 1/2000 x Chainlink 2000e8 etched at CHAINLINK_ETH_USD), NHI 0.85, TreasuryFactory etched, Treasury holds 10,000 IMD so the redemptions are reserve-funded, launch constants (divisor 2). OTHER locks 200, draws 100 and hands a whale contract 9 imdUSD; two quiet days. Test 1: control, whale cash(9): redemptionBaseRate == 0.045e18 (9 of 100 over 2: the cap). Reverted. Then as three transactions in one block: whale lock(1,530) + draw(900); cash(9, 0, 0); wipe(900). Supply afterwards 91 either way. EXPECTED: 0.045e18. ACTUAL: 0.0045e18 ('a draw held one block must not lower the base rate everyone pays: 4500000000000000 != 45000000000000000'); redemptionFeeBps(0) is 95 for every later redeemer instead of 500. Test 2: whale lock(2,000) + draw(900) (tx1). Control: wipe(900) then cash(9) as two transactions: 0.045e18. Reverted. Then wipe(900) and cash(9) in ONE call. EXPECTED: 0.045e18 (the cold 900 counts at once). ACTUAL: 0.0045e18 ('one transaction must charge what two do: 4500000000000000 != 45000000000000000').","severity":"low","snippet":"        uint256 prior = _laggedSupplyFrom(_supplyStart());","title":"CDPVault._redemptionRate: the lagged fee base lags decreases only, so a draw held for one block (across transactions) or a cold repayment inside the redeeming transaction dilutes the fee base to a ten"},{"citation":"resolved","description":"Q3/Q4, the transient bookkeeping. SUPPLY_START_SLOT holds the imdUSD supply the transaction began with, plus one (`_checkpointSupply`). `_redemptionRate` only requires `amount <= stablecoin.totalSupply()` (the LIVE supply, line 902), and the live supply exceeds the start supply inside a transaction that minted first (a `draw` or `earn` before the `cash`). `cash` then executes `sub(start + 1, amount)` in assembly with no floor; for `amount > start + 1` the slot wraps to about 2^256 (and for `amount == start + 1` to exactly 0, which `_checkpointSupply` reads as 'not recorded' and re-checkpoints mid-transaction from the post-burn supply). For the rest of the transaction `_supplyStart()` returns the wrapped figure: `_backingPerUnit` reads `supply = max(live, start)` of about 2^256, `perUnit` rounds to 0, `payoutScale` is 0, `gemOut` is 0 and every further `cash` reverts `ZeroAmount`; `_redemptionRate` reads `prior` of about 2^256 and quotes a zero increase (the view `redemptionFeeBps` too, for a caller in that transaction). Transient storage is cleared when the transaction ends, so no storage is corrupted and nothing is extracted; the defect is that a legitimate single-transaction flow (a router that locks, draws and redeems more than the pre-existing supply, then redeems again: a leverage loop, a batched exit) is refused with an error indistinguishable from an empty reserve, and the arithmetic contradicts the line's own comment ('counts at once, across transactions and inside this one'). Reachable with the constants as committed: it only needs a draw larger than the existing supply in the same transaction as the redemptions, ordinary at launch (small supply, $1M line). Smallest fix: floor the subtraction, e.g. `uint256 start = _supplyStart(); start = start > amount ? start - amount : 0;` then `tstore(SUPPLY_START_SLOT, add(start, 1))` (in a small private helper: an extra local in `cash` itself hits stack-too-deep). Verified locally: with that change the attached proof passes and the committed suite is unchanged.","line":734,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {CDPVault} from \"src/CDPVault.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 SwFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\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 SwMirror 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 SwAggregator {\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 SwRouter {\n    ParameterizedVault private immutable vault;\n\n    constructor(ParameterizedVault vault_, MockIMD imd_) {\n        vault = vault_;\n        imd_.approve(address(vault_), type(uint256).max);\n    }\n\n    function lockDraw(uint256 collateral, uint256 debt) external {\n        vault.lock(collateral);\n        vault.draw(debt);\n    }\n\n    function cash(uint256 amount) external returns (uint256) {\n        return vault.cash(amount, 0, address(0));\n    }\n\n    function drawThenCashTwice(uint256 collateral, uint256 debt, uint256 first, uint256 second)\n        external\n        returns (uint256 a, uint256 b)\n    {\n        vault.lock(collateral);\n        vault.draw(debt);\n        a = vault.cash(first, 0, address(0));\n        b = vault.cash(second, 0, address(0));\n    }\n}\n\n/// @notice CDPVault.cash lowers SUPPLY_START_SLOT with an assembly `sub` that has no floor. The slot holds the\n/// supply the transaction began with (+1); a redemption larger than that, which is possible after a draw in\n/// the same transaction, wraps it to about 2^256, and every later `_backingPerUnit` in the transaction reads a\n/// supply of that size: payout zero, `ZeroAmount`.\ncontract SupplyStartWrapTest is Test {\n    address private constant OTHER = address(0x07E);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    SwRouter private router;\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 SwAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        SwFeed primary = new SwFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1\n        SwFeed health = new SwFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new SwMirror(primary))\n        );\n        stable = vault.stablecoin();\n        router = new SwRouter(vault, imd);\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(OTHER, 10_000 ether);\n        imd.mint(address(router), 10_000 ether);\n        imd.mint(address(vault.treasury()), 5_000 ether); // reserve-funded redemptions\n        vm.stopPrank();\n        vm.startPrank(OTHER);\n        imd.approve(address(vault), type(uint256).max);\n        vault.lock(200 ether);\n        vault.draw(100 ether);\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days);\n    }\n\n    /// Supply 100 before the transaction. In one call: lock, draw 900, cash 500 (more than the 100 the\n    /// transaction began with), cash 100. EXPECTED: both redemptions pay, as they do as separate transactions.\n    /// ACTUAL: the second reverts ZeroAmount, the transient start supply having wrapped below zero.\n    function test_aRedemptionLargerThanTheStartSupplyWrapsTheTransientStart() public {\n        // Control: the same calls as separate transactions.\n        uint256 snap = vm.snapshotState();\n        router.lockDraw(2_000 ether, 900 ether);\n        uint256 a1 = router.cash(500 ether);\n        uint256 b1 = router.cash(100 ether);\n        assertGt(a1, 0);\n        assertGt(b1, 0);\n        vm.revertToState(snap);\n\n        (uint256 a, uint256 b) = router.drawThenCashTwice(2_000 ether, 900 ether, 500 ether, 100 ether);\n        assertEq(a, a1, \"the first redemption is unaffected\");\n        assertGt(b, 0, \"the second redemption in the same transaction must pay\");\n    }\n}","reproduction":"test/scratch/SupplyStartWrap.t.sol (attached as proof; fails on this code). ParameterizedVault at $1, NHI 0.85, Treasury holds 5,000 IMD. OTHER locks 200 and draws 100 (supply 100); two quiet days. A router contract holds 10,000 IMD. Control, as separate transactions: router lock(2,000) + draw(900); cash(500, 0, 0) pays a1 > 0; cash(100, 0, 0) pays b1 > 0. Snapshot reverted. Then in ONE transaction: lock(2,000), draw(900), cash(500), cash(100). EXPECTED: both redemptions pay (a == a1, b > 0). ACTUAL: the call reverts `ZeroAmount()` in the second cash: after the first, SUPPLY_START_SLOT = (100e18 + 1) - 500e18 wrapped, `_supplyStart()` is about 2^256 and `_backingPerUnit` is 0.","severity":"low","snippet":"            tstore(SUPPLY_START_SLOT, sub(tload(SUPPLY_START_SLOT), amount))","title":"CDPVault.cash: the transient start-of-transaction supply is lowered with an unchecked assembly `sub`, which wraps when a redemption exceeds the supply the transaction began with, so every later redemp"},{"citation":"resolved","description":"Q1(a)/(c). `_cool` returns 0 for any `elapsed >= BACKING_WARMUP`. For the vault total that is the designed 'quiet day credits in full' rule, and a touch of any position restarts it. For a position the same rule runs on the position's OWN elapsed (`block.timestamp - position.coldAt`, line 971), so a position left untouched for a day reads its cold as zero even when the vault total, touched by others, still carries that position's residual (1/16 after exactly a day). The total is therefore above the sum of the positions, which the NatSpec calls the safe direction (1015-1016), but the decrease path then takes `coldOut = min(cold, out)` with `cold == 0` (lines 987-989): nothing leaves the total, the whole repayment is banked as warm, and the position's residual stays in `_coldDebt` / `_coldSecured` as cold for debt and collateral that no longer exist, halving every six hours and zeroed only by a quiet vault day. Effect: `laggedNow` reads the OTHER positions' warm capital short by that residual (62.5 of the helper's 100 in the reproduction), so `ParameterizedVault.backedDebt` / `earnLine` and the lagged `_backingPerUnit` figure are below honest for hours. The direction is conservative for the protocol (I checked that a healthy position's residual lowers the lagged backing in both the collateral-bound and the mat-bound regime, since 1.7 > backing), so this is a griefing / accuracy defect rather than an extraction: a borrower who holds D for a day while the vault stays active, repays, and immediately redraws (credited warm from its bank, so its own position is unaffected) leaves D/16 of phantom cold behind each time; with D equal to the honest debt that is about 6% off the lagged backing and the work ceiling for about a day, for one day of stability fee on D (0.012%) and gas. No capability beyond what simply holding D cold already does, but it outlives the capital. The claim at 1016-1018 ('A touch only ever restarts that day, so activity can slow warming but never speed it') holds; the claim implicit in the per-position design, that a position's quiet day 'is its own', does not: it is its own for the position's figures and not for the total the position's decrease is applied to. Smallest fix: cool a position's figures continuously (apply the half-life for any elapsed, without the `>= BACKING_WARMUP` short-circuit; `_pow` stays bounded at about 27 squarings for any realistic gap), so a position's cold is never below its share of the total and a decrease always removes what the position actually contributed; keep the quiet-day zeroing for the total only (when the total has been zeroed, a position's leftover only lowers what is banked, the safe direction).","line":1021,"path":"src/CDPVault.sol","reproduction":"test/scratch/OrphanCold.t.sol, test_aQuietDayPositionRepaidLeavesItsColdInTheTotal (fails on this code). ParameterizedVault at $1, NHI 0.85. HELPER locks 190 and draws 100 (190%: its term is its collateral, so lock(1) is a touch); three quiet days (warm). BORROWER locks 2,000 and draws 1,000 (cold). Every six hours for a day HELPER lock(1) touches the cold total while BORROWER stays untouched; one second past the day laggedNow().debt == totalDebt - 62.5e18 (+-0.01e18): 1/16 of the day-old 1,000 is still cold in the total. HELPER hands BORROWER 1 imdUSD for the day's fee; BORROWER wipe(debtOf(BORROWER)). EXPECTED: laggedNow().debt == totalDebt (== 100e18): BORROWER's debt and its cold both left, HELPER's 100 is warm. ACTUAL: 37502005602022804874 ('no cold should remain for debt that no longer exists: 37502005602022804874 != 100000000000000000000'): the 62.5 orphan stays and HELPER's warm debt reads 62.5 short, halving every six hours.","severity":"low","snippet":"        if (elapsed >= BACKING_WARMUP) return 0;","title":"CDPVault._lag / _cool: a position untouched for a day reads its own cold as zero while the vault total still holds its 1/16, so its repayment leaves that cold in the total for debt that no longer exis"},{"citation":"resolved","description":"Q1(a)/(d). Rounding the total up and a position down holds step for step, but `_pow` truncates at every multiply, so pow(e1) x pow(e2) / RAY and pow(e1 + e2) differ by up to about 1e-21 relatively (the error of each squaring doubles, 17 squarings for a day of seconds), in either direction. The total is cooled at every touch of any position while a position is cooled only at its own touches, so the two go through different step sequences and floor(amount x pow(e1+e2)) can exceed ceil(ceil(amount x pow(e1)) x pow(e2)). Bound: about 1e-21 of the amount, i.e. under 1e4 wei of a $1M principal and about 1e-19 sIMD of a 1e6 sIMD term, worth nothing; `laggedNow` can read above honest by that much and a decrease can push `total` to its saturating zero by that much. Not a defect to fix; the NatSpec should say 'never below the sum but for rounding of order 1e-21'.","line":1016,"path":"src/CDPVault.sol","reproduction":"test/scratch/OrphanCold.t.sol, test_concreteRoundingCounterexample (replicates `_pow` and `_cool` exactly; fails the claim). amount = 601691055351260499632438899944742 raw units (6.0e32; 6.0e8 sIMD), e1 = 1 second, e2 = 36,314 seconds. Position, one step of e1+e2 rounded down: 187614705151815296225965818292158. Total, two steps each rounded up: 187614705151815296225965817975788. The total is 316,370 raw units (1.7e-27 relatively, 3e-19 sIMD) BELOW the position. testFuzz_totalRoundsAboveThePosition in the same file finds such cases in a handful of runs.","severity":"info","snippet":"    /// totals (`up`, so they never read below the sum of the positions) and down for a position; and","title":"CDPVault._cool: the claim that the rounded-up vault total never reads below the sum of the positions' cold is violated by dust when the total has cooled in more steps than a position"},{"citation":"resolved","description":"Q3, the documentation half. `_laggedSupply` is stored as an ABSOLUTE supply figure; `_laggedSupplyFrom(start)` decays the difference `_laggedSupply - start`. After a warm repayment of W the stored figure is the pre-repayment supply S and the live supply S - W, so the base is S - W + fading(W) as documented (334-337). An increase of E that follows (another borrower's draw, an `earn`) raises `start` to S - W + E, and the excess becomes W - E: the increase is swallowed by the fading repayment rather than added on top, until the excess has faded. The NatSpec says 'an increase counts at once'; the code gives max(live, decaying high-water mark), which is the same thing only while nothing is fading. Direction: the base is lower than the documented model, never below the live supply, so the fee is HIGHER than documented (95 bps instead of 74 in the reproduction) for the hours a dominant repayment takes to fade; no attacker gains (the attacker's own redraw is absorbed the same way and the figure after a wipe-and-redraw is exactly the pre-churn supply, arguably the more honest answer). Reword 334-337 to 'the base is the larger of the live supply and the last checkpointed supply decaying toward it', or store the excess separately if the documented model is wanted.","line":1424,"path":"src/CDPVault.sol","reproduction":"test/scratch/Absorption.t.sol (fails the documented model). ParameterizedVault at $1, NHI 0.85, Treasury holds 10,000 IMD. OTHER 200 / 100, BIG 2,000 / 900, both seasoned two days; supply 1,000. BIG wipe(900): warm, so redemptionFeeBps(9e18) == 95 (9 of a base of 1,000 over divisor 2, plus the 50 floor). NEW locks 2,000 and draws 900 in the next transactions: supply back to about 1,000 (plus two days of reminted fee). EXPECTED by the NatSpec: base 1,000 live + 900 fading = 1,900, so 9 of it is 24 bps, redemptionFeeBps(9e18) == 74. ACTUAL: 95 ('an increase counts at once on top of the fading repayment: 95 != 74'): the base is 1,000.","severity":"info","snippet":"        return lagged <= start ? start : start + _cool(lagged - start, block.timestamp - _laggedSupplyAt, true);","title":"CDPVault._laggedSupply NatSpec: an increase does not count at once while a warm repayment is fading; it is absorbed by the fading excess, so redeemers after a large warm repayment pay more than the do"}],"hash":"03ec61cf44e4dcd98da640dbc0651e34951f021e76e4afded416926880975802","nodeId":"2ee46308-e202-4feb-973f-ca036626d335","outcome":"completed","summary":"The review is complete. The source tree is unchanged, `.imd-findings.json` holds five substantiated findings (three low, two info), and every scratch check fails on the committed code for the reason it states. No high or critical defect was found in the per-position redesign.\n\n**Findings, most severe first**\n\n- **Low, `_redemptionRate` line 906.** The lagged fee base lags decreases only. A draw held for one block across transactions (lock and draw; cash; wipe) is charged against the inflated supply and stores a base rate a tenth of the honest one for everyone after. Inside one transaction a cold repayment also stays in the base, although the `_laggedSupply` NatSpec says it counts at once, so the same wipe and cash charge 0.45% in one call and 4.5% as two. Lagging increases fixes both proofs but fails ten committed redemption tests, because a young vault's supply is all cold. This is a design cost to state, or a trade-off to decide. Proof attached.\n- **Low, `cash` line 734.** The transient start supply is lowered with an unchecked assembly `sub`. A redemption larger than the supply the transaction began with, possible after a draw in the same call, wraps the slot to about 2^256, and every later redemption in that transaction reverts with ZeroAmount. Floor the subtraction in a helper. Proof attached and the fix verified locally.\n- **Low, `_cool` line 1021.** A position untouched for a day reads its own cold as zero while the vault total still carries its 1/16. Its repayment then removes nothing from the total, which keeps cold for debt that no longer exists. In the reproduction the helper's warm 100 reads as 37.5 for hours. Conservative for the protocol, but it undercounts every other position's warmth.\n- **Info, `_cool` line 1016.** The claim that the rounded-up total never reads below the sum of the positions fails by dust when the total cooled in more steps than a position. The counterexample is 1.7e-27 relative, worth nothing.\n- **Info, `_laggedSupplyFrom` line 1424.** An increase that follows a warm repayment is absorbed by the fading excess instead of adding to the base. The documented model gives 74 bps where the code charges 95.\n\n**Answers to the numbered questions**\n\n1. The per-position cold holds. The total is only ever reduced by a position's own cold, so warmth cannot move between positions in either order; the draw-then-cancel and cancel-then-draw sequences, in one transaction or across many, leave the newcomer's debt cold. Activity only slows warming. The saturation, unchecked block and `_pow` gas are sound. The two deviations are the orphaned residue (low) and rounding dust (info).\n2. The bank cannot be inflated beyond a position's own warm loss, moved, or kept past its day. A secured bank can arise from a price rise with no capital leaving, but the collateral it credits is still in the vault, and the mat cap on cold debt bounds it, so nothing new is warmed.\n3. The base never falls below the live supply. The gaps are the two one-sided cases in the first finding and the absorption note.\n4. The accepted cross-transaction premium is bounded as stated: a round-trippable repayment is at most principal minus half the collateral's value, 15% at mat 170, falling to zero at 200%. No other path raises the live or lagged figure above honest; donations to the reserve and position-funded redemptions only lower or neutralize it.\n5. Cover and bite are consistent through a value sweep. The record is rewritten at the sweep and reduced by the burn, so totalBadDebt and the position's record move together. A rebuilding borrower is paid par rather than a 20% penalty, and the no-grace bite applies only at a collateral value below the recorded loss, which is below roughly 100% CR.\n6. No regressions found in the fresh-debt record, the wage gate, liquidation, the stability fee, price gating or arithmetic. The committed non-invariant suite passes (564 tests, 4 fork tests skipped). ParameterizedVault initcode is 45,981 bytes and runtime 21,","treeHash":null,"usage":{"cachedInputTokens":5061720,"inputTokens":770,"model":"claude-fable-5-1","outputTokens":126727,"runtime":"claude","turns":62,"wallClockMs":2016214}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2b9b0095482c54e6","findings":[{"citation":"resolved","description":"Q3. The retry panel's medium #5 was fixed by lagging the fee base so a repayment of WARM principal fades out over hours; the stated intent is that moving the base needs seasoned capital. The other direction is open: `_laggedSupplyFrom` floors the base at `start`, the live supply when the transaction began, and the live supply includes principal that is zero blocks old. (1) Across transactions: tx N `lock` + `draw(D)` (D cold, held one block); tx N+1 anyone's `cash(A)` is quoted `A / (S + D) / divisor` instead of `A / S / divisor`, and `redemptionBaseRate` is set from that; tx N+2 `wipe(D)` (cold principal, `_coldRepaidCountsAtOnce` removes it at once, the position's bank is empty, nothing lags). The churner's cost is gas, a stability fee on D for ~24 seconds and 1.7 x D of sIMD exposed for one block; the dilution factor is (S + D) / S with D bounded only by the line's room. (2) In one call, through a contract: `wipe(D)` of cold principal then `cash(A)`: `_coldRepaidCountsAtOnce` has already subtracted D from `_laggedSupply`, but `_laggedSupplyFrom(_supplyStart())` returns `start` = the pre-wipe supply, so the base still contains the cold D. This contradicts the NatSpec at 334-337 and 1427-1429 ('a repayment of cold principal counts at once') for the same transaction, and lets the churn finish inside one call. Who loses: the base rate everyone pays afterwards is a fraction of honest for at least a half-life (12 h); the candidates in the band retain a smaller discount and the reserve pays out more IMD per imdUSD; the fee's job as the redemption throttle is defeated by whoever can open a large cold position for one block. Reachable with the constants as committed at wage 0 (LINE $1M; at launch the supply is small relative to it). Smallest fix: measure the fee base against WARM supply the way backing does: `prior = laggedSupply - (totalDebt - laggedNow().debt)` (the vault's cold principal), and let cold principal repaid in the current transaction reduce `start` as well (keep a second tally that `_payDebt` does not clear, or compute `start - coldRepaidThisTx` before `_coldRepaidCountsAtOnce` zeroes the slot). Trade-off to state if adopted: while every unit of supply is cold (the first hours after launch, or after a large honest draw) `prior` is small and the increase saturates at the cap, which is the lag's accepted direction.","line":906,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {CDPVault} from \"src/CDPVault.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 FdFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function set(uint256 v) external {\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 FdMirror 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 FdAggregator {\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 FdActor {\n    ParameterizedVault private immutable vault;\n\n    constructor(ParameterizedVault vault_, MockIMD imd_) {\n        vault = vault_;\n        imd_.approve(address(vault_), type(uint256).max);\n    }\n\n    function lockDraw(uint256 c, uint256 d) external {\n        vault.lock(c);\n        vault.draw(d);\n    }\n\n    function wipeThenCash(uint256 w, uint256 a) external returns (uint256) {\n        vault.wipe(w);\n        return vault.cash(a, 0, address(0));\n    }\n\n    function wipe(uint256 w) external {\n        vault.wipe(w);\n    }\n\n    function cash(uint256 a) external returns (uint256) {\n        return vault.cash(a, 0, address(0));\n    }\n}\n\ncontract FeeDilutionTest is Test {\n    address private constant HOLDER = address(0x401D);\n    address private constant OTHER = address(0x07E);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    FdFeed private primary;\n    FdActor private actor;\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 FdAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new FdFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1\n        FdFeed health = new FdFeed(0.85 ether); // mat 170, gap 50\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new FdMirror(primary))\n        );\n        stable = vault.stablecoin();\n        actor = new FdActor(vault, imd);\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(address(actor), 100_000 ether);\n        imd.mint(OTHER, 10_000 ether);\n        imd.mint(address(vault.treasury()), 10_000 ether); // reserve-funded redemptions\n        vm.stopPrank();\n        vm.prank(OTHER);\n        imd.approve(address(vault), type(uint256).max);\n        // Warm supply of 1,000, held by HOLDER.\n        vm.startPrank(OTHER);\n        vault.lock(3_000 ether);\n        vault.draw(1_000 ether);\n        stable.transfer(HOLDER, 1_000 ether);\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days);\n        assertEq(stable.totalSupply(), 1_000 ether);\n    }\n\n    function _next() private {\n        vm.roll(block.number + 1);\n        vm.warp(block.timestamp + 12);\n    }\n\n    /// Honest: redeeming 90 of a 1,000 warm supply is a 4.5% increase (the cap).\n    function test_honestFeeForNinetyOfAThousand() public {\n        assertEq(vault.redemptionFeeBps(90 ether), 500);\n        vm.prank(HOLDER);\n        vault.cash(90 ether, 0, address(0));\n        assertEq(vault.redemptionBaseRate(), 0.045e18);\n    }\n\n    /// A draw one block before the redemption, repaid one block after, halves the fee.\n    function test_coldDrawOneBlockEarlierDilutesTheFee() public {\n        actor.lockDraw(17_000 ether, 9_000 ether); // 90% of the resulting supply, zero seconds old\n        _next();\n        uint256 quoted = vault.redemptionFeeBps(90 ether);\n        vm.prank(HOLDER);\n        vault.cash(90 ether, 0, address(0));\n        uint256 base = vault.redemptionBaseRate();\n        _next();\n        actor.wipe(9_000 ether);\n        emit log_named_uint(\"quoted fee bps (honest 500)\", quoted);\n        emit log_named_uint(\"base rate after (honest 0.045e18)\", base);\n        assertApproxEqAbs(stable.totalSupply(), 910 ether, 1e16, \"supply is 910 after the churn (fee dust aside)\");\n        assertEq(quoted, 500, \"cold principal must not dilute the fee\");\n        assertEq(base, 0.045e18, \"cold principal must not depress the base rate\");\n    }\n\n    /// Same transaction: repay the cold principal and redeem in one call: the base reads the pre-wipe supply.\n    function test_wipeOfColdPrincipalThenCashInOneTransactionReadsThePreWipeSupply() public {\n        actor.lockDraw(17_000 ether, 9_000 ether);\n        _next();\n        // the actor also holds HOLDER's 90 for the redemption\n        vm.prank(HOLDER);\n        stable.transfer(address(actor), 90 ether);\n        actor.wipeThenCash(9_000 ether, 90 ether);\n        uint256 base = vault.redemptionBaseRate();\n        emit log_named_uint(\"base rate after (honest 0.045e18)\", base);\n        assertApproxEqAbs(stable.totalSupply(), 910 ether, 1e15, \"supply is 910 after the churn\");\n        assertEq(base, 0.045e18, \"cold principal repaid in the same call must count at once\");\n    }\n\n}","reproduction":"ParameterizedVault over MockIMD at $1 (IMD/ETH 1/2000 x Chainlink 2000e8 etched), NHI 0.85, divisor 2, Treasury holding 10,000 IMD so the redemption is reserve-funded. OTHER locks 3,000, draws 1,000 and gives HOLDER the 1,000; two quiet days (warm). Honest: `redemptionFeeBps(90e18)` == 500 and `cash(90e18)` leaves `redemptionBaseRate` == 0.045e18 (passes). Attack 1: actor contract `lock(17,000)` + `draw(9,000)` in one tx; next block HOLDER `cash(90e18, 0, 0)`; next block actor `wipe(9,000)`. EXPECTED: quoted fee 500 bps, base 0.045e18. ACTUAL: quoted 95 bps, base 0.0045e18, supply back at 910. Attack 2: actor holds the 90, one call `wipe(9,000)` then `cash(90)`. EXPECTED base 0.045e18 (cold repaid counts at once). ACTUAL 0.0045e18. Proof: test/scratch/FeeDilution.t.sol, both attack tests fail on this code; the honest baseline passes.","severity":"medium","snippet":"        uint256 prior = _laggedSupplyFrom(_supplyStart());","title":"CDPVault._redemptionRate: the fee base counts cold principal at once, so a draw one block before a redemption (or a repayment of cold principal in the same call) dilutes the fee and depresses the base"},{"citation":"resolved","description":"Q5 ('a price move between their deposits'). The retry panel's low #7 narrowed the skip to a re-lock worth less than the recorded bad debt, and its low #6 made `cover` take such a re-lock at its value with no liquidator needed. After those two fixes the bite shortcut is no longer required to clear a griefing re-lock (cover does it at par), but it still removes the mark and the grace from every drained borrower whose collateral is worth less than the record at the moment of the bite, whatever their intent. A borrower who re-locks collateral worth 110% of the record (above the record, so marked and given grace like anyone else) and is then moved below the record by a 10% price fall is liquidated by anyone, in the same block, for any `debtToRepay` the collateral covers, at the 20% penalty, with both bonus shares to the liquidator; an identical position with no record gets `bark` and up to six hours (NHI >= 0.85) to top up, and the comment's premise at 1123-1124 ('cannot recover by waiting') does not hold, because the same price moving back up returns the position to the marked path. The harm is bounded by the re-lock (20% of the collateral bitten); the honest remedy the fix added (`cover` at value, 0% penalty) is strictly better for the protocol and the borrower than the shortcut. Reachable with the constants as committed. Smallest fix: drop the shortcut and require the ordinary mark and grace in `bite` for every position above dust (the mark costs one `bark`; grace is already zero whenever NHI <= 0.60), leaving `cover`'s at-value sweep as the permissionless remedy for a re-lock below the record; or, if the shortcut is kept, say in docs/MAINNET-RUNBOOK.md and the borrower docs that a drained borrower must re-lock at least the recorded bad debt's worth in one transaction and keep it above the record.","line":1129,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault at $1, NHI 0.60 (mat 200, lull 0, so grace is only the mark). B locks 2,000 and draws 1,000; K locks 40,000 and draws 10,000. Price to $0.50; bark(B); K bite(B, 833.333e18) drains B (collateral 0, totalBadDebt == debtOf(B) ~166.7). Price back to $1; two days (the old mark expires); B lock(1.1 x debtOf(B)) (worth 110% of the record). K bite(B, 1e18): reverts MarkExpired (the ordinary path: a fresh bark is needed). Price to $0.90: the re-lock is worth 99% of the record. K bite(B, 10e18) with no bark: EXPECTED PositionNotMarked / MarkExpired like any other borrower at that ratio. ACTUAL: succeeds at once, seizing 10 x 1.2 / 0.9 = 13.3 IMD with both bonus shares to K. test/scratch/CoverSweep.t.sol test_priceMoveBetweenDepositsExposesRebuilder (passes: it pins the behaviour).","severity":"low","snippet":"        if (!_relockBelowBadDebt(owner, price)) {","title":"CDPVault.bite: the no-mark, no-grace path keys on the bad-debt record, so a borrower rebuilding a drained position loses mark and grace after a price fall between deposits and is bitten at the 20% pen"},{"citation":"resolved","description":"Q3/Q4 transient bookkeeping. SUPPLY_START_SLOT holds the supply at the transaction's first mint or burn plus one. A `draw(D)` earlier in the same transaction makes the live supply `start + D`, and `_redemptionRate` only requires `amount <= live`, so `cash(amount)` with `amount > start + 1` wraps the slot to ~2^256. From then on `_supplyStart()` reads ~2^256: `_backingPerUnit` divides by it (payout 0, every further `cash` in the transaction reverts ZeroAmount) and `_redemptionRate` reads a zero increase. The effect is confined to the caller's own transaction (transient storage; `_laggedSupply` is written from the live supply and saturates), so nothing persists and nobody else is affected; a contract that batches a draw with several redemptions is the only victim. Smallest fix: saturate, e.g. `let s := tload(SUPPLY_START_SLOT) tstore(SUPPLY_START_SLOT, sub(s, mul(amount, lt(amount, s))))`, or recompute the slot as `max(1, s - amount)`.","line":734,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault at $1, supply 1,000 all held by an actor contract (Treasury holds IMD so redemptions are reserve-funded). One call: `draw(9,000)`, `cash(1,002e18, 0, 0)` (succeeds, fee at the cap), `cash(1e18, 0, 0)`. EXPECTED: the second redemption pays about par less the capped fee. ACTUAL: reverts ZeroAmount because `_supplyStart()` reads 2^256 - 1 and the payout scale is 0. Pinned by a scratch test (test_supplyStartSlotWrapsAfterALargeSameTxRedemption, expecting ZeroAmount, passes).","severity":"info","snippet":"            tstore(SUPPLY_START_SLOT, sub(tload(SUPPLY_START_SLOT), amount))","title":"CDPVault.cash: the transient start-supply slot is decremented with an unchecked assembly `sub`, so a redemption that burns more than the supply the transaction began with wraps it, and every later red"},{"citation":"resolved","description":"(1) 1002-1003 `_lag`: the position's cold saturates at 2^128 but `total` (the vault's cold) receives the whole increase, so the excess is COLD in `laggedNow` until it cools or a quiet day passes; only the position's bank treats it as warm on a later decrease. 'counts as warm' is the opposite direction for the figure that matters (unreachable at sIMD's supply; the direction is the safe one). (2) 893-895 `_redemptionRate`: 'a repayment in it or in the last few hours does not shrink the base' is true only of WARM principal: a repayment of cold principal in an earlier transaction shrinks the base at once (`_coldRepaidCountsAtOnce`, by design), while in the same call even a cold repayment does not (`_laggedSupplyFrom` floors at the pre-wipe `start`), which contradicts 334-337 and 1427-1429 (finding 1 above). (3) 768-770 `_backingPerUnit`, the accepted cross-transaction premium: the formula (`supply / (supply - repaid)`, repayment bounded by principal above half the collateral's value) holds and test/retry-panel/AdjacentTxBurn.t.sol pins it, but the parenthetical '15% of it at a 170% minimum ratio, 25% at 150%' describes a churner at mat; `wipe` has no health check, so a position below mat that is unmarked or inside its grace (CR 110%, say) can repay 45% of its principal without moving its term, and several churners add. State the bound as the general clause. (4) 1123-1124 `bite`: 'cannot recover by waiting' is not a property of the position: a price rise that lifts the re-lock above the record returns it to the mark-and-grace path (finding 2 above). Checked and consistent: 308-323 (lagged capital, '94% / 99.6%'), 954-965 `_lag`, 1015-1018 `_cool`, 1031 `laggedNow`, 242-256 the transient slots, 334-342 the lagged supply, 557-569 `cover`, 298-305 `totalBadDebt`, 750-772 the rest of `_backingPerUnit`, ParameterizedVault 230-258 `backedDebt`, ImdUSD throughout.","line":1003,"path":"src/CDPVault.sol","reproduction":"(1) read `_lag` 996-1012: `total += after_ - before - credit` is uncapped while `cold` is capped at 1004. (2) test/scratch/FeeDilution.t.sol test_wipeOfColdPrincipalThenCashInOneTransactionReadsThePreWipeSupply. (3) `wipe` 548-555 has no `_healthy` check; `_secured` binds on collateral while `principal * 2e18 / price >= collateral`. (4) test/scratch/CoverSweep.t.sol test_priceMoveBetweenDepositsExposesRebuilder, with the price moved back to $1 the same bite reverts MarkExpired again.","severity":"info","snippet":"        // units (3.4e14 sIMD) a position's excess over that counts as warm.","title":"Comments and NatSpec that claim properties the committed code does not have (lag saturation direction, cold repayment and the fee base, the premium bound's parenthetical, the no-grace rationale)"}],"hash":"3289b01d43686a051e7fcc9ab6e2782d4d2e07bb23ca418fcaddcbf4cbc1b485","nodeId":"c2865a48-31b6-4fdd-9b4b-5b7358f2de63","outcome":"completed","summary":"Review complete. The findings file is at `.imd-findings.json` with four entries; no source files were changed, and the scratch tests under `test/scratch/` are mine.\n\n## Findings\n\n- **Medium, `CDPVault._redemptionRate` line 906.** The fee base counts cold principal at once. A `lock` + `draw(9,000)` one block before someone's `cash(90)` against a warm supply of 1,000 cuts the quoted fee from 500 bps to 95 bps and sets the base rate to a tenth of honest; a `wipe(9,000)` the next block puts everything back, with the position's bank empty and nothing lagging. The same works inside one call as `wipe(cold D)` then `cash`, because the base floors at the pre-wipe supply, which contradicts the NatSpec's \"cold repaid counts at once\". Cost is gas and one block of exposure, not seasoned capital. Proof attached (two tests fail on this code, the honest baseline passes). Fix: measure the fee base against warm supply, as backing does, and let cold repaid this transaction reduce the start figure too.\n- **Low, `CDPVault.bite` line 1129.** The no-mark, no-grace path keys on the bad-debt record, not on a griefing re-lock. A drained borrower who re-locks 110% of the record is on the ordinary marked path, then a 10% price fall puts them below it and anyone bites at once at the 20% penalty, where an identical position without a record gets a mark and grace. Since `cover` now takes such a re-lock at par with no liquidator, the shortcut no longer earns its cost. Reproduced in a scratch test.\n- **Info, `cash` line 734.** The transient start-supply slot is decremented with an unchecked assembly `sub`. After a `draw` in the same transaction, a redemption larger than the starting supply wraps it, and every later `cash` in that transaction reverts `ZeroAmount`. Confined to the caller's own transaction.\n- **Info, comment claims (anchored at line 1003).** Four places where text and code disagree, listed below.\n\n## Answers to the numbered questions\n\n1. **Cold capital per position.** Holds. (a) The vault total cools per step with ceil and a position in one step with floor; a position's quiet day only lowers the sum, and the vault's quiet day implies every position's. I fuzzed the invariant \"vault cold total at least the sum of positions' cold, on both sides\" over 20,000 handler calls (lock, draw, wipe, free, cash, price moves, warps up to 30 hours) with direct storage reads; it never broke beyond sub-wei `_pow` rounding. (b) Draw then cancel through cash, bite and cover, in one transaction and across blocks, all leave the newcomer's capital cold (committed retry-panel test plus my bite and cover orderings). Banks are per position and only the owner can increase a position, so nothing moves. (c) A touch only restarts the vault total's quiet day; a position's quiet-day zeroing feeds only its bank split, and the vault total keeps the cold, so round trips are neutral or conservative. One conservative artifact: a position that withdraws after its own quiet day while others kept the vault busy leaves its residual cold orphaned in the vault total until it decays. (d) Saturation is safe in the conservative direction (the total is not capped, the position is), `_pow` is 17 iterations under the one-day short-circuit and 28 to 35 for the redemption decay, and every unchecked subtraction is guarded.\n2. **The bank.** No inflation, transfer, trickle, or cross-side revival found. One date per bank set when it fills from empty, expiry checked on the side touched, credit only on the same position's increase. The one over-credit is after the position's own quiet day (the whole amount banks while the continuous figure would leave one sixteenth cold), and it is matched by the orphaned cold in the vault total, so `laggedNow` is unaffected.\n3. **Lagged fee base.** The downward direction (pin the fee at the cap) is closed: warm repayments fade, a fresh self-redemption only shrinks the base to the warm supply, which is the honest figure. The upward direction is finding 1. The transien","treeHash":null,"usage":{"cachedInputTokens":4988712,"inputTokens":866,"model":"claude-fable-5-1","outputTokens":112827,"runtime":"claude","turns":38,"wallClockMs":1862956}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"11e4593175677f7e","findings":[{"citation":"resolved","description":"Q3. `_laggedSupply` is stored as an absolute figure and `_laggedSupplyFrom(start)` reads it as max(start, start + cool(lagged - start)). A mint therefore absorbs the fading excess that a warm repayment left behind (start grows, the stored figure does not), and `_coldRepaidCountsAtOnce` then subtracts the cold principal repaid from the same absolute figure. Draw D cold and repay D in the same transaction (or the next): the draw's checkpoint sets _laggedSupply = start + cool(E) where E is the warm excess, the mint lifts the live supply by D so that the excess E is now inside the figure, and the cold repayment subtracts D, taking E with it. The lag the retry panel's medium #5 was fixed with is thereby removed by anyone, for gas and collateral held for one call. Function: _coldRepaidCountsAtOnce (line 1437) with _checkpointSupply (line 1417) and _laggedSupplyFrom (line 1424). Call sequence from an external caller, launch constants (divisor 2, wage 0): supply 1,000 (BORROWER 900 seasoned two days, OTHER 100), Treasury holds IMD so redemptions are reserve-funded. tx1: BORROWER wipe(900e18): live 100, _laggedSupply 1,000, redemptionFeeBps(9e18) still 95. tx2 (one call through a contract, by anyone with 2,000 IMD of collateral for the length of the call): draw(900e18), wipe(900e18), cash(9e18, 0, 0). tx3: BORROWER draw(900e18), credited warm from its own bank. Reachable with the constants as committed, no governance. Victims: every later redeemer pays ~500 bps instead of 50 for a half-life or two; the peg floor min(1 - fee, backing) is 0.95 on demand; a candidate deters redemptions against itself. Smallest fix: keep the lag as an EXCESS over the live supply rather than an absolute figure: at each checkpoint cool the excess; add (principalPaid - coldOut) to it at every repayment burn (_payDebt, cover); leave it untouched by mints, redemptions and cold repayments; prior = start + excess. A mint can then not absorb it and a cold repayment cannot subtract from it. NatSpec this contradicts: lines 337-338 ('a repayment of warm principal only as it ages, its excess halving every BACKING_HALF_LIFE'), 893-895 ('a repayment in it or in the last few hours does not shrink the base'), 1427-1429.","line":1437,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract FbeFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function set(uint256 v) external {\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 FbeMirror 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 FbeAggregator {\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\n/// @dev One transaction: draw cold principal, repay it, redeem. The cold repayment is subtracted from\n/// the lagged supply, which the draw had already absorbed the warm excess into.\ncontract FbeAttacker {\n    ParameterizedVault private immutable vault;\n    MockIMD private immutable imd;\n\n    constructor(ParameterizedVault v, MockIMD i) {\n        vault = v;\n        imd = i;\n        i.approve(address(v), type(uint256).max);\n    }\n\n    function lock(uint256 amount) external {\n        vault.lock(amount);\n    }\n\n    function drawWipeCash(uint256 draw, uint256 redeem) external {\n        vault.draw(draw);\n        vault.wipe(draw);\n        vault.cash(redeem, 0, address(0));\n    }\n}\n\n/// @notice Q3: a cold draw absorbs the lagged fee base's excess, and its repayment then subtracts from it,\n/// so a warm repayment's lag is erased for gas and the fee is pinned at the cap for a tenth of the cost.\ncontract FeeBaseErasedTest is Test {\n    address private constant BORROWER = address(0xB0B);\n    address private constant OTHER = address(0x07E);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    FbeFeed private primary;\n    FbeAttacker private attacker;\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 FbeAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new FbeFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1\n        FbeFeed health = new FbeFeed(0.85 ether); // mat 170, gap 50\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new FbeMirror(primary))\n        );\n        stable = vault.stablecoin();\n        attacker = new FbeAttacker(vault, imd);\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 10_000 ether);\n        imd.mint(OTHER, 10_000 ether);\n        imd.mint(address(attacker), 10_000 ether);\n        vm.stopPrank();\n        vm.prank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(OTHER);\n        imd.approve(address(vault), type(uint256).max);\n    }\n\n    /// @dev The committed regression (test/retry-panel/AdjacentTxBurn.t.sol) as a baseline: a seasoned\n    /// dominant borrower's wipe, a small redemption, a redraw: the base is the supply before the churn.\n    function test_baseline_warmRepaymentStaysInTheBase() public {\n        _seasonedDominantBorrower();\n        vm.prank(BORROWER);\n        vault.wipe(900 ether);\n        vm.prank(OTHER);\n        vault.cash(9 ether, 0, address(0));\n        assertEq(vault.redemptionBaseRate(), 0.0045e18, \"45 bps: the base is still 1,000\");\n    }\n\n    /// @dev The same, with a third party's cold draw-and-wipe between the wipe and the redemption, in ONE\n    /// transaction with the redemption. EXPECTED: the base is still the 1,000 that stood before the warm\n    /// repayment, so 9 of it raises the rate 45 bps. ACTUAL: the base is 100 and the rate is pinned at the cap.\n    function test_coldDrawAndWipeErasesTheLaggedBase() public {\n        _seasonedDominantBorrower();\n        vm.prank(BORROWER);\n        vault.wipe(900 ether);\n        assertEq(stable.totalSupply(), 100 ether + _fees(), \"live supply after the warm repayment\");\n        assertEq(vault.redemptionFeeBps(9 ether), 95, \"the warm repayment is still in the base: 50 + 45\");\n\n        // Next transaction, by anyone with collateral for one call: draw 900 cold, repay it, redeem 9.\n        attacker.lock(2_000 ether);\n        vm.prank(OTHER);\n        stable.transfer(address(attacker), 9 ether);\n        attacker.drawWipeCash(900 ether, 9 ether);\n\n        assertEq(vault.redemptionBaseRate(), 0.0045e18, \"the base must still be the 1,000 before the churn\");\n    }\n\n    function _seasonedDominantBorrower() private {\n        vm.startPrank(BORROWER);\n        vault.lock(2_000 ether);\n        vault.draw(900 ether);\n        vm.stopPrank();\n        vm.startPrank(OTHER);\n        vault.lock(200 ether);\n        vault.draw(100 ether);\n        stable.transfer(BORROWER, 50 ether);\n        vm.stopPrank();\n        address treasury = address(vault.treasury());\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(treasury, 100 ether); // redemptions are reserve-funded\n        vm.warp(block.timestamp + 2 days);\n        assertEq(stable.totalSupply(), 1_000 ether);\n    }\n\n    function _fees() private view returns (uint256) {\n        return stable.balanceOf(address(vault.treasury()));\n    }\n}","reproduction":"test/scratch/FeeBaseErased.t.sol test_coldDrawAndWipeErasesTheLaggedBase (fails on this code; the baseline test in the same file, the committed regression's sequence, passes). ParameterizedVault over an 18-decimal MockIMD at $1, NHI 0.85. BORROWER locks 2,000, draws 900; OTHER locks 200, draws 100 and gives BORROWER 50 imdUSD; Treasury minted 100 IMD; two quiet days. tx1 BORROWER wipe(900e18). tx2 attacker contract: lock(2000e18) then in ONE call draw(900e18), wipe(900e18), cash(9e18, 0, address(0)). EXPECTED: redemptionBaseRate == 0.0045e18 (45 bps: 9 of the 1,000 that stood before the warm repayment). ACTUAL: 44901683765300744 (449 bps: 9 of 100.35, the fee pinned at the cap for everyone), for 0.45 imdUSD of fee plus gas instead of a burn of 9% of supply.","severity":"medium","snippet":"        _laggedSupply = lagged > cold ? lagged - cold : 0;","title":"CDPVault fee base: a cold draw absorbs the lagged supply's warm excess and its repayment then subtracts it, erasing a warm repayment's lag for gas"},{"citation":"resolved","description":"Q2 / Q1(b). The retry panel's medium #3 was answered with one date per bank, 'set only when that bank goes from empty to full', so that 'a bank expires one warm-up after the capital first left'. But a return credits the bank down to zero (line 996-997), and the next departure finds bank == 0 and dates the refilled bank at that moment (line 992). A position that seasoned its capital once can therefore leave, come back for a single transaction every 23 hours (lock + draw, credited in full on both sides), leave in the next transaction (banked again, re-dated), and never warm up again: the capital is in the vault one block out of every 23 hours, indefinitely. That is exactly the D1 round trip the lag exists to close, for anyone who has held a position for one day at any time in the past: bring the capital in (tx N), redeem reserve IMD at the lifted backing or earn against the lifted ceiling (tx N+1), withdraw (tx N+2), with the capital exposed to price and liquidation for one block per cycle. Reachable with the constants as committed: the redemption half at wage 0 (`_backingPerUnit`'s lagged figure reads min(held, lagSecured) and supply - fresh with the returning capital warm), the ceiling half once a wage is set. Bounded by what the position once held warm, hence medium, as the panel rated the same 'permanent option'. Smallest fix that keeps the design: let a bank COOL while it waits, at the lag's own half-life, and credit only what is left: credit = min(increase, _cool(bank, now - bankAt, false)) with the expiry becoming the natural zero at BACKING_WARMUP. A borrower's ordinary wipe-and-redraw minutes apart is still credited almost whole; capital away for 23 hours comes back about 7% warm; a one-block visit cannot re-arm it because the refilled bank is dated at the departure and cools from there. NatSpec this contradicts: lines 318-319 ('capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par'), 760-761 ('An attacker's capital can raise the live figure but not the lagged one, whichever position it sits in').","line":992,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract BrfFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function set(uint256 v) external {\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 BrfMirror 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 BrfAggregator {\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\n/// @notice Q2: a bank's date is set when it goes from empty to full. A one-block visit (lock + draw, credited\n/// in full, then wipe + free, banked again) empties and refills it, so the day restarts. A once-seasoned\n/// position keeps its warmth forever while its capital is away all but one block a day.\ncontract BankRefreshTest is Test {\n    address private constant BORROWER = address(0xB0B);\n    address private constant OTHER = address(0x07E);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    BrfFeed 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 BrfAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new BrfFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1\n        BrfFeed health = new BrfFeed(0.85 ether); // mat 170, gap 50\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new BrfMirror(primary))\n        );\n        stable = vault.stablecoin();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 100_000 ether);\n        imd.mint(OTHER, 100_000 ether);\n        vm.stopPrank();\n        vm.prank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(OTHER);\n        imd.approve(address(vault), type(uint256).max);\n        // OTHER gives the borrower fee money so it can always repay in full.\n        vm.startPrank(OTHER);\n        vault.lock(1_000 ether);\n        vault.draw(200 ether);\n        stable.transfer(BORROWER, 200 ether);\n        vm.stopPrank();\n    }\n\n    /// @dev Season 2,000 / 1,000 for two days, leave, and come back for one block every 23 hours. After the\n    /// capital has been away for more than BACKING_WARMUP in total (two visits of one block in 46 hours), the\n    /// bank that was created when it FIRST left has had its day, and the returning capital should be cold.\n    function test_oneBlockVisitsKeepTheBankAliveForever() public {\n        vm.startPrank(BORROWER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days);\n        (uint256 warmBefore, uint256 warmSecBefore) = vault.laggedNow();\n        assertEq(warmBefore, 1_200 ether, \"everything warm after a quiet warm-up\");\n        assertEq(warmSecBefore, 2_000 ether + 400 ether, \"both terms warm\");\n\n        // Leave: the capital first leaves at t0.\n        _leave();\n        uint256 t0 = block.timestamp;\n        (uint256 warmAway,) = vault.laggedNow();\n        assertEq(warmAway, 200 ether, \"only OTHER's debt remains\");\n\n        // Ten one-block visits, 23 hours apart: in each, come back (credited), and leave the next transaction.\n        for (uint256 i; i < 10; ++i) {\n            vm.warp(block.timestamp + 23 hours);\n            _comeBack();\n            (uint256 warm, uint256 warmSec) = vault.laggedNow();\n            if (block.timestamp - t0 > vault.BACKING_WARMUP()) {\n                // EXPECTED: the returning 1,000 of debt and 2,000 of collateral are cold: the bank was created\n                // when the capital first left, more than a warm-up ago, and the capital has been in the vault\n                // for one block since. ACTUAL: credited in full, every time.\n                assertLe(warm, 200 ether + 70 ether, \"capital away for a day comes back cold\");\n                assertLe(warmSec, 400 ether + 140 ether, \"collateral away for a day comes back cold\");\n            }\n            _leave();\n        }\n    }\n\n    function _leave() private {\n        vm.startPrank(BORROWER);\n        vault.wipe(vault.debtOf(BORROWER));\n        (uint256 collateral,) = vault.positions(BORROWER);\n        vault.free(collateral);\n        vm.stopPrank();\n    }\n\n    function _comeBack() private {\n        vm.startPrank(BORROWER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        vm.stopPrank();\n    }\n}","reproduction":"test/scratch/BankRefresh.t.sol test_oneBlockVisitsKeepTheBankAliveForever (fails on this code). ParameterizedVault at $1, NHI 0.85, wage 0. OTHER 1,000 / 200 and hands BORROWER 200 imdUSD of fee money. BORROWER locks 2,000, draws 1,000; two quiet days: laggedNow() == (1,200, 2,400). t0: BORROWER wipe(debtOf) and free(all): laggedNow().debt == 200, bankDebt 1,000 and bankSecured 2,000 dated t0. Every 23 hours: lock(2000e18) + draw(1000e18) in one transaction, then wipe + free in the next. EXPECTED at t0 + 46 h (the capital first left more than a BACKING_WARMUP ago and has been present for two blocks since): laggedNow().debt <= 270e18 and .secured <= 540e18 (OTHER's terms plus at most 7% of the returning capital). ACTUAL: laggedNow() == (1200e18, 2400e18) on every visit, ten cycles over ten days in the test.","severity":"medium","snippet":"                    if (bank == 0) bankAt = block.timestamp;","title":"CDPVault._lag: a bank fully credited by a one-block visit is re-dated when the capital leaves again, so a once-seasoned position keeps its warmth forever while its capital is away all but one block a "},{"citation":"resolved","description":"Q3 / Q4 transient bookkeeping. The slot holds supply-at-start + 1. `cash` subtracts its whole burn with `sub`, which has no underflow check in assembly. A transaction that mints first (draw or earn) and then redeems more than the supply it began with (amount > start + 1; allowed, since _redemptionRate only bounds amount by the LIVE supply) leaves the slot at about 2^256 - start. `_supplyStart()` then returns that, `_backingPerUnit` divides by it and returns 0, `payoutScale` is 0, gemOut is 0 and every later `cash` in the transaction reverts ZeroAmount; `_redemptionRate` reads an increase of 0 for them too. When amount == start + 1 exactly the slot becomes 0 and reads as 'not checkpointed', which is harmless. Persistent state is not corrupted (the slot is transient, and _laggedSupply is only ever lowered), so the harm is a revert of a composed flow: a router or multicall that borrows or earns and redeems in one transaction, or an early-launch transaction when the supply is small. Reachable with the constants as committed. Smallest fix: clamp in Solidity, e.g. `uint256 recorded = _transient(SUPPLY_START_SLOT); tstore(SUPPLY_START_SLOT, recorded > amount ? recorded - amount : 1)` (a start of zero), and treat `amount >= recorded` as a start of zero.","line":734,"path":"src/CDPVault.sol","reproduction":"test/scratch/SupplyStartUnderflow.t.sol test_secondRedemptionAfterOverStartRedemptionReverts (fails on this code with ZeroAmount()). ParameterizedVault at $1, NHI 0.85; OTHER 1,000 / 100 seasoned two days (supply 100); Treasury holds 10,000 IMD. One call through a contract: lock(2000e18), draw(1000e18) (supply 1,100), cash(200e18, 0, address(0)), cash(10e18, 0, address(0)). EXPECTED: both redemptions paid pro rata from the reserve (the first about 199 IMD, the second about 9.9 IMD). ACTUAL: the first is paid, the second reverts ZeroAmount because the slot wrapped to 2^256 - 100 and _backingPerUnit reads 0.","severity":"low","snippet":"            tstore(SUPPLY_START_SLOT, sub(tload(SUPPLY_START_SLOT), amount))","title":"cash: SUPPLY_START_SLOT is reduced with an unchecked assembly sub, so a redemption larger than the supply the transaction began with wraps it and every later redemption in the transaction reverts"},{"citation":"resolved","description":"Q4 ('or underpays honest redeemers') and Q1(c). `_lag` is driven by the secured TERM, min(collateral, 2 x principal / price), not by the collateral itself. For a position above 200% the term is debt-bound; when the price falls and the position is next touched (its own lock, wipe or free, or ANY third party's `cash` against it once it is an eligible candidate), the term rises toward its collateral and `_lag` treats the whole rise as cold capital although nothing arrived: the collateral had been in the vault for days. `_backingPerUnit` then pays redeemers the lagged figure, which excludes that collateral, for a day after the fall (half of it for six hours), which is exactly when redemptions defend the peg. It is the 'safe direction' and not a theft, but it is not 'new capital' either, and a third party can impose it on every eligible candidate with a one-wei cash against each. Reachable with the constants as committed. Smallest fix: cold only the part of a term increase that corresponds to capital added in the same call: pass the collateral and principal this call added into `_resecure` (from lock, lockIMD and draw) and cap the cold increment at added collateral + 2 x added principal / price; a pure re-pricing is then warm, and the comment at 954-957 ('what a position adds is cold') becomes true as written.","line":950,"path":"src/CDPVault.sol","reproduction":"test/scratch/PriceFallColdTerm.t.sol test_priceFallMakesLongHeldCollateralColdAndUnderpaysRedeemers (fails on this code). ParameterizedVault at $1, NHI 0.85. P locks 4,000 and draws 1,000 (term 2,000, debt-bound); Q locks 1,700 and draws 1,000 (term 1,700); three quiet days: laggedNow().secured == 3,700e18, backingPerUnit() == 1e18. IMD to $0.50 (stale terms: backing 0.925). P lock(1). EXPECTED: laggedNow().secured >= 5,700e18 (P's term is re-priced to 4,000 with no capital added) and backingPerUnit() == 1e18 (min(5,700 x 0.5, 1.7 x 2,000) / 2,000, capped). ACTUAL: laggedNow().secured == 3,700e18 (2,000 IMD of three-day-old collateral is cold) and backingPerUnit() == 0.925e18 for the next day: a redeemer of 100 imdUSD is paid 7.5% less than the honest pro-rata figure.","severity":"low","snippet":"        _lag(position, true, before, current);","title":"_resecureBounded/_lag: a price fall re-prices a debt-bound secured term upward and the whole increase is cold, so collateral held for days is excluded from the lagged backing for a day after a fall an"},{"citation":"resolved","description":"Q1(a)/(d). The claim is that rounding the vault's totals up and a position's figure down keeps the total at or above the sum of the positions. `_pow` truncates at every squaring and multiplication, so _pow(d, a) * _pow(d, b) / RAY and _pow(d, a + b) differ by up to a few units in 1e27, and the one-unit ceil cannot cover that gap on an amount of 1e30 raw units (a million sIMD). So `laggedNow` can read a few thousand raw units (about 2e-27 of the cold) above the honest figure. Economically nothing: 2e-27 of any position is below one wei of imdUSD or one raw unit's worth of sIMD. Reported because the comment claims a property the code does not strictly have. Fix: reword ('within a few units of'), or make `_pow` round up when `up` is set (ceil at each step), which restores the inequality exactly.","line":1016,"path":"src/CDPVault.sol","reproduction":"test/scratch/PowMultiplicativity.t.sol test_search (fails on this code): a copy of _pow and _cool with the committed COLD_SECOND_DECAY; amount 1e30, a = 2932 seconds then b = 18215 seconds: _cool(_cool(1e30, 2932, up), 18215, up) is 2,158 raw units BELOW _cool(1e30, 2932 + 18215, down). EXPECTED by the NatSpec: the two-step total is never below the one-step position figure. ACTUAL: it is, by 2,158 units of 1e30.","severity":"info","snippet":"    /// totals (`up`, so they never read below the sum of the positions) and down for a position; and","title":"_cool NatSpec: the vault total cooled in two steps rounded up can read below a position cooled in one step rounded down, because _pow is not exactly multiplicative"},{"citation":"resolved","description":"Each is the documentation half of a finding above; reword to the behaviour the code has, or fix the code and keep the text. (1) Lines 318-319 and 760-761: 'capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par' / 'An attacker's capital can raise the live figure but not the lagged one, whichever position it sits in': false for a position that was once seasoned, because a one-block return and departure re-dates its bank (medium, _lag line 992). (2) Lines 337-338 ('a repayment of warm principal only as it ages, its excess halving every BACKING_HALF_LIFE'), 893-895 ('a repayment in it or in the last few hours does not shrink the base') and 1427-1429 ('Only warm repayments lag'): a cold draw and repayment after the warm repayment erases the excess at once (medium, _coldRepaidCountsAtOnce line 1437). (3) Lines 954-957 and 312-313 ('what a position adds is cold'): a price fall's re-pricing of a debt-bound term is cold although nothing was added (low, line 950). (4) Line 1016 ('so they never read below the sum of the positions'): not exact, by units of 1e-27 (info). (5) Line 768-770, the accepted cross-transaction premium: the formula (the principal above half the collateral's value) holds, and the figures 15% / 25% hold for a churner AT the minimum ratio; a churner below mat can repay up to 50% of its principal without moving its term, but cannot redraw without passing the health check, so the gas-only version is bounded as stated. Checked and consistent: the Position struct comments (41-60), line 983 ('Every subtraction below is guarded'), line 1002-1003 (uint128 saturation, 3.4e14 sIMD), lines 316-317 (94% / 99.6% under activity), cover's 562-564 and 629, bite's 1123-1127, _relockBelowBadDebt, _recordBadDebt, ParameterizedVault.backedDebt 236-240, and the transient-slot comments at 242-256 for the same call.","line":318,"path":"src/CDPVault.sol","reproduction":"Each claim is refuted by the reproduction of the finding it documents: test/scratch/BankRefresh.t.sol (claim 1), test/scratch/FeeBaseErased.t.sol (claim 2), test/scratch/PriceFallColdTerm.t.sol (claim 3), test/scratch/PowMultiplicativity.t.sol (claim 4). Claim 5: read lines 768-770 against _secured (922-928) and draw's health check (489).","severity":"info","snippet":"    /// position's own cold first and counts at once. So capital brought in one transaction and withdrawn a\n    /// few later cannot authorise work minting or a redemption at par, and what one position removes can","title":"NatSpec and comments that claim properties the committed code does not have after 24337a2 (the lagged fee base, the bank, the cold term)"}],"hash":"5b59f0c7a40b8563a5dad6259fa73e6a936dcb4b28cf4c4f9d018dafa1488f2a","nodeId":"6e79c021-8f85-4323-9048-ce2c7ab78cb9","outcome":"completed","summary":"The review is complete. The tree is clean apart from the gitignored findings file and the scratch tests, and `.imd-findings.json` holds six verified findings.\n\n**Findings (all reproduced by failing Foundry tests under test/scratch/, with the two mediums carrying proofs)**\n\n- **Medium, fee base erased by a cold draw-and-wipe** (`_coldRepaidCountsAtOnce`, CDPVault.sol:1437). The lagged supply is stored as an absolute figure, so a mint absorbs the warm excess and a cold repayment then subtracts it. After a seasoned borrower wipes 900 of 1,000, anyone with collateral for one call does draw 900, wipe 900, cash 9 in one transaction. Expected base rate 45 bps, actual 449 bps. The retry panel's medium #5 is reopened at the same cost. Fix: keep the lag as an excess over the live supply, added to only by warm repayments.\n- **Medium, bank re-armed by a one-block visit** (`_lag`, CDPVault.sol:992). A return credits the bank to zero, and the next departure dates a fresh bank. A once-seasoned position can be away all but one block every 23 hours forever and always read warm. Expected after 46 hours away: cold. Actual: fully warm on every visit, ten cycles. This is the permanent option the panel's medium #3 named. Fix: cool the bank at the lag's half-life while it waits.\n- **Low, transient supply-start underflow** (`cash`, CDPVault.sol:734). An unchecked assembly `sub` wraps when a transaction mints and then redeems more than its starting supply. A second same-transaction redemption reverts ZeroAmount instead of being paid.\n- **Low, price-fall re-pricing counts as cold** (`_resecureBounded`, CDPVault.sol:950). A debt-bound term grows when the price falls and the position is touched, and the growth is cold although nothing arrived. Redeemers are paid 0.925 instead of 1.0 for a day, and a third party can impose it with a one-wei cash against any eligible candidate.\n- **Info, rounding claim** (CDPVault.sol:1016). The vault total cooled in two steps can read below a one-step position by units of 1e-27, so \"never\" is not exact. Immaterial.\n- **Info, NatSpec claims the code does not have**, with the list at lines 318, 337, 760, 893, 954, 1016, 1427.\n\n**Answers to the numbered questions**\n\n1. The per-position cold holds: the vault total's date is always at or after every position's date, so its factor is never smaller, zeroing after a quiet day is consistent, and cross-position inheritance is gone in both orders. Rounding, the 128-bit saturation, `_pow` precision and the unchecked block are sound except for the 1e-27 note above. Activity cannot speed warming, but a pure re-pricing is treated as new capital (low finding).\n2. The bank cannot be inflated, moved between positions, kept past its day by a trickle or the other side. It can be kept alive by a one-block round trip, which is the medium finding.\n3. The base never drops below the live start supply, and inflating it needs seasoned principal. It can be pushed down to the live supply at once by a cold draw-and-wipe, which is the medium finding. Same-transaction bookkeeping across wipe, cash, bite, cover, earn and remints is consistent, with the one slot underflow noted.\n4. The accepted premium's formula and bound hold as stated. I found no path raising backing above honest. The underpayment path is the low finding.\n5. Cover's value sweep and bite's no-mark path keep the record and totalBadDebt consistent. A rebuilding borrower whose tranche or price-moved re-lock sits below the record is covered at par or bitten without grace, which is the accepted design and I did not re-report it.\n6. No regression found in the fresh-debt record, wage gate, liquidation, redemption, fee index or price gating. ParameterizedVault runtime is 21,796 bytes with 3,171 bytes of initcode margin.\n\n**Coverage.** Read in full: CDPVault.sol, ParameterizedVault.sol, ImdUSD.sol, Treasury.sol, DeploymentConfig.sol, and the first 200 lines of Parameters.sol. Read for context only: the retry-panel audit, its regression tests and ","treeHash":null,"usage":{"cachedInputTokens":3985575,"inputTokens":770,"model":"claude-fable-5-1","outputTokens":99569,"runtime":"claude","turns":45,"wallClockMs":1463623}},{"artifacts":[],"attempt":3,"bundleHash":null,"device":"9b06782c7559b54c","findings":[{"citation":"resolved","description":"Q3; a gap left by the fix for the retry panel's medium #5 (merged from audit_flow 2f12b4a4 and audit_math a73695b7; audit_economics cba55ee9 is the documentation half of the same mechanism). `_laggedSupply` is stored as an ABSOLUTE figure and `_laggedSupplyFrom(start)` reads max(start, start + cool(_laggedSupply - start)). Two rules contradict each other. (1) A mint never touches `_laggedSupply` (`_mintOrBurn` only checkpoints), so at the next checkpoint the fading excess is measured against a live supply the mint has raised: a cold draw (or an `earn`) of M absorbs M of the warm repayment's share. (2) `_coldRepaidCountsAtOnce` (line 1437) then subtracts a cold repayment from `_laggedSupply` in full, although the mint it repays was never added to it. Net effect of a cold draw-and-repay of M by ANY position: the base falls by min(M, excess). Call sequence (launch constants, wage 0, divisor 2; BORROWER holds 900 of a 1,000 supply as seasoned debt, OTHER 100; the Treasury holds IMD so the redemption is reserve-funded): tx1 BORROWER wipe(900e18): warm, banked, live 100, `_laggedSupply` 1,000 (redemptionFeeBps(9e18) still 95, as designed). tx2 any position (a contract in one call, or an EOA in three transactions) lock(2000e18), draw(900e18), wipe(900e18): the draw's checkpoint sets `_laggedSupply` = 100 + cool(900) = 999.6, the mint lifts live to 1,000; the wipe's `_lag` retires 899.6 of cold principal into COLD_REPAID_SLOT and `_coldRepaidCountsAtOnce` sets `_laggedSupply` = 1,000 - 899.6 = 100.4, live 100. tx3 anyone cash(9e18, 0, address(0)): `_redemptionRate` reads prior 100.4 and increase 9 / 100.4 / 2 = 4.48%: `redemptionBaseRate` is set to the 4.5% cap where the honest base of 1,000 gives 0.0045e18. tx4 BORROWER draw(900e18), credited warm from its bank: the position is exactly where it started. The same erasure works through `cash` against the cold helper instead of its wipe. Cost: gas, one block, no seasoned capital (the helper can even use the collateral BORROWER freed) and 0.45 imdUSD of fee against the honest 4.5 (a burn of 9% of supply). Who loses: every later redeemer pays 500 bps instead of 50 for a half-life or two; the peg floor min(1 - fee, backing) sits at 0.95 on demand; a candidate can deter redemptions against itself; repeatable every half-life. Reachable with the constants as committed, no governance; needs one large position (LINE is $1M at launch). The regression test test/retry-panel/AdjacentTxBurn.t.sol test_adjacentWipeCashDrawPinsTheFeeBase passes only because its redraw is credited from the same position's bank (warm), so no cold principal is ever retired. NatSpec the code does not have: lines 334-337 ('an increase counts at once ... a repayment of warm principal only as it ages, its excess halving every BACKING_HALF_LIFE'; an increase is in fact absorbed by the fading excess, so after a warm repayment of W and a new draw of E the base is max(live, fading high-water mark), not live + fading W), 893-895 ('a repayment in it or in the last few hours does not shrink the base') and 1427-1429 ('Only warm repayments lag'). Smallest fix: keep the lag as an EXCESS over the live supply rather than an absolute figure. At each checkpoint cool the excess; at every repayment burn (`_payDebt`, `cover`) add principalPaid - coldRepaid to it; on a draw subtract the part credited from the position's bank (the same supply returning; floor at zero) and nothing else; leave it untouched by other mints, by redemptions and by cold repayments; prior = start + cooled excess. A mint can then not absorb it and a cold repayment cannot subtract from it, while a wipe-and-redraw by the same position still nets to the pre-churn supply. (audit_flow verified an equivalent variant locally: tally increases into the lagged figure at once, less the bank-credited part, so a cold repayment's subtraction is matched by its mint's addition; the attached proof and the committed redemption, retry-panel, lag, cover, liquidation and invariant suites s","line":1437,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// A cold draw by any position absorbs a warm repayment's fading share of the lagged fee base at the next\n// checkpoint, and the cold repayment is then subtracted from the base a second time: a dominant borrower's\n// wipe, a helper's draw-and-wipe, and a small cash pin the redemption fee at the cap for a tenth of the\n// honest cost, with no seasoned capital and no wait.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract FbFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\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 FbMirror 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 FbAggregator {\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\n/// @dev A helper position that draws cold principal and repays it in the same call.\ncontract FbHelper {\n    ParameterizedVault private immutable vault;\n\n    constructor(ParameterizedVault vault_, MockIMD imd_) {\n        vault = vault_;\n        imd_.approve(address(vault_), type(uint256).max);\n    }\n\n    function lockDrawWipe(uint256 collateral, uint256 debt) external {\n        vault.lock(collateral);\n        vault.draw(debt);\n        vault.wipe(debt);\n    }\n}\n\ncontract FeeBaseColdMintTest is Test {\n    address private constant BORROWER = address(0xB0B);\n    address private constant OTHER = address(0x07E);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    FbHelper private helper;\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 FbAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        FbFeed primary = new FbFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1\n        FbFeed health = new FbFeed(0.85 ether); // mat 170, gap 50\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new FbMirror(primary))\n        );\n        stable = vault.stablecoin();\n        helper = new FbHelper(vault, imd);\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 10_000 ether);\n        imd.mint(OTHER, 10_000 ether);\n        imd.mint(address(helper), 10_000 ether);\n        imd.mint(address(vault.treasury()), 100 ether); // the redemption is reserve-funded\n        vm.stopPrank();\n        vm.prank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(OTHER);\n        imd.approve(address(vault), type(uint256).max);\n\n        // A seasoned dominant position: 900 of a 1,000 supply, warm for two days.\n        vm.startPrank(BORROWER);\n        vault.lock(2_000 ether);\n        vault.draw(900 ether);\n        vm.stopPrank();\n        vm.startPrank(OTHER);\n        vault.lock(200 ether);\n        vault.draw(100 ether);\n        stable.transfer(BORROWER, 9 ether);\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days);\n        assertEq(stable.totalSupply(), 1_000 ether);\n        assertEq(vault.redemptionFeeBps(9 ether), 95, \"floor 50 + 9 / 1,000 / 2 = 45 bps\");\n    }\n\n    /// Transaction 1: the dominant borrower repays its warm 900 (the base keeps it, fading over hours).\n    /// Transaction 2: a helper position draws 900 cold and repays it in the same call.\n    /// Transaction 3: a 9 imdUSD redemption. EXPECTED: an increase of 45 bps against a base of about 1,000.\n    /// ACTUAL: the base read about 100, and the rate is pinned at the 450 bps cap for a tenth of the cost.\n    function test_aColdDrawAndRepayByAnotherPositionEmptiesTheLaggedFeeBase() public {\n        vm.prank(BORROWER);\n        vault.wipe(900 ether);\n        helper.lockDrawWipe(2_000 ether, 900 ether);\n        assertApproxEqAbs(stable.totalSupply(), 100 ether, 0.5 ether, \"supply is back where the wipe left it\");\n        // The quote already shows it: the increase for 9 imdUSD reads the cap instead of 45 bps.\n        assertLe(vault.redemptionFeeBps(9 ether), 95 + 1, \"the lagged base must still hold the warm 900 repaid moments ago\");\n        vm.prank(BORROWER);\n        vault.cash(9 ether, 0, address(0));\n        assertLe(vault.redemptionBaseRate(), 0.0046e18, \"a 9-of-1,000 burn must not pin the fee at the cap\");\n        // The dominant borrower redraws from its bank, warm, and the position is where it was.\n        vm.prank(BORROWER);\n        vault.draw(900 ether);\n    }\n\n    /// The same, as three separate transactions from two keys (no contract needed).\n    function test_theHelperNeedsNoContract() public {\n        vm.prank(BORROWER);\n        vault.wipe(900 ether);\n        vm.startPrank(OTHER);\n        vault.lock(2_000 ether);\n        vault.draw(900 ether);\n        vault.wipe(900 ether);\n        vm.stopPrank();\n        vm.prank(BORROWER);\n        vault.cash(9 ether, 0, address(0));\n        assertLe(vault.redemptionBaseRate(), 0.0046e18, \"a 9-of-1,000 burn must not pin the fee at the cap\");\n    }\n}","reproduction":"test/scratch/Proof_2f12b4a45902.t.sol (attached; both tests fail on this code, run by me). ParameterizedVault over an 18-decimal MockIMD at $1 (IMD/ETH 1/2000 times Chainlink ETH/USD 2000e8 etched at CHAINLINK_ETH_USD), NHI 0.85, TreasuryFactory etched at TREASURY_FACTORY, launch constants (wage 0, divisor 2). BORROWER locks 2,000 and draws 900; OTHER locks 200, draws 100 and gives BORROWER 9 imdUSD; the Treasury holds 100 IMD; two quiet days; supply 1,000, redemptionFeeBps(9e18) == 95. Test 1: BORROWER wipe(900e18) [tx 1]; a helper contract calls lock(2000e18), draw(900e18), wipe(900e18) in one transaction [tx 2]; supply back at ~100. EXPECTED: redemptionFeeBps(9e18) <= 96 and, after cash(9e18, 0, address(0)), redemptionBaseRate <= 0.0046e18. ACTUAL: redemptionFeeBps(9e18) == 500 ('the lagged base must still hold the warm 900 repaid moments ago: 500 > 96'). Test 2: the same three steps from two EOAs as separate transactions (OTHER locks 2,000, draws 900, wipes 900), then cash(9e18, 0, address(0)). EXPECTED: redemptionBaseRate 0.0045e18. ACTUAL: 44890786251530523 (the cap: 'a 9-of-1,000 burn must not pin the fee at the cap: 44890786251530523 > 4600000000000000'). audit_math's Proof_a73695b7acf2 (one call: draw, wipe, cash) fails the same way with base 44901683765300744 against 4500000000000000, and its baseline (the committed regression's sequence) passes.","severity":"medium","snippet":"        _laggedSupply = lagged > cold ? lagged - cold : 0;","title":"CDPVault lagged fee base: a cold draw by any position absorbs a warm repayment's fading share, and the cold repayment is then subtracted again, so the base collapses to the live supply and the fee is "},{"citation":"resolved","description":"Q2 and Q1(b) (from audit_math 73d81e30). The retry panel's medium #3 was answered with one date per bank, 'set only when that bank goes from empty to full', so that a bank expires one warm-up after the capital first left. But a return credits the bank down to zero (lines 996-997), and the next departure finds bank == 0 and dates the refilled bank at that moment (line 992). A position that seasoned its capital once can therefore leave, come back for a single transaction every 23 hours (lock + draw, credited in full on both sides), leave in the next transaction (banked again, re-dated) and never warm up again: the capital is in the vault one block out of every 23 hours, forever. That is exactly the D1 round trip the lag exists to close, for anyone who has held a position for one day at any time in the past: bring the capital in (tx N), redeem reserve IMD at the lifted backing or `earn` against the lifted ceiling (tx N+1), withdraw (tx N+2), with the capital exposed to price and liquidation for one block per cycle. Reachable with the constants as committed: the redemption half at wage 0 (`_backingPerUnit`'s lagged figure reads min(held, lagSecured) and supply - fresh with the returning capital warm), the ceiling half once a wage is set. Bounded by what the position once held warm, hence medium, as the panel rated the same 'permanent option' (its #3). NatSpec the code does not have: lines 318-319 ('capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par'), 759-760 ('An attacker's capital can raise the live figure but not the lagged one, whichever position it sits in'), 963-964 ('a bank only ever returns warmth to the position that lost it, within BACKING_WARMUP of the moment it first filled, whatever is added to it later'). Smallest fix that keeps the design: let a bank COOL while it waits, at the lag's own half-life, and credit only what is left: credit = min(increase, _cool(bank, block.timestamp - bankAt, false)), the expiry becoming the natural zero at BACKING_WARMUP. A borrower's ordinary wipe-and-redraw minutes apart is still credited almost whole; capital away for 23 hours comes back about 7% warm; a one-block visit cannot re-arm it, because the refilled bank holds only what was credited and is dated at the departure.","line":992,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract BrfFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function set(uint256 v) external {\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 BrfMirror 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 BrfAggregator {\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\n/// @notice Q2: a bank's date is set when it goes from empty to full. A one-block visit (lock + draw, credited\n/// in full, then wipe + free, banked again) empties and refills it, so the day restarts. A once-seasoned\n/// position keeps its warmth forever while its capital is away all but one block a day.\ncontract BankRefreshTest is Test {\n    address private constant BORROWER = address(0xB0B);\n    address private constant OTHER = address(0x07E);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    BrfFeed 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 BrfAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        primary = new BrfFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1\n        BrfFeed health = new BrfFeed(0.85 ether); // mat 170, gap 50\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new BrfMirror(primary))\n        );\n        stable = vault.stablecoin();\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 100_000 ether);\n        imd.mint(OTHER, 100_000 ether);\n        vm.stopPrank();\n        vm.prank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(OTHER);\n        imd.approve(address(vault), type(uint256).max);\n        // OTHER gives the borrower fee money so it can always repay in full.\n        vm.startPrank(OTHER);\n        vault.lock(1_000 ether);\n        vault.draw(200 ether);\n        stable.transfer(BORROWER, 200 ether);\n        vm.stopPrank();\n    }\n\n    /// @dev Season 2,000 / 1,000 for two days, leave, and come back for one block every 23 hours. After the\n    /// capital has been away for more than BACKING_WARMUP in total (two visits of one block in 46 hours), the\n    /// bank that was created when it FIRST left has had its day, and the returning capital should be cold.\n    function test_oneBlockVisitsKeepTheBankAliveForever() public {\n        vm.startPrank(BORROWER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days);\n        (uint256 warmBefore, uint256 warmSecBefore) = vault.laggedNow();\n        assertEq(warmBefore, 1_200 ether, \"everything warm after a quiet warm-up\");\n        assertEq(warmSecBefore, 2_000 ether + 400 ether, \"both terms warm\");\n\n        // Leave: the capital first leaves at t0.\n        _leave();\n        uint256 t0 = block.timestamp;\n        (uint256 warmAway,) = vault.laggedNow();\n        assertEq(warmAway, 200 ether, \"only OTHER's debt remains\");\n\n        // Ten one-block visits, 23 hours apart: in each, come back (credited), and leave the next transaction.\n        for (uint256 i; i < 10; ++i) {\n            vm.warp(block.timestamp + 23 hours);\n            _comeBack();\n            (uint256 warm, uint256 warmSec) = vault.laggedNow();\n            if (block.timestamp - t0 > vault.BACKING_WARMUP()) {\n                // EXPECTED: the returning 1,000 of debt and 2,000 of collateral are cold: the bank was created\n                // when the capital first left, more than a warm-up ago, and the capital has been in the vault\n                // for one block since. ACTUAL: credited in full, every time.\n                assertLe(warm, 200 ether + 70 ether, \"capital away for a day comes back cold\");\n                assertLe(warmSec, 400 ether + 140 ether, \"collateral away for a day comes back cold\");\n            }\n            _leave();\n        }\n    }\n\n    function _leave() private {\n        vm.startPrank(BORROWER);\n        vault.wipe(vault.debtOf(BORROWER));\n        (uint256 collateral,) = vault.positions(BORROWER);\n        vault.free(collateral);\n        vm.stopPrank();\n    }\n\n    function _comeBack() private {\n        vm.startPrank(BORROWER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        vm.stopPrank();\n    }\n}","reproduction":"test/scratch/Proof_73d81e305a41.t.sol (attached; fails on this code, run by me). ParameterizedVault at $1, NHI 0.85, wage 0. OTHER locks 1,000, draws 200 and hands BORROWER 200 imdUSD of fee money. BORROWER locks 2,000, draws 1,000; two quiet days: laggedNow() == (1,200e18, 2,400e18). t0: BORROWER wipe(debtOf) and free(all): laggedNow().debt == 200e18, bankDebt 1,000 and bankSecured 2,000 dated t0. Every 23 hours: lock(2000e18) + draw(1000e18) as one transaction, then wipe + free as the next. EXPECTED at t0 + 46 h and after (the capital first left more than a BACKING_WARMUP ago and has been present for two blocks since): laggedNow().debt <= 270e18 and .secured <= 540e18 (OTHER's terms plus at most 7% of the returning capital). ACTUAL: laggedNow() == (1200e18, 2400e18) on every visit, ten cycles over ten days ('capital away for a day comes back cold: 1200000000000000000000 > 270000000000000000000').","severity":"medium","snippet":"                    if (bank == 0) bankAt = block.timestamp;","title":"CDPVault._lag: a bank credited in full by a one-block visit is re-dated when the capital leaves again, so a once-seasoned position keeps its warmth indefinitely while its capital is in the vault one b"},{"citation":"resolved","description":"Q3, the other direction (merged from audit_permissions f1f7755a and audit_economics ddd10333). The lagged fee base lags DECREASES of warm principal only. `_laggedSupplyFrom` floors the base at `start`, the live supply when the transaction began, and the live supply includes principal that is zero blocks old; `_coldRepaidCountsAtOnce` removes a cold repayment across transactions but not inside the transaction that redeems, because `_redemptionRate` reads `_laggedSupplyFrom(_supplyStart())` and `_supplyStart()` is the pre-wipe supply. (1) Across transactions, one block: tx N lock + draw(D); tx N+1 anyone's cash(A) is quoted A / (S + D) / divisor instead of A / S / divisor, and `redemptionBaseRate` is stored from it for everyone after; tx N+2 wipe(D) (cold, `_coldRepaidCountsAtOnce`, the position's bank is empty, nothing lags). (2) In one call through a contract: wipe(D) of cold principal then cash(A): `_laggedSupply` has already lost D but the floor at `start` still holds it, which contradicts lines 334-336 and 1427-1429 ('a repayment of cold principal counts at once') for the same transaction and lets the churn finish inside one call; as two transactions the same calls charge A / S / divisor. Cost: gas, a stability fee on D for one block (0.012% a day at DUTY_BPS) and 1.7 x D of sIMD exposed for one block; D is bounded only by the line's room ($1M at launch against a small supply). Who loses: the redeemer's fee (up to the 4.5% between cap and floor on A) is value that would have stayed in the reserve or the candidate position, and the stored base rate, the throttle that slows a redemption run and makes the b952037a pump expensive, is reset low on demand (95 bps for everyone instead of 500 in the proof), repeatable every half-life. Reachable with the constants as committed, wage 0, no governance. It is the mirror image of the retry panel's medium #5 and of the finding above. NatSpec: 334 ('an increase counts at once') states the rule, but the requester's own Q3 property ('no sequence moves the base ... above [honest] without seasoned capital held for hours') does not hold, and 893-895 / 1427-1429 contradict each other for the same-call case. Smallest fix: measure the fee base against WARM supply the way backing does, prior = _laggedSupplyFrom(start) - (totalDebt - laggedNow().debt) - coldRepaidThisTransaction (a tally `_payDebt` does not clear), saturating at zero (which quotes the cap, the lag's accepted direction). Trade-off to decide: while every unit of supply is cold (the first hours after launch, or right after a large honest draw) `prior` is small and early redemptions pay the cap; audit_economics measured ten committed redemption tests failing under that rule. If the cost is refused, state at 334-343 and 893-895 that a draw held across one block dilutes the base and that a cold repayment counts at once only from the next transaction, and drop the claim at 1427-1429 that the cold rule prevents the inflation.","line":906,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// The lagged fee base lags decreases of warm principal only: an increase counts at once, so a cold draw held\n// for one block (or repaid in the same call as the redemption) dilutes the fee and the stored base rate.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract FdlFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\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 FdlMirror 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 FdlAggregator {\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 FdlActor {\n    ParameterizedVault private immutable vault;\n\n    constructor(ParameterizedVault vault_, MockIMD imd_) {\n        vault = vault_;\n        imd_.approve(address(vault_), type(uint256).max);\n    }\n\n    function lockDraw(uint256 c, uint256 d) external {\n        vault.lock(c);\n        vault.draw(d);\n    }\n\n    function wipeThenCash(uint256 w, uint256 a) external returns (uint256) {\n        vault.wipe(w);\n        return vault.cash(a, 0, address(0));\n    }\n\n    function wipe(uint256 w) external {\n        vault.wipe(w);\n    }\n\n    function cash(uint256 a) external returns (uint256) {\n        return vault.cash(a, 0, address(0));\n    }\n}\n\ncontract FeeDilutionTest is Test {\n    address private constant HOLDER = address(0x401D);\n    address private constant OTHER = address(0x07E);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    FdlActor private actor;\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 FdlAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        FdlFeed primary = new FdlFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1\n        FdlFeed health = new FdlFeed(0.85 ether); // mat 170, gap 50\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new FdlMirror(primary))\n        );\n        stable = vault.stablecoin();\n        actor = new FdlActor(vault, imd);\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(address(actor), 100_000 ether);\n        imd.mint(OTHER, 10_000 ether);\n        imd.mint(address(vault.treasury()), 10_000 ether); // reserve-funded redemptions\n        vm.stopPrank();\n        vm.prank(OTHER);\n        imd.approve(address(vault), type(uint256).max);\n        // A warm supply of 1,000, held by HOLDER.\n        vm.startPrank(OTHER);\n        vault.lock(3_000 ether);\n        vault.draw(1_000 ether);\n        stable.transfer(HOLDER, 1_000 ether);\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days);\n        assertEq(stable.totalSupply(), 1_000 ether);\n    }\n\n    function _next() private {\n        vm.roll(block.number + 1);\n        vm.warp(block.timestamp + 12);\n    }\n\n    /// Honest: redeeming 90 of a 1,000 warm supply is a 4.5% increase (the cap).\n    function test_honestFeeForNinetyOfAThousand() public {\n        assertEq(vault.redemptionFeeBps(90 ether), 500);\n        vm.prank(HOLDER);\n        vault.cash(90 ether, 0, address(0));\n        assertEq(vault.redemptionBaseRate(), 0.045e18);\n    }\n\n    /// A draw one block before the redemption, repaid one block after: the fee is quoted against 10,000.\n    /// EXPECTED: the cold 9,000 does not dilute the fee (quoted 500 bps, base about 0.045e18; a fix that cools\n    /// the cold principal for the 12 seconds it lived may read a hair under). ACTUAL: 95 bps, base 0.0045e18.\n    function test_coldDrawOneBlockEarlierDilutesTheFee() public {\n        actor.lockDraw(17_000 ether, 9_000 ether); // 90% of the resulting supply, zero seconds old\n        _next();\n        uint256 quoted = vault.redemptionFeeBps(90 ether);\n        vm.prank(HOLDER);\n        vault.cash(90 ether, 0, address(0));\n        uint256 base = vault.redemptionBaseRate();\n        _next();\n        actor.wipe(9_000 ether);\n        emit log_named_uint(\"quoted fee bps (honest 500)\", quoted);\n        emit log_named_uint(\"base rate after (honest 0.045e18)\", base);\n        assertApproxEqAbs(stable.totalSupply(), 910 ether, 1e16, \"supply is 910 after the churn (fee dust aside)\");\n        assertGe(quoted, 495, \"cold principal must not dilute the fee\");\n        assertGe(base, 0.0445e18, \"cold principal must not depress the base rate\");\n    }\n\n    /// Same transaction: repay the cold principal and redeem in one call. The NatSpec says a repayment of cold\n    /// principal counts at once; `_laggedSupplyFrom` floors at the pre-wipe supply, so it does not.\n    function test_wipeOfColdPrincipalThenCashInOneTransactionReadsThePreWipeSupply() public {\n        actor.lockDraw(17_000 ether, 9_000 ether);\n        _next();\n        vm.prank(HOLDER);\n        stable.transfer(address(actor), 90 ether);\n        actor.wipeThenCash(9_000 ether, 90 ether);\n        uint256 base = vault.redemptionBaseRate();\n        emit log_named_uint(\"base rate after (honest 0.045e18)\", base);\n        assertApproxEqAbs(stable.totalSupply(), 910 ether, 1e15, \"supply is 910 after the churn\");\n        assertGe(base, 0.0445e18, \"cold principal repaid in the same call must count at once\");\n    }\n}","reproduction":"test/scratch/FeeDilution.t.sol (attached as proof; mine, a tolerant rewrite of audit_permissions' Proof_f1f7755a6131, which fails identically but asserts an exact 0.045e18 that a fix cooling the cold principal for its 12 seconds would miss by a hair). ParameterizedVault over MockIMD at $1 (Chainlink 2000e8 etched), NHI 0.85, divisor 2, Treasury holding 10,000 IMD so redemptions are reserve-funded. OTHER locks 3,000, draws 1,000 and gives HOLDER the 1,000; two quiet days. Control (passes): redemptionFeeBps(90e18) == 500 and cash(90e18) leaves redemptionBaseRate == 0.045e18. Attack 1: an actor contract lock(17,000e18) + draw(9,000e18) in one transaction; next block HOLDER cash(90e18, 0, 0); next block actor wipe(9,000e18); supply 910 after. EXPECTED: quoted >= 495 bps, base >= 0.0445e18. ACTUAL: quoted 95 bps, base 0.0045e18 ('cold principal must not dilute the fee: 95 < 495'). Attack 2: the actor holds the 90 and calls wipe(9,000e18) then cash(90e18) in ONE call. EXPECTED base >= 0.0445e18 (cold repaid counts at once). ACTUAL 0.0045e18 ('cold principal repaid in the same call must count at once: 4500000000000000 < 44500000000000000').","severity":"medium","snippet":"        uint256 prior = _laggedSupplyFrom(_supplyStart());","title":"CDPVault._redemptionRate: the fee base counts cold principal at once, so a draw held for one block (or a cold repayment inside the redeeming call) dilutes the fee to the floor and resets the base rate"},{"citation":"resolved","description":"Q3/Q4, the transient bookkeeping across several mints and burns in one transaction (all four specialists: audit_flow 40b7b462, audit_math 24d7b4ae, audit_permissions a1132c6c, audit_economics 5076eedf; merged). SUPPLY_START_SLOT holds start + 1, the supply at the transaction's first mint or burn. `cash` subtracts its whole burn with assembly `sub`, which has no underflow check. `amount` is bounded only by the LIVE supply (`_redemptionRate` line 902), and a `draw` or `earn` earlier in the transaction raises the live supply above `start`, so a redemption of more than start + 1 wraps the slot to about 2^256 - (amount - start); exactly start + 1 wraps it to 0, which `_checkpointSupply` reads as 'not recorded' and re-checkpoints `_laggedSupply` and the start supply from the mid-transaction live supply. Until the transaction ends `_supplyStart()` returns the wrapped figure: `_backingPerUnit` takes supply = max(live, start) ~ 2^256 and rounds perUnit to 0 (backingPerUnit() reads 0 in a fully backed vault), `_redemptionRate` reads prior ~ 2^256 and quotes the floor with no increase, and a second `cash` computes gemOut = 0 and reverts ZeroAmount. Transient storage is cleared when the call ends and `_laggedSupply` only ever falls, so nothing persists and no profit path was found (a cash at the wrapped figure pays nothing): the harm is a legitimate composed flow (a router or multicall that borrows or earns and then redeems more than the pre-existing supply, ordinary at launch with a small supply and a $1M line) refused with an error indistinguishable from an empty reserve, and wrong quotes mid-call. The comment at 729-730 ('counts at once, across transactions and inside this one') does not hold for such a burn. Reachable with the constants as committed. Smallest fix: saturate at the transaction's floor, keeping the +1 sentinel so the slot never reads as unrecorded, e.g. in a small private helper (an extra local in `cash` itself hits stack-too-deep): `let s := tload(SUPPLY_START_SLOT) switch gt(s, amount) case 1 { tstore(SUPPLY_START_SLOT, sub(s, amount)) } default { tstore(SUPPLY_START_SLOT, 1) }`, matching the saturating update of `_laggedSupply` on the line before.","line":734,"path":"src/CDPVault.sol","reproduction":"test/scratch/SupplyStartWrap.t.sol (mine; fails on this code with ZeroAmount(), and its companion test pins the selector). ParameterizedVault at $1, NHI 0.85; OTHER locks 200 and draws 100 (the supply the next transaction begins with is 100); the Treasury holds 5,000 IMD so redemptions are reserve-funded; a router contract holds 10,000 IMD. Control, as separate transactions: lock(2000e18) + draw(900e18); cash(500e18, 0, 0) pays a1 > 0; cash(100e18, 0, 0) pays b1 > 0 (passes, snapshot reverted). Then in ONE transaction: lock(2000e18), draw(900e18) [live 1,000, start 100], cash(500e18, 0, 0) [burns more than start: the slot becomes 101e18 - 500e18 mod 2^256], then backingPerUnit(), redemptionFeeBps(100e18), cash(100e18, 0, 0). EXPECTED: both redemptions paid as in the control, backingPerUnit() == 1e18 between them. ACTUAL: the call reverts ZeroAmount() at the second cash; the specialists' logged readings with the asserts removed are backingPerUnit() == 0 and the quote at the 50 bps floor.","severity":"low","snippet":"            tstore(SUPPLY_START_SLOT, sub(tload(SUPPLY_START_SLOT), amount))","title":"CDPVault.cash: the start-of-transaction supply slot is decremented with an unchecked assembly sub, so a redemption larger than the supply the transaction began with wraps it; backingPerUnit reads 0, t"},{"citation":"resolved","description":"Q4, the bound check the task asks for on the accepted medium (retry panel #4); the premium itself is not re-reported (from audit_flow c05c911a; audit_permissions d451c417 item 3 adds the parenthetical note below). `_backingPerUnit` returns min(live, lagged) with lagged = (reserve + warm secured) * 1e18 / (supply - fresh), where fresh = totalDebt - laggedNow().debt is the vault's cold principal. Below par the lagged figure binds, so a warm borrower's repayment R one transaction earlier (its term unchanged, 170-200% band) scales that figure by (supply - fresh) / (supply - fresh - R), not by supply / (supply - R) as the NatSpec (768-770), the retry panel's resolution table and test/retry-panel/AdjacentTxBurn.t.sol (which pins the bound with fresh == 0) state. The two coincide only when no debt is cold. The churner must itself be warm (a cold churner's repayment lowers fresh by the same R, and the denominator does not move), so R is bounded by 15% of the WARM principal it holds and the true bound is 1 / (1 - 0.15 x s_w) with s_w the churner's share of the warm supply: 17.6% for a churner that is the whole warm supply, where supply / (supply - repaid) suggests 15% of its share of the whole supply. Whenever a large part of the supply is fresh (launch, or after a large draw by anyone) the stated bound understates what a redeemer takes out of the Treasury's reserve and the other holders' backing. Also, the parenthetical '15% of it at a 170% minimum ratio, 25% at 150%' describes a churner AT mat: `wipe` has no health check, so a position below mat that is unmarked or inside its grace can repay up to half its principal without moving its term; it cannot redraw past the health check, so that variant costs capital, not gas, and the gas-only bound stands as the general clause. Not a bypass of the acceptance: the cost (gas, below par only, bounded by the churner's warm principal) stands; the bound stated for it does not. Smallest fix: state the bound as (supply - fresh) / (supply - fresh - repaid), `fresh` being the cold principal `laggedNow` excludes, in the NatSpec, in docs/AUDIT-RETRY-PANEL-VAULT-2026-10-07.md's resolution and in the retry-panel test's assertion (which should pin the bound with fresh debt present); or make the lagged denominator read the supply the churn left in it by adding the position's unexpired `bankDebt` back to supply - fresh for the lagged figure.","line":769,"path":"src/CDPVault.sol","reproduction":"test/scratch/BackingBound.t.sol (mine; fails on this code at the stated bound). ParameterizedVault over an 18-decimal IMD at $1 (ETH/USD 2000e8 etched), NHI 0.85, wage 0. BORROWER locks 5,790 and draws 1,000; OTHER locks 5,100, draws 3,000 and hands BORROWER the 3,000 imdUSD; IMD to $0.294 (BORROWER 170.2%, OTHER 50%), both re-priced by lock(1); three quiet days; then NEW locks 14,000 and draws 2,000 in its own transaction: supply 6,000, fresh (totalDebt - laggedNow().debt) 2,000, backingPerUnit() 0.800415e18 (the lagged figure binds). Snapshot: BORROWER's cash(500e18, 0, BORROWER) pays 1297679625000000000000 raw (honest). Revert. Two transactions: BORROWER wipe(140e18); cash(500e18, 0, BORROWER). EXPECTED by the NatSpec: churned <= honest x 6000 / 5860 = 1328682209897610921501. ACTUAL: 1344618602670256454081 (+3.62% against the stated +2.39%), which is honest x 4000 / 3860 = 1344745725388601036269 less rounding, i.e. (supply - fresh) / (supply - fresh - repaid).","severity":"low","snippet":"    /// most supply / (supply - repaid), the repayment bounded by the churner's principal above half its","title":"CDPVault._backingPerUnit: the accepted cross-transaction premium is stated as at most supply / (supply - repaid), but the lagged figure's denominator is supply - fresh, so with cold debt outstanding t"},{"citation":"resolved","description":"Q4 ('or underpays honest redeemers') and Q1(c) (from audit_math 2e2c4d95). `_lag` is driven by the secured TERM, min(collateral, 2 x principal / price), not by the collateral itself. For a position above 200% the term is debt-bound; when the price falls and the position is next touched (its own lock, wipe or free, or ANY third party's `cash` against it once it is an eligible candidate, even for one wei), the term rises toward its collateral and `_lag` treats the whole rise as cold capital although nothing arrived: the collateral had been in the vault for days. `_backingPerUnit` then pays redeemers the lagged figure, which excludes that collateral, for a day after the fall (half of it for six hours), which is exactly when redemptions matter. It is the safe direction and not a theft, but it is not 'new capital' either, and a third party can impose it on every eligible candidate with a one-wei cash against each. Reachable with the constants as committed. NatSpec the code does not have: lines 312 and 956 ('what a position adds is cold' / 'An increase is cold, less what the position's bank gives back') describe additions; a re-pricing adds nothing. Smallest fix: cold only the part of a term increase that corresponds to capital added in the same call: pass the collateral and principal this call added into `_resecure` (from lock, lockIMD and draw) and cap the cold increment at added collateral + 2 x added principal / price; a pure re-pricing is then warm. (The mirror, a price rise shrinking a debt-bound term and banking the decrease as warm, only ever credits the same position's own term back within a day and was checked as within the design.)","line":950,"path":"src/CDPVault.sol","reproduction":"test/scratch/PriceFallColdTerm.t.sol (mine; fails on this code). ParameterizedVault at $1, NHI 0.85. P locks 4,000 and draws 1,000 (term 2,000, debt-bound); Q locks 1,700 and draws 1,000 (term 1,700); three quiet days: laggedNow().secured == 3,700e18, backingPerUnit() == 1e18. IMD to $0.50. P lock(1). EXPECTED: laggedNow().secured >= 5,700e18 (P's term is re-priced to 4,000 with no capital added) and backingPerUnit() == 1e18 (min(5,700 x 0.5, 1.7 x 2,000) / 2,000, capped). ACTUAL: securedCollateral 5,700e18 but laggedNow().secured == 3,700e18 (2,000 IMD of three-day-old collateral is cold: '3700000000000000000000 < 5700000000000000000000') and backingPerUnit() == 0.925e18 for the next day: a redeemer of 100 imdUSD is paid 7.5% less than the honest pro-rata figure.","severity":"low","snippet":"        _lag(position, true, before, current);","title":"_resecureBounded / _lag: a price fall re-prices a debt-bound secured term upward and the whole rise is cold, so collateral held for days is excluded from the lagged backing for a day after a fall and "},{"citation":"resolved","description":"Q1(a)/(c) (from audit_economics 4693a119). `_cool` returns 0 for any elapsed >= BACKING_WARMUP. For the vault total that is the designed quiet-day rule, and a touch of any position restarts it. For a position the same rule runs on the position's OWN elapsed (block.timestamp - position.coldAt, line 971), so a position left untouched for a day reads its cold as zero even when the vault total, touched by others every few hours, still carries that position's residual (1/16 after exactly a day, rounded up at every touch). The total is then above the sum of the positions, which the NatSpec calls the safe direction, but the decrease path takes coldOut = min(cold, out) with cold == 0 (lines 987-989): nothing leaves the total, the whole repayment is banked as warm, and the residual stays in `_coldDebt` / `_coldSecured` as cold for debt and collateral that no longer exist, halving every six hours and zeroed only by a quiet vault day. Effect: `laggedNow` reads the OTHER positions' warm capital short by that residual (62.5 of the helper's 100 in the reproduction), so `ParameterizedVault.backedDebt` / `earnLine` and the lagged `_backingPerUnit` figure are below honest for hours. Direction: conservative for the protocol (I checked that an orphan on both sides lowers the lagged backing below par whenever honest backing is under the position's term-to-debt ratio, i.e. always below par; on the debt side alone it would raise `fresh` and shrink the denominator, but a term always moves with the principal it bounds, so a debt-only orphan needs an unreadable price and was not reached). So this is a griefing and accuracy defect rather than an extraction: a borrower who holds D for a day while the vault stays active, repays, and redraws (credited warm from its bank, so its own position is unaffected) leaves D/16 of phantom cold behind each time; with D equal to the honest debt that is about 6% off the lagged backing and the work ceiling for about a day, for one day of stability fee on D and gas. The claim at 1016-1018 ('A touch only ever restarts that day, so activity can slow warming but never speed it') holds; the per-position premise in Q1(a) that 'a position's own quiet day is its own' holds for the position's figures and not for the total its decrease is applied to. Smallest fix: cool a position's figures continuously (apply the half-life for any elapsed, without the >= BACKING_WARMUP short-circuit; `_pow` stays at about 27 squarings for any realistic gap), so a position's cold is never below its share of the total and a decrease always removes what the position actually contributed; keep the quiet-day zeroing for the total only.","line":1021,"path":"src/CDPVault.sol","reproduction":"test/scratch/OrphanCold.t.sol (mine; fails on this code). ParameterizedVault at $1, NHI 0.85. HELPER locks 190 and draws 100 (190%: its term is its collateral, so lock(1) is a touch); three quiet days (warm). BORROWER locks 2,000 and draws 1,000 (cold). Every six hours for a day HELPER lock(1) touches the cold total while BORROWER stays untouched; one second past the day laggedNow().debt == 1037502005602022804874 == totalDebt - 62.5e18: 1/16 of the day-old 1,000 is still cold in the total. HELPER hands BORROWER 1 imdUSD for the day's fee; BORROWER wipe(debtOf(BORROWER)). EXPECTED: laggedNow().debt == totalDebt == 100e18 (BORROWER's debt and its cold both left; HELPER's 100 is warm). ACTUAL: 37502005602022804874 ('no cold should remain for debt that no longer exists: 37502005602022804874 != 100000000000000000000'): the 62.5 orphan stays and HELPER's warm debt reads 62.5 short, halving every six hours.","severity":"low","snippet":"        if (elapsed >= BACKING_WARMUP) return 0;","title":"CDPVault._lag / _cool: a position untouched for a day reads its own cold as zero while the vault total, kept alive by others' touches, still holds 1/16 of it, so the position's repayment leaves that c"},{"citation":"resolved","description":"Q5 ('a price move between their deposits') (from audit_permissions 1ba6e564). The retry panel's low #7 narrowed the skip to a re-lock worth less than the recorded bad debt, and its low #6 made `cover` take such a re-lock at its value with no liquidator needed. After those two fixes the bite shortcut is no longer needed to clear a griefing re-lock while the Treasury holds imdUSD (cover does it at par, 0% penalty, strictly better for protocol and borrower), but it still removes the mark and the grace from every drained borrower whose collateral is worth less than the record at the moment of the bite, whatever their intent: a borrower rebuilding in tranches is exposed between tranches, and one who re-locked collateral worth 110% of the record (above it, so marked and given grace like anyone) and is moved below it by a 10% price fall is liquidated by anyone in the same block, for any `debtToRepay` the collateral covers, at the 20% penalty, with both bonus shares to the liquidator (line 1156). An identical position with no record gets `bark` and up to six hours (NHI >= 0.85) to top up; the comment's premise at 1123-1124 ('cannot recover by waiting') is not a property of the position, because the same price moving back up returns it to the marked path. Harm bounded by the re-lock (20% of what is bitten); the bad-debt record and totalBadDebt stay consistent through the bite and through cover's value sweep (`_reduceDebt` lowers the record with the debt; `_recordBadDebt` re-records at zero collateral), which I checked. Reachable with the constants as committed. Smallest fix: require the ordinary mark and grace in `bite` for every position above dust, leaving `cover`'s at-value sweep as the permissionless remedy for a re-lock below the record (and keep the shortcut, if at all, only for the case cover cannot serve: a Treasury with no imdUSD); or, if the shortcut is kept, say in docs/MAINNET-RUNBOOK.md and the borrower docs that a drained borrower must re-lock at least the recorded bad debt's worth in one transaction and keep it above the record.","line":1129,"path":"src/CDPVault.sol","reproduction":"test/scratch/BiteRebuilder.t.sol (mine; fails on this code because the bite succeeds). ParameterizedVault at $1, NHI 0.60 (mat 200, lull 0, so grace is only the mark). B locks 2,000 and draws 1,000; K locks 40,000 and draws 10,000. Price to $0.50; bark(B); K bite(B, 833.333e18) drains B (collateral 0, totalBadDebt == debtOf(B) == 166666666666666666667). Price back to $1; two days (the old mark expires); B lock(1.1 x debtOf(B)) (worth 110% of the record; totalBadDebt unchanged). K bite(B, 1e18): reverts MarkExpired (the ordinary path: a fresh bark is needed). Price to $0.90: the re-lock is worth 99% of the record. K bite(B, 10e18) with no bark. EXPECTED: MarkExpired / PositionNotMarked like any other borrower at that ratio. ACTUAL: succeeds at once; K receives 12888888888888888889 raw IMD for 10 imdUSD (the 20% bonus less the protocol's cut, both shares to K).","severity":"low","snippet":"        if (!_relockBelowBadDebt(owner, price)) {","title":"CDPVault.bite: the no-mark, no-grace path keys on the re-lock's value against the bad-debt record, so a rebuilding borrower whose tranche or price move leaves the collateral below the record is bitten"},{"citation":"resolved","description":"Q1(a) and (d) (merged from audit_flow 08475e01, audit_math f30223db, audit_economics c023b9c7). The vault's cold totals are cooled at every touch of any position (`_lag` line 969) with `_cool(..., up = true)`, while a position's own cold is cooled once, at its next touch, over the whole gap, with `up = false`. The ceiling only guarantees total >= sum when the decay factor composes exactly, and `_pow` does not: each `result * factor / one` and `factor * factor / one` truncates, so `_pow(f, a) * _pow(f, b) / RAY` can read below `_pow(f, a + b)` by a few units in 1e27, and the one-unit ceiling per touch cannot cover that on an amount above about 1e27 raw units (a thousand sIMD at 24 decimals). Consequence: when such a position subtracts its cold the vault total saturates to zero (line 989) while another position still holds cold of that dust size, which `laggedNow` then reads as warm; bounded by about 1e-21 of the cold amount relatively (under 1e-18 sIMD on a million-sIMD term), no economic effect. Also checked and holding for Q1(d): the unchecked block in `_lag` (every subtraction guarded by the comparison or min before it; `bank + (out - coldOut)` cannot wrap for any representable position); the uint128 saturation keeps the excess in the vault total, the safe direction (see the NatSpec finding for the comment that says the opposite); `_pow`'s bound (factor and result at most `one`, products below 1e54 for RAY and 1e36 for 1e18); its gas over long gaps (at most 17 squarings for the cold and the lagged supply, elapsed being short-circuited at BACKING_WARMUP, about 27 for the base rate over a year of quiet). Smallest fix: reword the claim ('so the position's share is never read above the total, to within the truncation of `_pow`'), or compute the vault totals' decay with a rounding-up `_pow` (round each product up) so the totals dominate by construction.","line":1016,"path":"src/CDPVault.sol","reproduction":"Integer arithmetic over the committed constants (COLD_SECOND_DECAY = 999967910367635122012970996, RAY = 1e27, the `_pow` loop and `_cool` as written), run by me: pow(f, 86400) = 62499999999999999999995426 (ideal 0.0625e27); amount 1e30 cooled 2,932 s then 18,215 s rounded up = 507321505183320375430890732842, cooled once over 21,147 s rounded down = 507321505183320375430890735000: the two-step total is 2,158 raw units BELOW the one-step position figure; amount 601691055351260499632438899944742 cooled 1 s then 36,314 s rounded up = 187614705151815296225965817975788, once over 36,315 s rounded down = 187614705151815296225965818292158: 316,370 raw units below (1.7e-27 relatively). audit_math's test/scratch/PowMultiplicativity.t.sol and audit_economics' testFuzz_totalRoundsAboveThePosition report the same counterexamples against a copy of the two functions.","severity":"info","snippet":"    /// totals (`up`, so they never read below the sum of the positions) and down for a position; and","title":"CDPVault._cool NatSpec: 'rounded up for the vault's totals, so they never read below the sum of the positions' is not exact, because _pow truncates at every squaring and a total cooled touch by touch "},{"citation":"resolved","description":"Merged from audit_math 70dddc45, audit_permissions d451c417, audit_economics cba55ee9 and audit_flow's list; each is the documentation half of a finding above, to reword to the behaviour the code has or to keep once the code is fixed. (1) Lines 318-319 ('capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par'), 759-760 ('An attacker's capital can raise the live figure but not the lagged one, whichever position it sits in') and 963-964 ('a bank only ever returns warmth ... within BACKING_WARMUP of the moment it first filled'): false for a position that was once seasoned, because a one-block return and departure empties and re-dates its bank (medium, `_lag` line 992). (2) Lines 334-337 ('an increase counts at once ... a repayment of warm principal only as it ages, its excess halving every BACKING_HALF_LIFE'), 893-895 ('a repayment in it or in the last few hours does not shrink the base') and 1427-1429 ('Only warm repayments lag'): a cold draw and repayment after a warm repayment erases the excess at once (medium, `_coldRepaidCountsAtOnce` line 1437); an increase that follows a warm repayment is absorbed by the fading excess rather than counted on top of it (the base is max(live, decaying high-water mark), so a redeemer after a dominant repayment and a new draw pays 95 bps where the documented model gives 74, the higher-fee direction, no attacker gain); and a repayment of cold principal does NOT count at once inside the transaction that redeems, because `_laggedSupplyFrom` floors at the pre-wipe `start` (medium, `_redemptionRate` line 906), so 334-336 and 893-895 contradict each other for the same call. (3) Lines 312 and 956 ('what a position adds is cold' / 'An increase is cold'): a price fall's re-pricing of a debt-bound term is cold although nothing was added (low, line 950). (4) Line 1016 ('so they never read below the sum of the positions'): not exact, by units of 1e-27 (info). (5) Lines 768-770, the accepted premium: 'at most supply / (supply - repaid)' holds only with no cold debt; the true bound is (supply - fresh) / (supply - fresh - repaid), and the parenthetical describes a churner at mat (low, line 769). (6) Lines 1002-1003 ('past 128 bits of raw units a position's excess over that counts as warm'): `total` receives the whole increase (line 999) while `cold` is capped (line 1004), so the excess is COLD in `laggedNow` until it cools, and on a later decrease the position's capped coldOut leaves it orphaned in the total; the opposite direction from the comment, the safe one, and unreachable at sIMD's supply (3.4e14 sIMD). (7) Lines 1123-1124 ('cannot recover by waiting'): a price rise that lifts the re-lock above the record returns it to the mark-and-grace path (low, line 1129). (8) Lines 729-730 ('counts at once, across transactions and inside this one'): a burn larger than the start supply wraps the slot instead (low, line 734). (9) Line 1016-1018 implicitly and Q1(a)'s premise: a position's quiet day zeroes its own figures but not its share of the total, which its decrease then cannot remove (low, line 1021). Checked and consistent: the Position struct comments (41-60), 983 ('Every subtraction below is guarded'), 316-317 (94% / 99.6% under activity), 242-256 the transient slots for the same call, cover's 557-569 and 591-595, `_coverDust` 651-658, `_relockBelowBadDebt`, `_recordBadDebt` 1446-1463, `totalBadDebt` 298-305, the rest of `_backingPerUnit` 750-767 and 771-772, ParameterizedVault.backedDebt 230-258 (its 'in either order' clause holds for the new per-position lag; 'real capital at risk ... not gas' holds for the ceiling's slow round trip only until the bank finding above is fixed), and ImdUSD throughout (single minter/burner bound at construction or once by APPROVED_OPERATOR, no admin, no pause, no upgrade). ANSWERS WHERE NOTHING IS WRONG. Q1(b): no sequence moves warmth between positions: a draw adds cold to its own position and the total, a cancellat","line":318,"path":"src/CDPVault.sol","reproduction":"Each claim is refuted by the reproduction of the finding it documents: test/scratch/Proof_73d81e305a41.t.sol (claim 1), test/scratch/Proof_2f12b4a45902.t.sol and test/scratch/FeeDilution.t.sol (claim 2), test/scratch/PriceFallColdTerm.t.sol (claim 3), the integer-arithmetic counterexamples in the `_cool` info (claim 4), test/scratch/BackingBound.t.sol (claim 5), test/scratch/BiteRebuilder.t.sol (claim 7), test/scratch/SupplyStartWrap.t.sol (claim 8), test/scratch/OrphanCold.t.sol (claim 9). Claim 6: read `_lag` 996-1012: `total += after_ - before - credit` at 999 is uncapped while `cold` is capped at 1004. Baseline: `forge test` --no-match-path 'test/scratch/*' reports 588 passed, 0 failed, 4 skipped on this commit.","severity":"info","snippet":"    /// position's own cold first and counts at once. So capital brought in one transaction and withdrawn a","title":"NatSpec and comments that claim properties the committed code does not have after 24337a2 (the lagged fee base, the bank, the cold term, the premium bound, the saturation direction, the no-grace ratio"}],"hash":"70fb56bba8fd577545fc1af3cce479075cbbfbd363741c8f06987df1cc3fbf71","nodeId":"dcdd6f7d-54a5-4610-b038-bcd8e754a0a6","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds ten findings, every one re-run against commit `24337a2` by me; no source files were changed (`git status` shows nothing tracked modified — `test/scratch/` and the findings file are ignored).\n\n## What I kept, after merging the four specialists' 19 findings\n\n**Medium (3, each with a proof that fails on this code):**\n- **`_coldRepaidCountsAtOnce`, line 1437** — the fee base is stored as an absolute figure, so a cold draw by *any* position absorbs a warm repayment's fading share and the cold repayment subtracts it again. Wipe 900 warm → helper draw/wipe 900 → cash 9 pins the fee at the cap for 0.45 imdUSD instead of 4.5. Merged audit_flow + audit_math (+ audit_economics' documentation half); audit_flow's proof attached, audit_math's fails identically.\n- **`_lag`, line 992** — a one-block return empties the bank and the next departure re-dates it, so a once-seasoned position keeps its warmth forever while its capital is present one block in 23 hours (the \"permanent option\" the panel rated medium before, by a new path). audit_math's proof attached.\n- **`_redemptionRate`, line 906** — the mirror of the first: a cold draw held one block (or repaid in the redeeming call) dilutes the fee from 500 bps to 95 and resets everyone's base rate. Merged audit_permissions + audit_economics. I wrote a tolerant proof, because audit_permissions' asserted an exact figure a reasonable fix would miss by rounding. The trade-off audit_economics measured (early redemptions at the cap while all supply is cold) is stated.\n\n**Low (5):** the unchecked `sub` on `SUPPLY_START_SLOT` (all four specialists; merged, severity settled at low since nothing persists); the accepted premium's bound is `(supply − fresh)/(supply − fresh − repaid)`, not `supply/(supply − repaid)` — 3.62% vs the stated 2.39% in the panel's own fixture (the Q4 bound check); a price fall re-prices a debt-bound term as cold (underpays redeemers for a day); orphaned cold in the vault total after a quiet-day position repays; the bite shortcut taking a rebuilder between tranches or after a price dip.\n\n**Info (2):** `_pow` is not exactly multiplicative, so the \"never below the sum\" claim fails by ~1e-27 (three specialists; confirmed with integer arithmetic); and a merged list of nine NatSpec claims the code does not have, with the answers to Q1–Q6 where nothing is wrong and the coverage statement.\n\n## Dropped or changed\n- Nothing failed to reproduce. audit_permissions' separate \"same-call cold wipe then cash\" item is folded into the dilution finding (same mechanism, one line); audit_permissions' NatSpec note on the premium parenthetical is folded into the bound finding.\n- I did not implement any fix in `src/` (review task). The fee-base fix I describe is the excess-over-live model; audit_flow reports its equivalent variant passed the proof and the committed suites locally.\n\n**Verification:** committed suite baseline 588 passed / 0 failed / 4 skipped; all six attached proof tests fail for their stated reasons; my six scratch tests fail (or pin) as described; every snippet checked against the tree.","treeHash":null,"usage":{"cachedInputTokens":1964786,"inputTokens":391,"model":"claude-fable-5-1","outputTokens":72939,"runtime":"claude","turns":32,"wallClockMs":1058209}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"89214b73ec1e0b7b","findings":[{"citation":"resolved","description":"Q3 (the lagged fee base), a gap left by the fix for the retry panel's medium #5. `_laggedSupply` (L) is meant to hold the live supply plus the fading share of every WARM repayment, so that a dominant borrower's wipe cannot shrink the fee base for a redemption a transaction later. Two rules of the bookkeeping contradict each other. (1) A mint never touches L (`_mintOrBurn` only checkpoints), so at the NEXT checkpoint `_laggedSupplyFrom(start)` measures the excess as `L - start` against a live supply that the mint has raised: a cold draw (or an `earn`) of M absorbs M of the warm repayment's fading share. (2) `_coldRepaidCountsAtOnce` then subtracts a cold repayment from L in full, although the mint it repays was never added to L. Net effect of a cold draw-and-repay of M by ANY position: L falls by min(M, excess). Call sequence (launch constants, wage 0, divisor 2; BORROWER holds 900 of a 1,000 supply as its own seasoned debt, OTHER 100; the Treasury holds 100 IMD so the redemption is reserve-funded): tx1 BORROWER.wipe(900e18): warm, banks 900, live 100, L stays 1,000 (excess 900, as designed: redemptionFeeBps(9e18) still quotes 95). tx2 a helper position (any key, or a contract doing it in one call) lock(2000e18), draw(900e18), wipe(900e18): at the draw's checkpoint L = 100 + cool(900) = 999.6, mint -> live 1,000; at the wipe `_lag` retires 899.6 of cold principal into COLD_REPAID_SLOT, the burn checkpoints (same tx: no-op) or re-checkpoints L = max(1000, 999.6) = 1,000, live -> 100, `_coldRepaidCountsAtOnce` sets L = 1,000 - 899.6 = 100.4. tx3 anyone cash(9e18, 0, address(0)): `_redemptionRate` reads prior = `_laggedSupplyFrom(100)` = 100.4, increase = 9 / 100.4 / 2 = 4.48%: `redemptionBaseRate` is set to the 4.5% cap (0.04489e18) where the honest base of ~1,000 gives 0.0045e18. tx4 BORROWER.draw(900e18): credited from its bank, warm, the position is exactly where it started. Cost: gas, one block, no seasoned capital (the helper's debt lived for one call and paid no fee; the helper can even use the collateral BORROWER freed after its wipe) and 0.45 imdUSD of fee against the honest 4.5 for the same pin (9% of supply at 5%). Who loses: every later redeemer pays 500 bps instead of 50 for a half-life or two (12-hour decay), the peg floor min(1 - fee, backing) sits at 0.95 on demand, and a candidate can deter redemptions against itself; repeatable every half-life. Reachable with the constants as committed, no governance; needs one large position, as before (LINE is $1M at launch). The regression test test/retry-panel/AdjacentTxBurn.t.sol test_adjacentWipeCashDrawPinsTheFeeBase passes only because its redraw is credited from the same position's bank (warm), so no cold principal is ever retired. NatSpec the code does not have: CDPVault.sol 334-337 ('an increase counts at once ... a repayment of warm principal only as it ages') and 1427-1429 ('Otherwise a draw repaid one transaction later left the base inflated ... Only warm repayments lag'): a cold draw repaid one transaction later now DEFLATES the base by the warm share it absorbed. Smallest fix, verified locally (the attached proof passes; test/retry-panel, LaggedBacking, Redemption*, RedemptionFeePinning, Cover, StabilityFee, Liquidation, BadDebtSweep, Adversarial, BoundaryPaths, ProtocolSequences, WorkCeiling, InHouse, LaunchAuditFixes and the Redemption invariant stay green): make increases count at once in L as the NatSpec says, so a cold repayment's subtraction is matched by its mint's addition. In `_lag`'s increase branch for the debt side, tally `after_ - before - _cool(credit, block.timestamp - bankAt, false)` into a new transient slot (the whole increase, less only the bank-credited part that is still in the base as a fading repayment); in `draw`, after `_mintOrBurn`, add the amount this call tallied to `_laggedSupply`; in `earn`, add `amount` to `_laggedSupply` and to the slot; in `_redemptionRate`, subtract the slot's total from `prior` so supply created in the same trans","line":1417,"path":"src/CDPVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n// A cold draw by any position absorbs a warm repayment's fading share of the lagged fee base at the next\n// checkpoint, and the cold repayment is then subtracted from the base a second time: a dominant borrower's\n// wipe, a helper's draw-and-wipe, and a small cash pin the redemption fee at the cap for a tenth of the\n// honest cost, with no seasoned capital and no wait.\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract FbFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\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 FbMirror 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 FbAggregator {\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\n/// @dev A helper position that draws cold principal and repays it in the same call.\ncontract FbHelper {\n    ParameterizedVault private immutable vault;\n\n    constructor(ParameterizedVault vault_, MockIMD imd_) {\n        vault = vault_;\n        imd_.approve(address(vault_), type(uint256).max);\n    }\n\n    function lockDrawWipe(uint256 collateral, uint256 debt) external {\n        vault.lock(collateral);\n        vault.draw(debt);\n        vault.wipe(debt);\n    }\n}\n\ncontract FeeBaseColdMintTest is Test {\n    address private constant BORROWER = address(0xB0B);\n    address private constant OTHER = address(0x07E);\n\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    FbHelper private helper;\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 FbAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        FbFeed primary = new FbFeed(uint256(1 ether) * 1e18 / 2000 ether); // IMD = $1\n        FbFeed health = new FbFeed(0.85 ether); // mat 170, gap 50\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new FbMirror(primary))\n        );\n        stable = vault.stablecoin();\n        helper = new FbHelper(vault, imd);\n        vm.startPrank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 10_000 ether);\n        imd.mint(OTHER, 10_000 ether);\n        imd.mint(address(helper), 10_000 ether);\n        imd.mint(address(vault.treasury()), 100 ether); // the redemption is reserve-funded\n        vm.stopPrank();\n        vm.prank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vm.prank(OTHER);\n        imd.approve(address(vault), type(uint256).max);\n\n        // A seasoned dominant position: 900 of a 1,000 supply, warm for two days.\n        vm.startPrank(BORROWER);\n        vault.lock(2_000 ether);\n        vault.draw(900 ether);\n        vm.stopPrank();\n        vm.startPrank(OTHER);\n        vault.lock(200 ether);\n        vault.draw(100 ether);\n        stable.transfer(BORROWER, 9 ether);\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days);\n        assertEq(stable.totalSupply(), 1_000 ether);\n        assertEq(vault.redemptionFeeBps(9 ether), 95, \"floor 50 + 9 / 1,000 / 2 = 45 bps\");\n    }\n\n    /// Transaction 1: the dominant borrower repays its warm 900 (the base keeps it, fading over hours).\n    /// Transaction 2: a helper position draws 900 cold and repays it in the same call.\n    /// Transaction 3: a 9 imdUSD redemption. EXPECTED: an increase of 45 bps against a base of about 1,000.\n    /// ACTUAL: the base read about 100, and the rate is pinned at the 450 bps cap for a tenth of the cost.\n    function test_aColdDrawAndRepayByAnotherPositionEmptiesTheLaggedFeeBase() public {\n        vm.prank(BORROWER);\n        vault.wipe(900 ether);\n        helper.lockDrawWipe(2_000 ether, 900 ether);\n        assertApproxEqAbs(stable.totalSupply(), 100 ether, 0.5 ether, \"supply is back where the wipe left it\");\n        // The quote already shows it: the increase for 9 imdUSD reads the cap instead of 45 bps.\n        assertLe(vault.redemptionFeeBps(9 ether), 95 + 1, \"the lagged base must still hold the warm 900 repaid moments ago\");\n        vm.prank(BORROWER);\n        vault.cash(9 ether, 0, address(0));\n        assertLe(vault.redemptionBaseRate(), 0.0046e18, \"a 9-of-1,000 burn must not pin the fee at the cap\");\n        // The dominant borrower redraws from its bank, warm, and the position is where it was.\n        vm.prank(BORROWER);\n        vault.draw(900 ether);\n    }\n\n    /// The same, as three separate transactions from two keys (no contract needed).\n    function test_theHelperNeedsNoContract() public {\n        vm.prank(BORROWER);\n        vault.wipe(900 ether);\n        vm.startPrank(OTHER);\n        vault.lock(2_000 ether);\n        vault.draw(900 ether);\n        vault.wipe(900 ether);\n        vm.stopPrank();\n        vm.prank(BORROWER);\n        vault.cash(9 ether, 0, address(0));\n        assertLe(vault.redemptionBaseRate(), 0.0046e18, \"a 9-of-1,000 burn must not pin the fee at the cap\");\n    }\n}","reproduction":"test/scratch/FeeBaseColdMint.t.sol (attached; both tests fail on this code). ParameterizedVault over an 18-decimal MockIMD at $1 (IMD/ETH 1/2000 times Chainlink ETH/USD 2000e8 etched at CHAINLINK_ETH_USD), NHI 0.85, TreasuryFactory etched at TREASURY_FACTORY, launch constants (wage 0, divisor 2). BORROWER locks 2,000 and draws 900; OTHER locks 200, draws 100 and gives BORROWER 9 imdUSD; the Treasury holds 100 IMD; two quiet days (the 900 is warm); supply 1,000, redemptionFeeBps(9e18) == 95. Test 1: BORROWER wipe(900e18) [tx 1]; a helper contract calls lock(2000e18), draw(900e18), wipe(900e18) in one transaction [tx 2]; supply is back at ~100. EXPECTED: redemptionFeeBps(9e18) <= 96 (the lagged base still holds the warm 900 repaid moments ago) and, after cash(9e18, 0, address(0)) [tx 3], redemptionBaseRate <= 0.0046e18. ACTUAL: redemptionFeeBps(9e18) == 500 ('the lagged base must still hold the warm 900 repaid moments ago: 500 > 96'). Test 2: the same three steps from two EOAs as separate transactions (OTHER locks 2,000, draws 900, wipes 900), then cash(9e18, 0, address(0)). EXPECTED: redemptionBaseRate == 0.0045e18. ACTUAL: 0.044890786251530523e18, the cap ('a 9-of-1,000 burn must not pin the fee at the cap: 44890786251530523 > 4600000000000000'). With the fix described above both tests pass and the redemption's base rate is 0.0045e18.","severity":"medium","snippet":"        _laggedSupply = _laggedSupplyFrom(start);","title":"CDPVault lagged fee base: a cold draw by any position absorbs a warm repayment's fading share at the next checkpoint, and the cold repayment is subtracted again, so the base collapses to the live supp"},{"citation":"resolved","description":"Q4, the bound check the task asks for on the accepted medium (retry panel #4); the premium itself is not re-reported. `_backingPerUnit` returns min(live, lagged) with lagged = (reserve + warm secured) * 1e18 / (supply - fresh), where fresh = totalDebt - laggedNow().debt is the cold principal. Below par the lagged figure binds, so a warm borrower's repayment R one transaction earlier (its term unchanged, 170-200% band) scales that figure by (supply - fresh) / (supply - fresh - R), not by supply / (supply - R) as the NatSpec, the retry panel's resolution table and test/retry-panel/AdjacentTxBurn.t.sol (which pins the bound with fresh == 0) state. The two coincide only when no debt is cold. The churner must itself be warm (a cold churner's repayment lowers fresh by the same R and the denominator does not move), so R is bounded by 15% of the WARM principal it holds, and the true bound on the premium is 1 / (1 - 0.15 * s_w) where s_w is the churner's share of the warm supply: 17.6% for a churner that is the whole warm supply, where supply / (supply - repaid) suggests at most 15% of its share of the whole supply. Whenever a large part of the supply is fresh (launch, or after a large draw by anyone), the stated bound understates what a redeemer paid out of the Treasury's reserve and out of the other holders' backing. Not a bypass of the acceptance: the cost (gas, below par only, bounded by the churner's warm principal) stands; the bound stated for it does not. Smallest fix: state the bound as (supply - fresh) / (supply - fresh - repaid), with `fresh` the cold principal `laggedNow` excludes, in the `_backingPerUnit` NatSpec, docs/AUDIT-RETRY-PANEL-VAULT-2026-10-07.md's resolution and the retry-panel test's assertion (which should pin the bound with fresh debt present), or make the lagged denominator read the supply the churn left in it by adding the position's banked warm principal (its `bankDebt`, while unexpired) back to `supply - fresh` for the lagged figure.","line":769,"path":"src/CDPVault.sol","reproduction":"test/scratch/BackingBound.t.sol (fails on this code at the stated bound). ParameterizedVault over an 18-decimal IMD at $1 (ETH/USD 2000e8 etched), NHI 0.85, wage 0. BORROWER locks 5,790 and draws 1,000; OTHER locks 5,100, draws 3,000 and hands BORROWER the 3,000 imdUSD; IMD to $0.294 (BORROWER 170.2%, OTHER 50%), both re-priced by lock(1); three quiet days; then NEW locks 14,000 and draws 2,000 in its own transaction: supply 6,000, fresh (totalDebt - laggedNow().debt) 2,000, backingPerUnit() 0.8004e18 (the lagged figure binds). Snapshot: BORROWER's cash(500e18, 0, BORROWER) pays 1297679625000000000000 raw (honest). Revert. Three transactions: BORROWER wipe(140e18); cash(500e18, 0, BORROWER); draw(140e18). EXPECTED by the NatSpec: churned <= honest * 6000 / 5860 = 1328682209897610921501. ACTUAL: 1344618602670256454081 (+3.62%, against the stated +2.39%), which is honest * 4000 / 3860 = 1344745725388601036269 less rounding, i.e. (supply - fresh) / (supply - fresh - repaid).","severity":"low","snippet":"    /// most supply / (supply - repaid), the repayment bounded by the churner's principal above half its","title":"CDPVault._backingPerUnit: the accepted cross-transaction premium is stated as at most supply / (supply - repaid), but the lagged figure's denominator is supply - fresh, so with cold debt outstanding t"},{"citation":"resolved","description":"Q3/Q4, the transient bookkeeping across several mints and burns in one transaction. SUPPLY_START_SLOT holds start + 1, the supply at the transaction's first mint or burn. `cash` subtracts its burn so a second redemption in the same transaction measures against the shrunken supply. The subtraction is an unchecked assembly `sub`: `amount` is only bounded by the LIVE supply (`_redemptionRate`: amount > totalSupply reverts), and a draw or earn earlier in the transaction raises the live supply above `start`, so a redemption of more than `start` wraps the slot to about 2^256 - (amount - start). Until the transaction ends, `_supplyStart()` returns that figure: `_backingPerUnit` takes supply = max(live, start) ~ 2^256, so perUnit rounds to 0 and `backingPerUnit()` reads 0 in a fully backed vault; `_redemptionRate` takes prior = `_laggedSupplyFrom(~2^256)` and quotes the floor with no increase (`redemptionFeeBps` 50 where the honest quote carries the increase); a second `cash` computes gemOut = 0 and reverts ZeroAmount; if the wrap lands on exactly zero (amount == start + 1) the slot reads as 'not recorded' and the next `_mintOrBurn` re-checkpoints `_laggedSupply` and the start supply from the mid-transaction live supply. Reachable with the constants as committed by any router or helper contract that mints and redeems in one call; no profit path was found (a cash at the wrapped figure pays nothing, so the only effects are the revert and the wrong quotes mid-transaction, and the transient slot is cleared at the end of the call), hence low. Smallest fix: saturate at the transaction's floor, e.g. in assembly `let recorded := tload(SUPPLY_START_SLOT) switch gt(recorded, amount) case 1 { tstore(SUPPLY_START_SLOT, sub(recorded, amount)) } default { tstore(SUPPLY_START_SLOT, 1) }` (keep the +1 sentinel so the slot never reads as unrecorded), which also matches the saturating update of `_laggedSupply` on the line before it.","line":734,"path":"src/CDPVault.sol","reproduction":"test/scratch/SupplyStartWrap.t.sol (fails on this code: 'ZeroAmount()'). ParameterizedVault over an 18-decimal IMD at $1, NHI 0.85; OTHER locks 200 and draws 100 (the supply the next transaction begins with is 100); the Treasury holds 10,000 IMD so redemptions are reserve-funded. A router contract calls, in ONE transaction: lock(5000e18), draw(1000e18) [live 1,100, start 100], cash(500e18, 0, address(0)) [burns more than the start: the slot becomes 101 - 500 mod 2^256], then redemptionFeeBps(100e18), backingPerUnit() and cash(100e18, 0, address(0)). EXPECTED: the second redemption is paid like the first (as it would be as a separate transaction), backingPerUnit() == 1e18 and the fee quote carries the increase for 100 against the supply. ACTUAL: the call reverts ZeroAmount() at the second cash; with the asserts removed the logs show backingPerUnit() == 0 and the quote at the 50 bps floor.","severity":"low","snippet":"            tstore(SUPPLY_START_SLOT, sub(tload(SUPPLY_START_SLOT), amount))","title":"CDPVault.cash: the start-of-transaction supply slot is decremented with an unchecked assembly sub, so a redemption larger than the supply the transaction began with (minted earlier in the same call) w"},{"citation":"resolved","description":"Q1(a) and (d), a NatSpec claim the arithmetic does not quite deliver. The vault's cold totals are cooled at every touch of any position (`_lag` line 969) with `_cool(..., up = true)`, while a position's own cold is cooled once, at its next touch, over the whole gap, with `up = false`. The ceiling only guarantees total >= sum when the decay factor composes exactly, and `_pow` does not: each `result * factor / one` and `factor * factor / one` truncates, so `_pow(f, a) * _pow(f, b) / RAY` can read below `_pow(f, a + b)`, and a chain of many short cools reads lower still. Measured on the committed constants: one cool of 86,400 s gives 62499999999999999999995426 / 1e27, 7,200 cools of 12 s give 62499999999999999999994326 / 1e27, 1,100 parts in 1e27 lower; the ceiling adds at most one raw unit per touch (7,200 units) against it, so for a cold secured term above about 6.5e27 raw units (about 6,500 sIMD at 24 decimals, roughly $52,000) a total touched once a block for a day reads below the position's own cold by one or more raw units. Consequence: when such a position subtracts its cold (`total = total > coldOut ? total - coldOut : 0`, line 989) the vault total saturates to zero while another position still holds cold of that dust size, which `laggedNow` then reads as warm. The shortfall is bounded by about 1e-24 of the cold amount per day of per-block touches (1e-18 sIMD on a million-sIMD term): no economic effect, so info. Also checked and holding: the unchecked block in `_lag` (every subtraction guarded by the comparison or min before it; `bank + (out - coldOut)` cannot wrap for any representable position; the uint128 saturation keeps the excess in the vault total, which is the safe direction); `_pow`'s bound (factor and result at most `one`, products below 1e54 for RAY, below 1e36 for 1e18); and its gas over long gaps (at most 17 squarings for the cold, elapsed being capped by BACKING_WARMUP, and about 27 for the fee base over a year of quiet). Smallest fix: reword the claim ('rounded up so that the position's share is never read above the total, to within the truncation of `_pow`'), or compute the vault totals' decay with a rounding-up `_pow` (round each product up) so the totals dominate by construction.","line":1016,"path":"src/CDPVault.sol","reproduction":"Python, integer arithmetic, the committed constants (COLD_SECOND_DECAY = 999967910367635122012970996, RAY = 1e27, the `_pow` loop as written): pow(f, 86400) = 62499999999999999999995426; the product of 7200 successive pow(f, 12) factors, each applied with floor division by RAY as `_cool` does, = 62499999999999999999994326 (1,100 parts in 1e27 lower); over 2,000 random splits a + b <= 86,399, pow(f, a) * pow(f, b) / RAY is below pow(f, a + b) by up to 2 parts in 1e27. For a position whose cold secured term is 1e30 raw units (a million sIMD) cooled once over a day the position reads floor(1e30 * 0.0625 - 5.4e-21 * 1e30) while a total cooled 7,200 times reads at most 1e30 * 0.0625 - 1.1e-21 * 1e30 + 7200, i.e. about 1.1e6 raw units below the position's figure.","severity":"info","snippet":"    /// totals (`up`, so they never read below the sum of the positions) and down for a position; and","title":"CDPVault._cool: 'rounded up for the vault's totals, so they never read below the sum of the positions' is not guaranteed, because _pow truncates at every squaring and a total cooled touch by touch los"}],"hash":"956102e416ca7a9a6ff5b730272ec252b78d2ecbe3fef0c19d929d33e6d5ab77","nodeId":"77a2e3bd-aebd-4846-90e7-357ba3bb9d60","outcome":"completed","summary":"All checks are complete. The source tree is unchanged (the temporary fix patch was reverted after verification), the committed suite passes as a baseline, and `.imd-findings.json` holds four substantiated findings with the one proof attached to the medium.\n\n## Findings\n\n| Severity | Where | Claim |\n|---|---|---|\n| medium | `src/CDPVault.sol:1417`, the supply checkpoint and `_coldRepaidCountsAtOnce` | A cold draw by any position absorbs a warm repayment's fading share of the lagged fee base at the next checkpoint, and its cold repayment is then subtracted a second time. A dominant borrower's wipe, a helper's draw-and-wipe (one call, no fee, no wait) and a 9-of-1,000 redemption pin the fee at the cap for a tenth of the honest burn. This reopens the retry panel's medium #5 at gas cost; the committed regression test passes only because its redraw is credited from the same position's bank. Proof attached and verified to pass with the described fix (cold mints and earns count at once in the base; a bank-credited redraw adds only what is not still fading), with the retry-panel, lag, redemption, cover, liquidation and invariant suites green. |\n| low | `src/CDPVault.sol:769`, the accepted premium's NatSpec | The bound is stated as supply over supply less repaid. The lagged denominator is supply less fresh, so with cold debt outstanding the premium is (supply − fresh)/(supply − fresh − repaid). Measured: a 3.62% premium where the stated bound allows 2.39%. The acceptance stands; its stated cost does not. |\n| low | `src/CDPVault.sol:734`, `cash` | The start-of-transaction supply slot is decremented with an unchecked assembly `sub`. A redemption larger than the supply the transaction began with (minted earlier in the same call) wraps it, so later quotes in that transaction read a ~2^256 supply, backing reads 0 and a second `cash` reverts `ZeroAmount`. No profit path; a router that mints and redeems in one call is blocked. |\n| info | `src/CDPVault.sol:1016`, `_cool` | \"Rounded up so the totals never read below the sum of the positions\" is not guaranteed: `_pow` truncates at every squaring, and a total cooled once per block for a day reads about 1.1e-24 of the amount below a position cooled once. Dust, quantified in the entry. |\n\n## Answers to the six questions\n\n1. **Cold capital per position.** (a) Holds up to the truncation above. The total is cooled at every touch, each position at its own; a position's quiet day zeroes its cold while the total keeps up to a 6.25% residue under activity, which only understates warmth. Probe: after 25 touched hours the lag read 1,953.76 of 2,010, and a day-old position's wipe-and-redraw left the total exactly where it was. (b) No sequence found. Draw-then-bite left 700.4 warm (the honest residue plus half of a six-hour-old draw), draw-then-cover left 0, and the committed cash-order proofs pass. (c) Activity only slows warming; a debt-free lock changes no term and is not a touch at all. The one asymmetry is the exact 24-hour boundary (cold zero at ≥ 24h, bank alive until > 24h), one second wide. (d) Saturation keeps the excess in the vault total, the safe direction; every subtraction in the unchecked block is guarded; `_pow` cannot overflow and costs at most 17 squarings for the cold.\n2. **The bank.** Not inflatable: it is filled only by the warm part of a decrease after the position's own cold is cooled to now. It lives in the position's struct and nothing moves it. One date per bank set only on empty-to-full, so a trickle and the other side cannot keep it alive. One cost to state: a liquidation or cover banks the drained term, so a re-lock within a day reads warm at once. It costs real collateral behind recorded bad debt, taken at par by cover or at the bonus by bite, so no profit was found.\n3. **The lagged fee base.** Broken below the honest supply at gas cost (the medium). Above it needs warm capital, which needs a position's own day. Wipe-then-cash, cash-then-wipe, bite, cover, earn and fee remints we","treeHash":null,"usage":{"cachedInputTokens":7743602,"inputTokens":966,"model":"claude-fable-5-1","outputTokens":139383,"runtime":"claude","turns":84,"wallClockMs":2401441}}],"verification":[]}