{"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":"6a5f4140-95a4-4011-aa75-cbf064127f7f","kind":"audit","nodes":[{"acceptedSubmissionHash":"2510a75e866242965b6b0d6082ee58d5d55f9e1d033ad4c5545afb2a4eae55dd","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":"84d4d922e82645724a8a5909d1f44e141eeeeff1ebe0c1fa615abb24aafd3fae","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":"ea55636b1e3cd14487cdbef290c7c97ba6cc285cd38c013e8ca3f81f0552d4e0","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":"d189067298faf54514fdae265363c31070f65870db62b5e41c141ccba9e49ea9","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":"884c3e9ea10a62bdcebbe0031f222ec1d6758351d4f7de094f0994594e96f412","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, as fixed through this commit, at the pinned commit, for a mainnet launch. Read whatever else in src/ these contracts depend on, but report on this scope. Three audit rounds and their fixes are already in (docs/AUDIT-*.md, newest docs/AUDIT-FINAL-2-2026-10-07.md and the fix commit after it); this panel audits the code as it will deploy, so a finding of an earlier round counts only if its fix regressed or left a gap.\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. Positions: lock, lockIMD (credits shares by balance delta), free, draw, wipe. Below mat, double counting, funds moved for anyone but the caller, reentrancy through the share vault?\n2. Liquidation (bark, barkFor, bite, heel, tail, lull, the mark's expiry): with CHOP_PERCENT 20 and the chip/cut split, can anyone receive more than the formula, can a position be frozen unliquidatable, and can the grace (lull, six hours at NHI >= 0.85) be gamed?\n3. cover and its dust floor (_coverDust: the seizure for the larger of a millionth of the debt and one imdUSD, a hundredth under 100 imdUSD): can cover sweep collateral that is not dust, can a drained borrower still block it cheaply, can coverage exceed what is owed or desynchronise totalBadDebt from the per-position record? The second-half review's residual (docs/AUDIT-FINAL-2-2026-10-07.md finding 4) is fixed here; break the fix.\n4. Redemption (cash): the fee base (redemptionDivisor), the fresh-debt record, candidate eligibility (mat + gap), the backingPerUnit cap and the LAGGED capital (laggedNow, BACKING_WARMUP). Can a redeemer pay less than the fee for its size, worsen a candidate's ratio, or drain the reserve below what backs remaining supply?\n5. Bad debt and the Treasury's imdUSD (BadDebtFirst): any sequence of cover, withdraw, payStream that spends what outstanding bad debt needs?\n6. Stability fee (duty, chi, drip, checkpoints): can a governed rate change reprice elapsed time, or chiOf exceed chi?\n7. Price gating (_pricingStale, _requirePriceAgreement, skew): every value-moving action refused on stale or divergent feeds, the exceptions (lock, wipe, debt-free free) safe, and what a feed that cannot follow a gap for hours (SwarmFeed's allowance schedule) does to each action, liquidations included.\n8. Work issuance (earn, earnLine, earnMat, backedDebt and its transient slot) with WAGE_WAD 0 at launch: can anything mint before governance turns the wage on, and can same-transaction debt or a reserve listing authorise unbacked minting once it is on?\n9. Arithmetic: overflow at extreme collateral or price, rounding direction in every division that pays someone, units wherever a price, a 24-decimal amount and basis points meet.\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":"6c4860a0d31f2afc3fae3e3024861ec1ed596c644956ccedff768df050b14b3b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"6a5f4140-95a4-4011-aa75-cbf064127f7f","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":"51439","feedbackHash":"362e0b5b7b4961fa9b88e02a753da1413a56af2a5e97ad70fd820e2e29a38f25","nodeKey":"audit_economics","submissionHash":"2510a75e866242965b6b0d6082ee58d5d55f9e1d033ad4c5545afb2a4eae55dd","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52157","feedbackHash":"4097cc8017820d4494bea98e36f4dc6f51888370ba0fcc45ffcad444b2de403b","nodeKey":"audit_flow","submissionHash":"84d4d922e82645724a8a5909d1f44e141eeeeff1ebe0c1fa615abb24aafd3fae","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51082","feedbackHash":"d81588a21ccbea1579b589955034fe5b25d9a959ff6915771a463c1be9d0640f","nodeKey":"audit_judge","submissionHash":"ea55636b1e3cd14487cdbef290c7c97ba6cc285cd38c013e8ca3f81f0552d4e0","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52159","feedbackHash":"55c02e494b55548ddb24a0cd368faaeb49ea795e7caa7a56c6dd64d4f1672452","nodeKey":"audit_math","submissionHash":"d189067298faf54514fdae265363c31070f65870db62b5e41c141ccba9e49ea9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52163","feedbackHash":"af1d559fdadcbfe6d26c93858cad2b22d0a0d99b1f859f43cacd9ff4a8ba2220","nodeKey":"audit_permissions","submissionHash":"884c3e9ea10a62bdcebbe0031f222ec1d6758351d4f7de094f0994594e96f412","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"49bf0a39aef7e597c51d37771b0d0760a9637e0435a1245edff7d865271258d8","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"249bc6a0e6af3475","findings":[{"citation":"resolved","description":"ParameterizedVault._lagApplies disables the work-backing warm-up whenever wage is zero, but CDPVault.earn does not check the wage and SwarmWorkOracle.mintingRights/consumeRights continue to honour previously credited rights. After an ordinary timelocked wage shutdown, a rights holder can borrow in one transaction, earn against all that new debt in another transaction in the same block, then repay and withdraw everything. This reopens the adjacent-transaction unbacked-minting defect that BACKING_WARMUP was added to fix. It also contradicts _lagApplies' NatSpec ('exactly while minting from work is on') and Parameters.proposeWorkOracle's claim that wage zero means no rights are claimable in either oracle. The governor need not act maliciously: switching work issuance off is documented normal operation. Reachability: not at first deployment with WAGE_WAD=0 and no saved rights; reachable with the committed code after governance enables wages, a user claims rights, and governance returns the wage to zero. Smallest economic fix: make backedDebt apply laggedNow unconditionally, including at wage zero. If zero wage is intended to suspend spending saved rights too, additionally reject earn while wage is zero (without erasing those rights).","line":112,"path":"src/ParameterizedVault.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 {Parameters} from \"src/Parameters.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {SwarmWorkOracle} from \"src/SwarmWorkOracle.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY, ERC8004_ADAPTER} from \"src/DeploymentConfig.sol\";\n\n// Only environmental collateral/price/identity stand-ins; accounting is the production vault.\ncontract ERToken {\n    string public name = \"Shares\";\n    string public symbol = \"sIMD\";\n    uint8 public constant decimals = 24;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 amount) external { balanceOf[to] += amount; totalSupply += amount; }\n    function approve(address spender, uint256 amount) external returns (bool) { allowance[msg.sender][spender] = amount; return true; }\n    function transfer(address to, uint256 amount) external returns (bool) { balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true; }\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount; balanceOf[to] += amount; return true;\n    }\n    function asset() external pure returns (address) { return address(0x1AD); }\n    function convertToAssets(uint256 shares) external pure returns (uint256) { return shares * 795 / 100_000_000; }\n}\ncontract ERFeed is ISwarmFeed {\n    uint256 public immutable value;\n    uint256 public constant maxAge = 1 days;\n    constructor(uint256 v) { value = v; }\n    function latestValue() external view returns (uint256, uint64) { return (value, uint64(block.timestamp)); }\n    function isStale() external pure returns (bool) { return false; }\n}\ncontract ERAggregator {\n    function decimals() external pure returns (uint8) { return 8; }\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\ncontract ERAdapter {\n    function isController(uint256, address) external pure returns (bool) { return true; }\n}\ncontract ERWork is SwarmWorkOracle {\n    constructor(address consumer) SwarmWorkOracle(consumer, 1 days) {}\n    function seedRoot(bytes32 root) external { _accept(uint256(root), uint64(block.timestamp)); }\n}\ncontract WageOffResidualTest is Test {\n    ParameterizedVault vault;\n    ERToken shares;\n    ERWork work;\n    ImdUSD stable;\n    address constant BORROWER = address(0xB0B);\n    address constant HOLDER = address(0xCAFE);\n    uint256 constant PRICE = 79_500_000_000_000; // $10/IMD * 7.95 IMD/sIMD, per 1e18 raw shares\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new ERAggregator()).code);\n        vm.etch(ERC8004_ADAPTER, address(new ERAdapter()).code);\n        shares = new ERToken();\n        ERFeed primary = new ERFeed(5e15); // IMD = $10\n        ERFeed nhi = new ERFeed(0.85e18);\n        ERFeed spot = new ERFeed(5e15);\n        address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);\n        work = new ERWork(predicted);\n        vault = new ParameterizedVault(address(shares), address(0), address(work), address(primary), address(nhi), address(spot));\n        assertEq(address(vault), predicted);\n        stable = vault.stablecoin();\n        shares.mint(BORROWER, 30_000e24);\n        vm.prank(BORROWER);\n        shares.approve(address(vault), type(uint256).max);\n    }\n    function _wage(uint256 amount) private {\n        Parameters p = vault.parameters();\n        vm.prank(APPROVED_OPERATOR);\n        p.proposeWage(amount);\n        vm.warp(block.timestamp + 48 hours);\n        p.applyPending();\n    }\n    function _lockDollars(uint256 dollars) private {\n        vm.prank(BORROWER);\n        vault.lock((dollars * 1e18 + PRICE - 1) / PRICE);\n    }\n    function test_zeroWageMustNotDisableLagForPreviouslyClaimedRights() public {\n        _wage(1e18);\n        bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(uint256(1), uint32(250), uint64(250)))));\n        work.seedRoot(root);\n        work.recordRoot();\n        vm.prank(BORROWER);\n        work.claim(1, 250, 250, new bytes32[](0), root);\n        assertEq(work.mintingRights(BORROWER), 250e18);\n        _wage(0);\n        assertEq(vault.parameters().wage(), 0);\n        _lockDollars(2000e18);\n        vm.prank(BORROWER);\n        vault.draw(1000e18);\n        (uint256 lag,) = vault.laggedNow();\n        assertEq(lag, 0);\n        // Separate top-level calls are separate transactions (the repository's isolate=true).\n        // No time elapses between borrowing, earning, repayment and withdrawal.\n        vm.prank(BORROWER);\n        (bool earned,) = address(vault).call(abi.encodeCall(vault.earn, (250e18)));\n        if (!earned) assertEq(vault.totalEarned(), 0);\n        vm.prank(BORROWER);\n        vault.wipe(1000e18);\n        (uint256 collateral,) = vault.positions(BORROWER);\n        vm.prank(BORROWER);\n        vault.free(collateral);\n        assertEq(vault.totalDebt(), 0);\n        assertEq(shares.balanceOf(address(vault)), 0);\n        assertEq(vault.reserveValue(), 0);\n        emit log_named_uint(\"unbacked work supply\", stable.totalSupply());\n        // Either earn must refuse while off, or its ceiling must retain the lag.\n        assertEq(stable.totalSupply(), 0, \"saved rights minted against zero-second debt after wage was switched off\");\n    }\n}","reproduction":"Self-contained Foundry test test/scratch/WageOffResidual.t.sol::test_zeroWageMustNotDisableLagForPreviouslyClaimedRights. Production ParameterizedVault, Treasury and SwarmWorkOracle accounting; fresh $10 IMD price, $2000 ETH/USD, 24-decimal shares with 7.95 IMD/share, NHI=0.85, LINE=1,000,000 imdUSD. Governor proposes wage=1e18 and applies after 48h. Worker proves a valid accepted root for 250 cumulative tasks, claiming 250e18 rights without minting. Governor proposes wage=0 and applies after 48h. With no existing debt or reserve, worker locks $2000 of shares and draws 1000e18; next transaction in the same block calls earn(250e18); next calls wipe(1000e18) and free(all). laggedDebt is zero throughout. Expected: earn is refused, or the zero-second debt contributes zero to the work ceiling. Actual: all calls succeed, totalDebt=0, reserveValue=0, vault collateral=0, and totalSupply=250e18 held by the worker with backingPerUnit=0. The assertion requiring zero unbacked supply fails with 250000000000000000000 != 0. forge test --match-path 'test/scratch/*Residual.t.sol' -vv reproduced both independent assertion failures.","severity":"medium","snippet":"        return parameters.wage() != 0;","title":"Setting wage to zero reopens unbacked work minting through saved rights"},{"citation":"resolved","description":"In draw, rounding the timestamp increment up can move mintedAt all the way to block.timestamp when the added tranche is large relative to existing principal. _reduceDebt then attempts to recover the old age by multiplying (block.timestamp - mintedAt), which is already zero. Multiplying zero cannot recover the discarded principal-time, even when the whole repayment, including fees, is removed from the fresh record. Thus the fixes described at lines 454-458 and 1153-1165 still leave the record renewable indefinitely: genuinely old principal is excluded from the stored redemption-rate increase. This is a residual of the previously fixed fresh-debt pinning defect, not a report of the old small-tranche reproduction. The current redeemer is still charged the quoted fee; the defect is the lower base left for subsequent redeemers. Reachable with the committed launch constants, no governance changes, no work issuance and no manipulated price. The demonstrated renewal uses 400,000 temporary imdUSD against about $2M of temporarily posted collateral to refresh $10 of old principal every 11 hours; at shorter renewal intervals larger principal can be refreshed within LINE. Smallest sound fix: preserve fractional principal-time in the per-position fresh record and use it through both draw and _reduceDebt, rather than trying to reconstruct it from an integer-second weighted timestamp. Simply reversing one rounding direction can instead over-age the residual; preserve the remainder or use explicit tranches.","line":463,"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 {Parameters} from \"src/Parameters.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {SwarmWorkOracle} from \"src/SwarmWorkOracle.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY, ERC8004_ADAPTER} from \"src/DeploymentConfig.sol\";\n\n// Only environmental collateral/price/identity stand-ins; accounting is the production vault.\ncontract ERToken {\n    string public name = \"Shares\";\n    string public symbol = \"sIMD\";\n    uint8 public constant decimals = 24;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 amount) external { balanceOf[to] += amount; totalSupply += amount; }\n    function approve(address spender, uint256 amount) external returns (bool) { allowance[msg.sender][spender] = amount; return true; }\n    function transfer(address to, uint256 amount) external returns (bool) { balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true; }\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount; balanceOf[to] += amount; return true;\n    }\n    function asset() external pure returns (address) { return address(0x1AD); }\n    function convertToAssets(uint256 shares) external pure returns (uint256) { return shares * 795 / 100_000_000; }\n}\ncontract ERFeed is ISwarmFeed {\n    uint256 public immutable value;\n    uint256 public constant maxAge = 1 days;\n    constructor(uint256 v) { value = v; }\n    function latestValue() external view returns (uint256, uint64) { return (value, uint64(block.timestamp)); }\n    function isStale() external pure returns (bool) { return false; }\n}\ncontract ERAggregator {\n    function decimals() external pure returns (uint8) { return 8; }\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\ncontract ERAdapter {\n    function isController(uint256, address) external pure returns (bool) { return true; }\n}\ncontract ERWork is SwarmWorkOracle {\n    constructor(address consumer) SwarmWorkOracle(consumer, 1 days) {}\n    function seedRoot(bytes32 root) external { _accept(uint256(root), uint64(block.timestamp)); }\n}\ncontract FreshAgeResidualTest is Test {\n    ParameterizedVault vault;\n    ERToken shares;\n    ERWork work;\n    ImdUSD stable;\n    address constant BORROWER = address(0xB0B);\n    address constant HOLDER = address(0xCAFE);\n    uint256 constant PRICE = 79_500_000_000_000; // $10/IMD * 7.95 IMD/sIMD, per 1e18 raw shares\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new ERAggregator()).code);\n        vm.etch(ERC8004_ADAPTER, address(new ERAdapter()).code);\n        shares = new ERToken();\n        ERFeed primary = new ERFeed(5e15); // IMD = $10\n        ERFeed nhi = new ERFeed(0.85e18);\n        ERFeed spot = new ERFeed(5e15);\n        address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);\n        work = new ERWork(predicted);\n        vault = new ParameterizedVault(address(shares), address(0), address(work), address(primary), address(nhi), address(spot));\n        assertEq(address(vault), predicted);\n        stable = vault.stablecoin();\n        shares.mint(BORROWER, 30_000e24);\n        vm.prank(BORROWER);\n        shares.approve(address(vault), type(uint256).max);\n    }\n    function _wage(uint256 amount) private {\n        Parameters p = vault.parameters();\n        vm.prank(APPROVED_OPERATOR);\n        p.proposeWage(amount);\n        vm.warp(block.timestamp + 48 hours);\n        p.applyPending();\n    }\n    function _lockDollars(uint256 dollars) private {\n        vm.prank(BORROWER);\n        vault.lock((dollars * 1e18 + PRICE - 1) / PRICE);\n    }\n    function test_largeDrawAndWipeMustNotErasePrincipalAge() public {\n        _lockDollars(2_000_000e18);\n        vm.prank(BORROWER);\n        vault.draw(10e18);\n        vm.prank(BORROWER);\n        stable.transfer(HOLDER, 1e18);\n        uint256 started = block.timestamp;\n        // Renew the age at 11 hours. 400,000 is over 39,599 * the original 10.\n        for (uint256 i; i < 3; ++i) {\n            vm.warp(block.timestamp + 11 hours);\n            vm.startPrank(BORROWER);\n            vault.draw(400_000e18);\n            vault.wipe(400_000e18);\n            vm.stopPrank();\n        }\n        assertGt(block.timestamp - started, 12 hours);\n        // Withdraw the temporary collateral, keeping the candidate at 200% (eligible below 220%).\n        (uint256 all, uint256 debt) = vault.positions(BORROWER);\n        uint256 keep = (debt * 2 * 1e18 + PRICE - 1) / PRICE;\n        vm.prank(BORROWER);\n        vault.free(all - keep);\n        assertLt(vault.collateralRatio(BORROWER), vault.redemptionCeilingCR());\n        vm.prank(HOLDER);\n        vault.cash(1e18, 0, BORROWER);\n        emit log_named_uint(\"redemption base\", vault.redemptionBaseRate());\n        // The principal has remained outstanding for 33 hours; draw/wipe pairs should conserve its age.\n        assertGt(vault.redemptionBaseRate(), 0, \"round-trip rounding reset seasoned debt to fresh\");\n    }\n}","reproduction":"Self-contained Foundry test test/scratch/FreshAgeResidual.t.sol::test_largeDrawAndWipeMustNotErasePrincipalAge. Production ParameterizedVault at NHI=0.85, DUTY_BPS=444, LINE=1,000,000e18, 24-decimal collateral worth $79.50/share. At t0 lock collateral worth $2M, draw 10e18 and send 1e18 to HOLDER. At t0+11h call draw(400000e18), then wipe(400000e18) without advancing time. fresh=10e18, age=39600: ceil(39600*400000/(400010))=39600, so mintedAt becomes now and the retained principal's computed age is zero. Repeat at +22h and +33h (accrued fees are correctly paid first and still do not repair the lost age). Withdraw the temporary collateral to leave a 200% ratio, eligible below mat+gap=220. HOLDER calls cash(1e18,0,BORROWER). Expected: the principal held through 33 hours contributes to redemptionBaseRate, so it is positive. Actual: freshCancelled=1e18 and redemptionBaseRate remains 0; the next no-size fee quote remains 50 bps. The test fails with 'round-trip rounding reset seasoned debt to fresh: 0 <= 0'.","severity":"low","snippet":"                + Math.mulDiv(block.timestamp - position.mintedAt, amount, fresh + amount, Math.Rounding.Ceil);","title":"Integer-second rounding still lets draw/wipe round trips erase fresh-debt age"},{"citation":"resolved","description":"The committed dust-floor fix broadened cover to the seizure for max(debt/1,000,000, min(debt/100, 1 imdUSD), 1 wei), but cover's NatSpec at lines 511-518 still says collateral must be unreachable by bite and under a millionth of debt (at least the one-wei seizure). Both claims are false: cover now deliberately sweeps collateral that is economically small but reachable by bite. This is a documentation gap left by the latest fix, not a criticism of the chosen dust floor. The new comment at line 580 is also overbroad: the roughly $1.20 blocking deposit applies only at debt >= $100 (and until the millionth term exceeds $1), and a deposit at or above the threshold is liquidated through bite, with only the protocol bonus share reaching the surplus account. Smallest fix: have cover's NatSpec refer to _coverDust and its full formula, distinguish economically small from mathematically unreachable collateral, and describe the recipients of a subsequent bite correctly. Reachable with the committed constants.","line":512,"path":"src/CDPVault.sol","reproduction":"A position with 1000e18 debt and no collateral has its residual recorded. With fresh agreeing feeds at price=1e12 (USD per 1e18 raw collateral), its borrower calls lock(1e24), collateral worth $1, and the Treasury holds 1000e18 imdUSD. Anyone calls cover(owner,1000e18). Expected from lines 512 and 517: reject, since the collateral is above the seizure for 0.001 imdUSD (1.2e21 raw) and bite can reach it. Actual from _coverDust: floor_=1e18, slice=1e18, threshold=1.2e24 raw; 1e24 is below it, so cover sweeps the full $1 to the Treasury and retires the debt. For line 580, a residual of 10e18 needs only 0.12e24 raw ($0.12) to block cover at equality. If marked and bitten for 0.1e18 after grace, CHOP=20, CHIP=CUT=1000 give the protocol $0.002, the marker $0.002 and the liquidator $0.116, rather than the whole blocking deposit going to the surplus account. These are intended code behaviours; the comments should describe them.","severity":"info","snippet":"    /// holding dust worth under a millionth of its debt (at least the seizure for one wei), which is","title":"cover NatSpec still promises the old, unreachable-collateral-only dust rule"}],"hash":"2510a75e866242965b6b0d6082ee58d5d55f9e1d033ad4c5545afb2a4eae55dd","nodeId":"f868450b-7f10-4c5f-84ef-d55e158ae05e","outcome":"completed","summary":"Saved **3 findings** in [.imd-findings.json](/home/imd-worker/.identitymd/work/6a5f4140-95a4-4011-aa75-cbf064127f7f/f868450b-7f10-4c5f-84ef-d55e158ae05e/.imd-findings.json), with exact snippets, concrete reproductions, fixes, and two embedded Foundry proofs. Production contracts and configuration remain unchanged.\n\n- **Medium — saved work rights bypass backing warm-up after wage returns to zero.** `ParameterizedVault._lagApplies`, line 112. After governance enables wages, a worker saves rights, and governance disables wages, the worker can draw 1,000 imdUSD, earn 250 in another transaction in the same block, repay, and withdraw all collateral. The test ends with **250 imdUSD supply and zero backing**. Apply the work-backing lag regardless of wage; optionally suspend `earn` while wages are zero.\n- **Low — integer-second rounding still erases fresh-debt age.** `CDPVault.draw`, line 463. Repeated 400,000-imdUSD draw/wipe pairs refresh $10 of existing principal every 11 hours. After 33 hours, redemption still treats that principal as fresh and leaves the stored fee base at zero. Preserve fractional principal-time through both borrowing and repayment.\n- **Info — `cover` documentation contradicts the new dust floor.** `CDPVault.cover`, line 512. At $1,000 debt, collateral worth $1 is swept despite being reachable by `bite` and exceeding the documented millionth threshold. Update the comments to describe the implemented economic threshold and liquidation recipients.\n\nThe existing non-fork suite passed. Both new regression tests failed at their intended assertions.\n\n1. **Positions.** I found no additional below-`mat`, double-credit, unauthorized withdrawal, or share-vault reentrancy defect. `lock` checks the received collateral; `lockIMD` credits the actual share balance increase. `draw` and debt-bearing `free` check accrued debt. `wipe` burns the caller’s tokens and retires fees first. Value-changing position functions share the reentrancy guard. The deployed external sIMD implementation remains an external dependency I did not fork-verify.\n\n2. **Liquidation.** Ignoring raw-unit rounding, a bite burns debt \\(D\\) and seizes \\(1.20D\\) of collateral: liquidator \\(1.16D\\), marker \\(0.02D\\), Treasury \\(0.02D\\). A liquidator who also marked receives \\(1.18D\\). There is an intentional exception: the final remainder below a one-wei seizure is swept in addition to the formula payout. Active marks cannot be reset simply by repeatedly barking. Grace is snapshotted; at NHI ≥ 0.85 it lasts six hours, followed by the liquidation window. Expiry permits a new mark and grace period. I found no new permanent dust freeze; stale or divergent feeds can nevertheless prevent liquidation.\n\n3. **`cover` and dust.** The threshold is the seizure for:\n   `max(debt / 1,000,000, min(debt / 100, 1 imdUSD), 1 wei)`.\n   Collateral strictly below that threshold can be swept. This includes collateral reachable by `bite`, as the documentation finding demonstrates. Depositing at the threshold still blocks `cover`, but exposes that collateral to liquidation; the fix imposes an economic cost rather than eliminating repeated obstruction. I found no additional bypass of the committed floor, excess debt cancellation, or sequence desynchronizing the recorded aggregate from its per-position updates.\n\n4. **Redemption.** The fresh-debt finding leaves subsequent redemptions a lower stored base; it does **not** make the current call evade its computed fee. Same-transaction principal and work issuance are excluded from the fee denominator. Candidate eligibility uses `mat + gap`; the proportional collateral limit prevents worsening the candidate’s ratio. Reserve expenditure is checked using the backing cap and consistent collateral valuation. Redemption uses lagged capital at every wage. I found no new reserve-drain bypass beyond previously documented issues excluded by the assignment.\n\n5. **Treasury and bad debt.** I found no new `cover` → `withdraw` → `payStream` ordering bypass. ","treeHash":null,"usage":{"cachedInputTokens":4904576,"inputTokens":272173,"model":"gpt-6-astra","outputTokens":25576,"runtime":"codex","turns":7,"wallClockMs":1432838}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"273e2f46918e7b5e","findings":[],"hash":"5603e68604b6229360d78a7e06e4c627b97ee708855f4b4d770f5aa8f94b67b3","nodeId":"898f8fb6-6be0-417c-a0b7-50f25a714dee","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":4,"wallClockMs":792942}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dd0f9bb0bf93f9dd","findings":[],"hash":"74d71d69215c1f7d0a3c50a3eb462b4afe11215de193b84437d309165d5dfe29","nodeId":"d4fe5288-b041-4b7b-bb77-8cefbd510b30","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":6,"wallClockMs":1175885}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"37eed9f56188ea8b","findings":[{"citation":"resolved","description":"CDPVault._clampLag (called from _resecure and _reduceDebt) lowers laggedDebt and laggedSecured to the live figure the moment either falls, while _approach only credits an increase at elapsed/BACKING_WARMUP per checkpoint. The clamp does not ask WHO removed the capital or whether it comes straight back, so a borrower who repays and re-borrows (wipe then draw) or withdraws and re-deposits (free then lock) inside ONE transaction converts its own warm capital into fresh capital: live totalDebt and securedCollateral are unchanged afterwards, but laggedDebt / laggedSecured stay at the clamped level for up to a day (exponentially longer under activity, per the NatSpec at lines 288-296). _backingPerUnit reads min(live, lagged) at every wage (line 682-687), so the figure every redeemer is paid against (cash, line 632) falls although nothing left the system. Two regimes. (A) Once governance sets a nonzero wage and work-minted imdUSD E is outstanding (supply = D + E): the lagged figure is min(heldLagged x price, 1.7 x (laggedDebt - bad)) / (supply - (totalDebt - laggedDebt)). After a borrower holding D_a of the debt churns, laggedDebt = D - D_a and the ratio is 1.7(D - D_a) / (D - D_a + E), which is below par whenever 0.7(D - D_a) < E, i.e. for any borrower with more than 1 - E/(0.7 D) of the debt (64% at the maximum earnMat of 25%; any borrower at all once repayments have brought D below E/0.7, since totalEarned never falls). When one position holds all the debt it reaches ZERO: cash reverts ZeroAmount (gemOut == 0) for every redeemer, and recovers only linearly with quiet time (0.24 after one quiet hour) or exponentially under activity, and the borrower can repeat it every block for gas. (B) At launch (wage 0, E = 0) the debt side cancels (fresh debt leaves both numerator cap and denominator) but the collateral side does not: when the collateral term binds (system near 100% on secured terms after a fall), a healthy borrower who frees down to 170% and re-locks removes up to 0.3 x its principal from laggedSecured, e.g. 1.0 -> 0.90 in the reproduction. Who profits: the churning borrower when it is the candidate being redeemed against (its debt is cancelled for backing x (1 - fee) of collateral per imdUSD instead of par), and the Treasury on the reserve-funded part; who loses: every redeemer paid against the depressed figure (a redeemer with minGemOut set is instead refused), and the peg, whose floor min(1 - fee, backing) the comment at lines 629-631 presents as the honest backing. Reachable with the constants as committed only in regime (B) (needs the collateral term to bind); regime (A) needs the wage governance intends to raise (DeploymentConfig.sol:168-172, a 48-hour proposal), after which a single borrower above 64% of a young vault's debt, or any borrower after repayments shrink D below E/0.7, can hold redemptions shut. The NatSpec at lines 294-296 ('Backing reads min(live, lagged), so capital brought in one transaction and withdrawn a few later cannot authorise work minting or a redemption at par') describes the direction the lag defends and is silent on this one; the README-level claim that redemption 'pays pro-rata' and the cash() comment that the measure is 'exactly neutral on backing by construction' do not hold while a clamp is in force. Smallest fix that keeps the design (decreases count at once for everyone else): make a same-position re-add within BACKING_WARMUP restore what that position's own decrease clamped. Record per position the lagged amounts its last decrease removed (coolingDebt, coolingSecured, cooledAt); in _resecure / draw, when the same position's term or principal rises again within BACKING_WARMUP of cooledAt, add min(increase, cooling) directly to laggedSecured / laggedDebt (bounded by the live figure) and reduce the cooling record, instead of routing it through _approach and the transient tally. Alternatively, do not clamp on a decrease that the same transaction reverses (compare at the end of the call), whi","line":849,"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 {MockWorkOracle} from \"src/MockWorkOracle.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract ChurnFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 public value;\n\n    constructor(uint256 v) {\n        value = v;\n    }\n\n    function set(uint256 v) external {\n        value = v;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, uint64(block.timestamp));\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract ChurnMirror is ISwarmFeed {\n    ISwarmFeed private immutable p;\n\n    constructor(ISwarmFeed p_) {\n        p = p_;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return p.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return p.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return p.maxAge();\n    }\n}\n\ncontract ChurnAggregator {\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 borrower that repays and re-borrows inside ONE transaction, so the round trip is atomic\n/// and nothing leaves the system: the same collateral and the same debt afterwards.\ncontract Churner {\n    ParameterizedVault private immutable vault;\n\n    constructor(ParameterizedVault v) {\n        vault = v;\n    }\n\n    function open(MockIMD imd, uint256 collateral, uint256 debt) external {\n        imd.approve(address(vault), collateral);\n        vault.lock(collateral);\n        vault.draw(debt);\n    }\n\n    /// @dev Wipe everything (principal plus the accrued fee) and draw the same figure back.\n    function churnDebt() external {\n        uint256 debt = vault.debtOf(address(this));\n        vault.wipe(debt);\n        vault.draw(debt);\n    }\n}\n\n/// @notice FINDING: the lagged backing (`laggedNow`) is clamped DOWN at once on any decrease and only\n/// warms back up over a day, so a borrower who repays and re-borrows in one transaction converts warm\n/// capital into fresh capital at will. Nothing left the system, yet `backingPerUnit()` — the figure\n/// every redeemer is paid against — falls, and with work-minted supply outstanding it falls to ZERO,\n/// which makes `cash` revert `ZeroAmount` for everyone for up to a day. Repeatable for gas.\n/// This test fails on the committed code and passes once a same-position re-add within\n/// BACKING_WARMUP restores the lagged figure the decrease clamped (or the clamp is otherwise closed).\ncontract LagChurnTest is Test {\n    address private constant WORKER = address(0xCA);\n    address private constant REDEEMER = address(0x4E1);\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    MockWorkOracle private oracle;\n    Churner private churner;\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 ChurnAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        // 1 IMD = 1/2000 ETH and 1 ETH = $2000, so the vault prices IMD at exactly $1 per 1e18 raw.\n        ChurnFeed primary = new ChurnFeed(uint256(1 ether) * 1e18 / 2000 ether);\n        ChurnFeed health = new ChurnFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new ChurnMirror(primary))\n        );\n        stable = vault.stablecoin();\n        oracle = MockWorkOracle(address(vault.oracle()));\n        churner = new Churner(vault);\n        vm.startPrank(APPROVED_OPERATOR);\n        oracle.grantRights(WORKER, 1_000 ether);\n        imd.mint(address(churner), 10_000 ether);\n        vm.stopPrank();\n        // Minting from work switched on the governed way.\n        Parameters params = vault.parameters();\n        vm.prank(APPROVED_OPERATOR);\n        params.proposeWage(0.01 ether);\n        vm.warp(block.timestamp + params.TIMELOCK());\n        params.applyPending();\n    }\n\n    /// @dev With work-minted imdUSD outstanding, the dominant borrower's atomic wipe-and-redraw drives\n    /// the lagged figure to zero: every redemption reverts for a day although the system is fully backed.\n    function test_atomicRepayAndRedrawDoesNotChangeBackingOrBlockRedemption() public {\n        churner.open(imd, 2_000 ether, 1_000 ether); // 200%, well above mat 170\n        vm.warp(block.timestamp + vault.BACKING_WARMUP() + 1); // the debt is warm\n        vm.prank(WORKER);\n        vault.earn(250 ether); // the ceiling: 1000 x 2500 / 10000\n        vm.warp(block.timestamp + vault.BACKING_WARMUP() + 1); // everything warm\n        vm.startPrank(WORKER);\n        stable.transfer(REDEEMER, 100 ether);\n        stable.transfer(address(churner), 10 ether); // for the fee the churner accrued meanwhile\n        vm.stopPrank();\n\n        assertEq(vault.backingPerUnit(), 1e18, \"fully backed: 2000 of collateral behind 1250 of supply\");\n\n        churner.churnDebt(); // one transaction: wipe principal + fee, draw the same figure back\n\n        (uint256 collateral, uint256 debt) = vault.positions(address(churner));\n        assertEq(collateral, 2_000 ether, \"same collateral\");\n        assertGe(debt, 1_000 ether, \"same principal (plus the fee it just converted)\");\n        assertGe(vault.backingPerUnit() , 1e18 - 1e15, \"an atomic round trip must not move backing below par\");\n\n        vm.prank(REDEEMER);\n        uint256 out = vault.cash(10 ether, 0, address(churner));\n        assertGt(out, 9 ether, \"redemption must stay open and pay about par less the fee\");\n    }\n}","reproduction":"test/scratch/LagChurn.t.sol (FAILS on this code). ParameterizedVault over an 18-decimal IMD at $1 (IMD/ETH 1/2000 x ETH/USD 2000), NHI 0.85 (mat 170), wage 0.01 applied through Parameters after the 48-hour timelock. A contract borrower locks 2,000 IMD and draws 1,000 imdUSD; a day later a rights holder earns 250 imdUSD (the ceiling, 1000 x 2500/10000); a day later everything is warm: backingPerUnit() == 1e18 (2,000 of collateral behind 1,250 of supply). The borrower calls wipe(debtOf(self)) then draw(the same figure) in ONE transaction. Expected: the position holds the same 2,000 collateral and the same principal, supply is unchanged apart from the 0.4 of fee it paid, so backingPerUnit() stays 1e18 and cash(10e18, 0, borrower) pays about 9.95 IMD. Actual (forge test --match-path test/scratch/LagChurn.t.sol): backingPerUnit() == 0 (laggedDebt 0, laggedSecured 0), cash(10e18, 0, borrower) reverts ZeroAmount(); after one quiet hour backingPerUnit() == 242907785239231997 and the same redemption pays 2.407 IMD for 10 imdUSD. With two borrowers at 60/40 of the debt the churn by the 60% holder leaves the figure at 1e18 (1.7 x 400 / 650 > 1), so the threshold is concentration: above 1 - E/(0.7 D). Launch variant (wage 0; test/scratch/Explore.t.sol test_launchCollateralChurn): borrower A 2,000 IMD / 1,000 debt, borrower B 38,000 IMD / 1,000 debt, price falls to $0.05 (A 10%, B 190%), both positions touched at the new price and warmed: backingPerUnit() == 1e18. B calls free(3,990) then lock(3,990) in one transaction (170% -> 190%, nothing leaves). Expected: 1e18. Actual: 900250000000000000 (laggedSecured 36,010 against securedCollateral 40,000) for the next day.","severity":"medium","snippet":"        if (totalDebt < laggedDebt) laggedDebt = totalDebt;\n        if (securedCollateral < laggedSecured) laggedSecured = securedCollateral;","title":"Lagged backing is clamped down at once by a borrower's own atomic repay-and-redraw (or withdraw-and-redeposit), so anyone can underpay redeemers for a day and, with work-minted supply outstanding, mak"},{"citation":"resolved","description":"ParameterizedVault._lagApplies() (line 111-113) answers `parameters.wage() != 0`, and backedDebt() (line 250-259) applies laggedNow() only when it is true. But nothing in earn() reads the wage: it mints whatever `oracle().mintingRights(msg.sender)` allows (CDPVault.sol:480-481), and oracle() (line 117-120) returns `parameters.workOracle()` once governance has applied Parameters.proposeWorkOracle, which is allowed ONLY while the wage is zero (Parameters.sol:385). The wage is a property of SwarmWorkOracle alone (it multiplies a task count); a successor oracle that prices rights any other way, which docs/PARAMETERS-2026-10-05.md ('Minting from work, deferred without blocking') describes as the planned deployment ('a governed tariff per skill ... the wage is 0 at launch and Parameters.proposeWorkOracle can replace the oracle wholesale while it stays 0'), therefore turns minting from work ON while _lagApplies() stays false. In that state backedDebt() is only min(totalDebt, debt at transaction start) - totalBadDebt, exactly the pre-D1 figure, and the adjacent-transaction round trip the launch audit's vault panel rated medium (borrow in one transaction, earn against a quarter of it in the next, repay and withdraw in a third) leaves work-minted imdUSD with no collateral and no debt behind it; the lag that was built to close it (CDPVault.sol:285-296, tracked from deployment 'so it is warm whenever it is read') never engages. Not reachable with the constants as committed: it needs the governor to apply a replacement oracle (48-hour timelock) whose rights do not come from the wage, and no such contract ships yet. It is not a bypass of governance, but it is a gap in the D1 fix's gating: the comment at line 109 ('The lagged WORK CEILING applies exactly while minting from work is on') and at CDPVault.sol:865 ('ParameterizedVault turns it on exactly when minting from work is on (a nonzero wage)') equate the two, and the Parameters NatSpec at line 244 ('Adds no trust: a governor who could mint through a hostile oracle can already raise the wage') is weaker than stated, because raising the wage turns the lag on and replacing the oracle does not. Smallest fix: drop the gate (`return true`): the redemption half already reads the lag at every wage, the figures are warm from deployment, and with the shipped oracle at wage 0 nothing can mint, so an always-on lag changes nothing at launch; or at least `return parameters.wage() != 0 || parameters.workOracle() != address(0)`. Then make Parameters._validate for Change.WorkOracle require that the successor answers mintingRights(vault) == 0 at proposal, or document that a successor must derive rights from wage().","line":112,"path":"src/ParameterizedVault.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 {IWorkOracle} from \"src/interfaces/IWorkOracle.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract RFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 public value;\n\n    constructor(uint256 v) {\n        value = v;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, uint64(block.timestamp));\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract RMirror is ISwarmFeed {\n    ISwarmFeed private immutable p;\n\n    constructor(ISwarmFeed p_) {\n        p = p_;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return p.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return p.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return p.maxAge();\n    }\n}\n\ncontract RAggregator {\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 The shape of the successor the parameters record plans (docs/PARAMETERS-2026-10-05.md, \"Minting\n/// from work\"): rights come from a governed tariff per job type, not from `wage()`. Nothing here is\n/// hostile: it answers `vault()`, `mintingRights` and `predecessor` exactly as Parameters requires.\ncontract TariffOracle is IWorkOracle {\n    address public immutable vault;\n    address public immutable predecessor;\n    mapping(address => uint256) public override mintingRights;\n\n    constructor(address vault_, address predecessor_) {\n        vault = vault_;\n        predecessor = predecessor_;\n    }\n\n    function credit(address account, uint256 amount) external {\n        mintingRights[account] += amount;\n    }\n\n    function consumeRights(address account, uint256 amount) external override {\n        require(msg.sender == vault, \"vault only\");\n        mintingRights[account] -= amount;\n    }\n}\n\n/// @notice FINDING: `ParameterizedVault._lagApplies()` keys the lagged WORK CEILING on\n/// `parameters.wage() != 0`, but `earn` mints against whatever `oracle()` answers, and the governed\n/// replacement path (`Parameters.proposeWorkOracle`, applicable only while the wage is zero) installs\n/// an oracle whose rights need not come from the wage at all. Minting from work is then ON with the\n/// D1 lag OFF, and the adjacent-transaction round trip the launch audit closed (borrow -> earn ->\n/// unwind) leaves work-minted imdUSD with nothing behind it. This test fails on the committed code\n/// and passes once the lag applies whenever work can be minted (e.g. `_lagApplies` returns true, or\n/// also when `parameters.workOracle() != address(0)`).\ncontract ReplacementOracleLagOffTest is Test {\n    address private constant ATTACKER = address(0xBAD);\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\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 RAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        RFeed primary = new RFeed(uint256(1 ether) * 1e18 / 2000 ether); // $1 per IMD\n        RFeed health = new RFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new RMirror(primary))\n        );\n        stable = vault.stablecoin();\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(ATTACKER, 2_000 ether);\n        vm.prank(ATTACKER);\n        imd.approve(address(vault), type(uint256).max);\n\n        // Governance installs the successor the documented way: wage still 0, 48-hour timelock.\n        Parameters params = vault.parameters();\n        TariffOracle successor = new TariffOracle(address(vault), address(vault.oracle()));\n        vm.prank(APPROVED_OPERATOR);\n        params.proposeWorkOracle(address(successor));\n        vm.warp(block.timestamp + params.TIMELOCK());\n        params.applyPending();\n        assertEq(address(vault.oracle()), address(successor));\n        assertEq(params.wage(), 0, \"the wage never moved\");\n        successor.credit(ATTACKER, 1_000 ether);\n    }\n\n    /// @dev Each top-level call is its own transaction (foundry.toml isolate), so transient storage\n    /// clears between them, exactly as on chain.\n    function test_workMintedSupplyNeverOutlivesTheDebtThatAuthorisedIt() public {\n        vm.startPrank(ATTACKER);\n        vault.lock(2_000 ether);\n        vault.draw(1_000 ether);\n        vm.roll(block.number + 1);\n        vm.warp(block.timestamp + 12);\n        // One block later. With the lag in force the ceiling credits about 0.014% of the new debt\n        // (about 0.14 imdUSD); without it the whole 1,000 counts and 250 imdUSD of work is mintable.\n        uint256 ceiling = vault.earnLine();\n        assertLe(ceiling, 1 ether, \"a block-old debt must not authorise a quarter of itself in work\");\n        vault.earn(ceiling == 0 ? 1 : ceiling);\n        vm.roll(block.number + 1);\n        vm.warp(block.timestamp + 12);\n        vault.wipe(vault.debtOf(ATTACKER));\n        vault.free(2_000 ether);\n        vm.stopPrank();\n        assertEq(vault.totalDebt(), 0);\n        assertEq(imd.balanceOf(address(vault)), 0, \"no collateral left\");\n        assertLe(stable.totalSupply(), 1 ether, \"work-minted supply with nothing behind it\");\n    }\n}","reproduction":"test/scratch/ReplacementOracleLagOff.t.sol (FAILS on this code; passes with _lagApplies() returning true). ParameterizedVault over 18-decimal IMD at $1, NHI 0.85, wage 0 throughout. Governance proposes a TariffOracle (answers vault() == vault, mintingRights, predecessor; not hostile) through parameters.proposeWorkOracle, waits the 48-hour timelock, applyPending(); vault.oracle() is now the successor and parameters.wage() is still 0. The successor credits ATTACKER 1,000 of rights. Transaction 1: lock(2,000), draw(1,000). One block later: expected (the D1 design, as with any nonzero wage) earnLine() about 0.14 imdUSD (1,000 x 2500/10000 x 12 s / 1 day); actual earnLine() == 250e18. Transaction 2: earn(250e18) succeeds. Transaction 3: wipe(debtOf) and free(2,000): totalDebt 0, the vault holds no collateral, totalSupply() == 250e18 of work-minted imdUSD with nothing behind it (backingPerUnit() 0).","severity":"medium","snippet":"        return parameters.wage() != 0;","title":"The lagged work ceiling is gated on wage != 0, but earn mints against whatever oracle() answers; a governed replacement oracle (the documented path for pay-per-job-type minting) mints at wage 0 with t"},{"citation":"resolved","description":"The fix for docs/AUDIT-FINAL-2-2026-10-07.md finding 4 raises _coverDust (lines 581-587) to the seizure for max(debt / 1e6, min(debt / 100, 1 imdUSD)), so re-locking enough to block cover now costs collateral worth about 1.2 imdUSD (1.3823e22 raw sIMD, 0.0138 sIMD, at the launch collateral price of 86,814,000,000,000 per 1e18 raw). The fix holds as stated: the collateral is real and ends in the surplus account. What it does not change is who drives the cycle. Collateral at or above the floor is 'collateral a bite could still reach' only through mark, grace and an exactly sized bite (bite, lines 948-959: seizure for debtToRepay must fit, the remainder is swept only below the one-wei seizure of 13,822 raw), and a bite of 1.2 imdUSD of collateral pays the liquidator 1.2 - 1.0 = 0.20 imdUSD before gas, minus the chip and cut if it did not mark: on mainnet a bark plus a bite cost far more in gas than that, so no third party will ever take it. Until the protocol's own keeper does, the drained position's record stays at its value, Treasury imdUSD equal to it stays behind BadDebtFirst (Treasury.withdraw, payStream), totalBadDebt keeps subtracting from backedDebt and from the mat cap in _securedCollateralValue, and fees accrue on the record that cover cannot retire. So the residual is: one lock of about $1.20 per cycle for the griefer against bark + 6 hours + bite in gas for the operator, repeatable; the NatSpec at lines 576-580 ('blocking cover now costs collateral worth about 1.2 imdUSD every cycle') is true only if the operator pays for every cycle. Reachable with the constants as committed (NHI >= 0.85, lull 6 h, PRICE_MAX_AGE 1 h for the window). Griefing only: no funds move to the borrower. Smallest fix, as the second-half review already offered: let bite skip mark and grace for a position whose _recordedBadDebt is nonzero (it has been drained once and the grace exists for a borrower who can recover), so the keeper's cost per cycle is one bite; or let cover sweep any collateral on such a position whose value at `price` is below its recorded bad debt, which makes the re-lock a straight donation.","line":533,"path":"src/CDPVault.sol","reproduction":"ParameterizedVault over sIMD (24 decimals, 7.95e12 raw IMD per 1e18 raw share), IMD/ETH 0.00546e18, ETH/USD 2000e8 (collateral price 86,814,000,000,000 per 1e18 raw), NHI 0.85. BORROWER drained by a crash, mark and bite: collateral 0, _recordedBadDebt R (say 1,850 imdUSD), Treasury holds R imdUSD. After the mark expires (6 h + 1 h), BORROWER lock(13,822,655,332,089,294,353,446) raw (one unit above _coverDust at this price, 0.0138 sIMD, about $1.20). Expected per lines 576-580: blocking cover costs that collateral every cycle. Actual: cover(BORROWER, R) reverts NoRealizedBadDebt; bite(BORROWER, x) reverts MarkExpired (the drain's mark is past its tail); after bark(BORROWER) it reverts GracePeriodNotElapsed for 6 hours; only bite(BORROWER, 1.0e18) (debtToRepay sized so the seizure 1.3823e22 fits the collateral) sweeps it, paying the liquidator 1.2 imdUSD of sIMD for 1.0 imdUSD burned, and cover then succeeds; nobody but the operator's keeper will pay two transactions of mainnet gas for 0.20 imdUSD, and BORROWER re-locks after each one.","severity":"low","snippet":"            if (position.collateral >= _coverDust(owner, price)) revert NoRealizedBadDebt();","title":"cover's dust floor (1.2 imdUSD) is paid per bite, not per cycle: a drained borrower's one-time re-lock of about $1.20 of sIMD keeps the record uncoverable until the operator's keeper barks, waits six "},{"citation":"resolved","description":"cover's @dev (lines 510-513) says it only cancels debt behind 'a drained position, or one holding dust worth under a millionth of its debt (at least the seizure for one wei)'. Since b73a05f _coverDust (lines 581-587) sweeps anything below the seizure for max(debt / 1e6, min(debt / 100, 1e18)): for a 100 imdUSD debt that is collateral worth up to 1.2 imdUSD, 1.2% of the debt, not a millionth, and for a 50 imdUSD debt up to 0.6 imdUSD. The inline comment at line 526 and _coverDust's own NatSpec are correct; the function-level NatSpec is the one a reader and the ABI docs quote. Fix: restate as 'worth under about 1.2 imdUSD (a hundredth of a debt under 100 imdUSD, a millionth of a debt over a million)'.","line":512,"path":"src/CDPVault.sol","reproduction":"Position with debt 100e18 and collateral 1.3e22 raw at price 86,814,000,000,000 (worth 1.13 imdUSD, 1.13% of the debt). Expected per the NatSpec: cover reverts NoRealizedBadDebt (the collateral is far above a millionth of the debt). Actual: _coverDust == 1.3823e22 > 1.3e22, so cover sweeps it to the Treasury and retires the debt.","severity":"info","snippet":"    /// holding dust worth under a millionth of its debt (at least the seizure for one wei), which is","title":"cover's NatSpec still describes the pre-b73a05f dust rule (under a millionth of the debt, at least the one-wei seizure); the code sweeps up to the seizure for one imdUSD"},{"citation":"resolved","description":"ParameterizedVault.earnLine's @dev (lines 262-266) says backing exceeds one 'exactly when the ratio is below mat - 1, and Parameters caps the ratio at half that cliff'; DeploymentConfig.sol:146-147 says the cliff 'is 5000 at the loosest NHI. 2500 is half that cliff, 120% worst-case backing with an empty reserve'. With the code as committed, mat is 170 at the loosest NHI (CDPVault._mat, line 1254), so mat - 1 is 0.70 = 7000 bps, which is what Parameters.sol:82-83 says ('7000 is the cliff ... at 2500 it is 136%'); MAX_EARN_MAT_BPS = 2500 is 5/14 of it, and with an empty reserve, debt D fully drawn at mat 170 and E = 0.25 D minted, backing is 1.7 D / 1.25 D = 1.36. The adversarial review of 2026-10-05 listed this NatSpec (item 4) and it was not corrected. No behaviour depends on it; a reader sizing a proposeEarnMat from these two comments would believe the cap sits at half the cliff with 20% headroom when it sits at 36% of it with 36% headroom. Fix: '7000 bps at mat 170; 2500 is about a third of it, 136% worst-case backing', in both files.","line":266,"path":"src/ParameterizedVault.sol","reproduction":"Compute: mat() at NHI 0.85 == 170; mat - 1 == 70% == 7000 bps; Parameters.MAX_EARN_MAT_BPS() == 2500; 2500 / 7000 == 0.357, not 0.5; worst case (reserve 0, collateral 1.7 D, supply 1.25 D) backingPerUnit == 1.36e18 before the par cap, not 1.20e18.","severity":"info","snippet":"    /// Parameters caps the ratio at half that cliff.","title":"earnLine's NatSpec and DeploymentConfig misstate the work-ceiling cliff: at mat 170 the cliff is 7000 bps (not 5000), 2500 is five-fourteenths of it (not half), and worst-case backing with an empty re"},{"citation":"resolved","description":"Lines 24-29 say the governor 'can change what the numbers are, never where the price comes from, where the revenue goes, or which contract governs'. That is true of collateral pricing (the three feeds, usdPriceFeed and collateralPriceFeed are immutables). It is not true of the two other price-bearing inputs the vault reads: Parameters.proposeReserveAsset lets the governor list any token against any ISwarmFeed-shaped source (Treasury.validateReserveAsset pins only the vault's own collateral to collateralPriceFeed), and that source's value feeds earnLine (line 268) and the reserve term of _backingPerUnit through _redemptionReserveBacking (line 165); and Parameters.proposeWorkOracle replaces the oracle earn mints against. Both are governed behind the 48-hour timelock and are trust assumptions of the role, which the launch audit's vault panel (finding 7) asked to be stated here rather than denied. Fix: 'never where the collateral price comes from ...; reserve price sources and the work oracle are governed behind the same delay'.","line":27,"path":"src/ParameterizedVault.sol","reproduction":"APPROVED_OPERATOR calls parameters.proposeReserveAsset(token, anyFeed, 10000) for a token the Treasury holds; 48 hours later anyone applies it; vault.reserveValue() and earnLine() now read anyFeed. Expected per the header: no governance action can change where a price the vault uses comes from. Actual: the reserve term's price source changed by a proposal.","severity":"info","snippet":"/// are, never where the price comes from, where the revenue goes, or which contract governs.","title":"ParameterizedVault's header says the governor can change 'never where the price comes from'; the governor chooses every reserve asset's price source and the work oracle"}],"hash":"84d4d922e82645724a8a5909d1f44e141eeeeff1ebe0c1fa615abb24aafd3fae","nodeId":"898f8fb6-6be0-417c-a0b7-50f25a714dee","outcome":"completed","summary":"Review complete. The findings file holds six entries (2 medium with proofs, 1 low, 3 info); source files are unchanged and the committed suite is green (556 passed, 4 skipped).\n\n## Findings\n\n| # | Severity | Where | Finding |\n|---|---|---|---|\n| 1 | medium | `src/CDPVault.sol:849` (`_clampLag`) | A borrower's own atomic wipe-then-draw (or free-then-lock) clamps `laggedDebt`/`laggedSecured` down and the re-add warms up over a day, so `backingPerUnit()` falls although nothing left the system. With work-minted supply outstanding (wage on) a borrower above about 64% of the debt drives it to 0 and every `cash` reverts `ZeroAmount` for a day; at launch (wage 0) a healthy borrower in a stressed system shaves up to 0.3 × its principal off redeemers' payout (1.0 → 0.90 reproduced). Proof `test/scratch/LagChurn.t.sol` fails here and passes under a per-position cooling fix I trialled (committed `LaggedBacking` tests still pass under it). |\n| 2 | medium | `src/ParameterizedVault.sol:112` (`_lagApplies`) | The lagged work ceiling keys on `wage != 0`, but `earn` mints against whatever `oracle()` answers. The governed replacement path, which is only open while the wage is zero and is the documented route to pay-per-job-type minting, installs an oracle that mints with the D1 lag off, reopening borrow → earn → unwind (250 imdUSD left with no debt or collateral in the proof). Needs a governance action, so not reachable with the constants as committed. Proof `test/scratch/ReplacementOracleLagOff.t.sol` fails here and passes with the gate removed. |\n| 3 | low | `src/CDPVault.sol:533` (`cover`) | The second-half review's finding 4 is fixed as stated: blocking cover now costs about $1.20 of sIMD (1.3823e22 raw). But the cost is paid per bite, and the bite that clears it nets the liquidator 0.16–0.20 imdUSD before gas, so only the operator's keeper will run the bark, six-hour grace and bite each cycle. The residual is bounded by the defender's gas, not the griefer's capital. |\n| 4 | info | `src/CDPVault.sol:512` | `cover`'s NatSpec still states the pre-fix dust rule (a millionth, one-wei seizure). |\n| 5 | info | `src/ParameterizedVault.sol:266` and `DeploymentConfig.sol:146` | Cliff arithmetic wrong: 7000 bps not 5000, 2500 is 5/14 not half, worst-case backing 136% not 120%. |\n| 6 | info | `src/ParameterizedVault.sol:27` | \"never where the price comes from\" is false for reserve price sources and the work oracle, which are governed. |\n\n## Answers to the numbered questions\n\n1. **Positions.** `lock`, `lockIMD`, `free`, `draw`, `wipe` are all `nonReentrant`; every health check uses `debtOf` (principal plus accrued fees) against `mat()`; `draw` accrues before adding principal so a new borrower's `chiOf` is set before any principal exists. `lockIMD` credits the share balance delta after resetting the allowance, so a mis-reporting share vault cannot over-credit. No path moves funds for anyone but the caller except the designed ones (`bite`, `cash`, `cover`). Nothing wrong found.\n2. **Liquidation.** The three transfers in `bite` sum to exactly `collateralSeized`; the marker and protocol cuts come out of the bonus; the dust path and the remainder sweep make every position drainable. Marks snapshot `lull()` and cannot be shortened, extended or re-taken while live. The only grace \"gaming\" is the accepted self-mark (18% effective penalty). No freeze found other than the cover residual (finding 3).\n3. **cover.** A healthy position can never be swept: the floor is the seizure for at most a hundredth of the debt (1.2% in collateral) against a 170% requirement. Coverage cannot exceed `debtOf` (`ExcessRepayment`), and `totalBadDebt` always equals the sum of the per-position records (`_recordBadDebt` and `_reduceDebt` are the only writers and move both together). The fix for finding 4 holds as stated; its residual is finding 3.\n4. **Redemption.** Fee base, fresh-debt record and candidate guard are consistent; the ratio guard uses exact fractions and cannot be worsene","treeHash":null,"usage":{"cachedInputTokens":9381024,"inputTokens":994,"model":"claude-fable-5-1","outputTokens":127912,"runtime":"claude","turns":67,"wallClockMs":1953472}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"52c98c0dc01791cd","findings":[{"citation":"resolved","description":"Q3 (break the dust-floor fix). The fix in b73a05f holds for what it set out to do: re-locking enough collateral to sit at or above _coverDust now costs about 1.20 imdUSD of sIMD per mark-and-grace cycle (confirmed: coverDust = 13,824,884,792,626,887,383,465 raw units, worth 1,199,999,999,999,999,999 at the 86,813,999,999,999 collateral price in test/scratch/Explore.t.sol test_dustFloorCost). What remains is the branch the floor sits in. cover(owner, amount) takes two paths: with position.collateral == 0 it burns the Treasury's imdUSD with no feed read at all; with any nonzero collateral it enters the dust path, and the first thing that path does (lines 530-531) is _requireFreshFeeds() and _requirePriceAgreement(), i.e. the primary IMD/ETH feed, the spot feed, the NHI feed and the Chainlink ETH/USD leg must all be fresh and primary/spot within SKEW_BPS. A drained borrower who calls lock(1) puts one raw unit of sIMD (1e-24 sIMD, about 1e-19 USD) on the position. That unit is far below _coverDust and is swept the moment cover runs, so it no longer blocks cover; but it moves cover onto the gated path. The shipped price feeds are stale between bought attestations by design (PRICE_MAX_AGE = 1 hour, no keep-alive for price: DeploymentConfig.sol lines 32-38, docs/PARAMETERS-2026-10-05.md 'between updates, price-dependent actions pause'), so whoever covers must first buy a primary and a spot attestation (0.5 IMD each through OracleAsker.askPaid, about $11 at $10.92/IMD) or wait for a keeper's. The borrower repeats lock(1) after each cover for one lock's gas; the coverer pays two attestations per cover. It is bounded: one cover can retire the whole record if the Treasury holds enough imdUSD, and the Treasury itself buys no price refresh for a quiet feed (DRIFT_RISE_TRIGGER_OF_CAP_BPS = 0, falls only), so the cost lands on whoever runs cover. No funds move to the borrower; the bad debt keeps accruing fees behind BadDebtFirst while cover waits for feeds. Reachable with the constants as committed. Smallest fix: make the dust decision independent of feed freshness for collateral that no price could make reachable, e.g. in cover, if position.collateral < _oneWeiSeizure(_priceOrZero()) (or any absolute raw threshold such as 1e6 raw units, about 1e-13 USD at launch prices) sweep it to the surplus account without the two guards and fall through to the no-feed path; keep the guards for anything larger, where the stale price could matter. Alternatively evaluate _coverDust against _priceOrZero() when the feeds are stale and the position's record is nonzero, since the sweep only ever moves collateral to the Treasury, never to the caller.","line":530,"path":"src/CDPVault.sol","reproduction":"test/scratch/CoverDustPriceGated.t.sol (PASSES on the committed code: it demonstrates the state). ParameterizedVault over a 24-decimal share at 7.95e12 raw IMD per 1e18 raw share, IMD $10.92, ETH $2,699 (collateral price 86,813,999,999,999 per 1e18 raw). BORROWER locks 1000e24 and draws 51,000e18 (CR 170); KEEPER locks 10,000e24 and draws 200,000e18. IMD halves; bark; +6h; bite(BORROWER, floor(collateral x price / 1.2e18)) drains BORROWER (totalBadDebt about 14,834e18); price back; mark expires; KEEPER funds the Treasury with the bad debt. Primary feed set stale (its normal state between purchases). cover(BORROWER, 1e18) succeeds with no fresh feed. BORROWER: lock(1). Expected (the no-feed path, since one raw unit backs nothing a bite could reach): cover(BORROWER, 1e18) succeeds. Actual: reverts CDPVault.StaleFeed (selector 0xa0cd3bb2). With the feed fresh again cover sweeps the unit and lands; BORROWER lock(1) again and the next cover reverts StaleFeed again. The borrower's cost per repetition is one lock transaction; the coverer's is whatever fresh primary and spot attestations cost.","severity":"low","snippet":"            _requireFreshFeeds();\n            _requirePriceAgreement();","title":"cover's dust path is price-gated, so one raw unit of sIMD re-locked on a drained position (gas only, ~1e-19 USD) makes every later cover require fresh, agreeing primary, spot, NHI and Chainlink legs; "},{"citation":"resolved","description":"NatSpec claim the code does not have. _coverDust (lines 581-587) sweeps collateral below the seizure for max(debt / 1_000_000, min(debt / 100, 1e18)), which at launch prices is collateral worth about 1.20 imdUSD for any debt from 100 imdUSD up to 1,000,000 imdUSD. The function's own @dev at line 512 still states the superseded bound ('at least the seizure for one wei'), while the inline comment at lines 526-527 and the _coverDust NatSpec at 574-580 state the current one. A reader of the public function's documentation is told cover sweeps only sub-wei dust; it sweeps up to 1.20 imdUSD of collateral (to the surplus account). Not a code defect. Fix: reword line 512 to 'at least the seizure for one imdUSD of it, or a hundredth of the debt if that is less'.","line":512,"path":"src/CDPVault.sol","reproduction":"Read src/CDPVault.sol:512 against src/CDPVault.sol:581-587. Concrete: debt 14,834e18, price 86,813,999,999,999: the one-wei seizure is 13,822 raw units (about 1.2e-15 USD) but _coverDust returns 13,824,884,792,626,887,383,465 raw units (about 1.20 USD), so a position holding, say, 1e22 raw units (about $0.87) is swept by cover although the NatSpec says only dust below the one-wei seizure is.","severity":"info","snippet":"    /// holding dust worth under a millionth of its debt (at least the seizure for one wei), which is","title":"cover's NatSpec still describes the dust floor as 'at least the seizure for one wei'; since cc4103f/b73a05f the floor is the seizure for the larger of a millionth of the debt and one imdUSD (a hundred"},{"citation":"resolved","description":"Comment claiming a property the code does not have (vault panel 2026-10-05 finding 15 corrected Parameters.sol:82-83 to '7000 ... at 2500 it is 136%', but the same derivation in DeploymentConfig.sol was left at the pre-170 numbers). CDPVault._mat returns 170 at NHI >= 0.85, so mat - 1 = 0.70 = 7000 bps, and with an empty reserve and the ratio term at 2500 every imdUSD of debt is backed by 1.70 of collateral while supply is 1.25 per debt: 1.70 / 1.25 = 136%, not 120% (which is 1.50 / 1.25, the old mat of 150). The two source files now state different safety margins for the same constant. Fix: change '5000' to '7000' and '120%' to '136%' at lines 146-147, or point the comment at Parameters.MAX_EARN_MAT_BPS.","line":146,"path":"src/DeploymentConfig.sol","reproduction":"Compute with the committed constants: CDPVault._mat(0.85e18) == 170 (src/CDPVault.sol:1254); EARN_MAT_BPS == 2500; backing with empty reserve = mat/100 / (1 + EARN_MAT_BPS/10000) = 1.70 / 1.25 = 1.36. Expected per the comment: 1.20 and a 5000 bps cliff. Actual: 1.36 and a 7000 bps cliff, matching src/Parameters.sol:82-83.","severity":"info","snippet":"/// exactly when this ratio is below mat - 1, which is 5000 at the loosest NHI. 2500 is half that\n/// cliff, 120% worst-case backing with an empty reserve. Parameters refuses any proposal above","title":"EARN_MAT_BPS comment gives the backing cliff as 5000 bps and the worst case as 120%; with the shipped mat floor of 170 the cliff is 7000 and the figure 136%, as Parameters.MAX_EARN_MAT_BPS already say"},{"citation":"resolved","description":"NatSpec claim the code does not have, carried over from the vault panel's finding 7 (2026-10-05), whose code half was fixed (Treasury.validateReserveAsset lines 155-161 now pin the collateral token to collateralPriceFeed) and whose documentation half was not. Parameters.proposeReserveAsset(asset, priceFeed, haircutBps) lets the governor name any ISwarmFeed as the USD source for any non-collateral reserve asset after the 48-hour delay; that source is read by Treasury.reserveValueUsd, which is the first term of ParameterizedVault.earnLine (the work ceiling) and of _redemptionReserveBacking (the reserve term of backingPerUnit, which sets every redemption payout). So a governed choice of price source does reach the vault's economics. This is the intended power of the role, visible for two days and bounded by MAX_RESERVE_VALUE per asset, not a bypass; the header should say so rather than deny it. Fix: 'never where the collateral price comes from (reserve assets other than the collateral are priced by the source governance lists for them), where the revenue goes, or which contract governs.'","line":27,"path":"src/ParameterizedVault.sol","reproduction":"APPROVED_OPERATOR: parameters.proposeReserveAsset(token, anyFeed, 10000) for a non-collateral token the Treasury holds; after TIMELOCK anyone calls applyPending(). Expected per the header: no governed path changes where a price the vault reads comes from. Actual: Treasury.reserveAsset(token).priceFeed == anyFeed, and vault.reserveValue(), vault.earnLine() and vault.backingPerUnit() all move with anyFeed.latestValue() (test/ReserveValuation.t.sol and test/scratch/Mislist.t.sol of the 2026-10-05 panel exercise the same path).","severity":"info","snippet":"/// are, never where the price comes from, where the revenue goes, or which contract governs.","title":"ParameterizedVault header: 'the governor can change what the numbers are, never where the price comes from' is true of collateral pricing but not of reserve pricing, which Parameters.proposeReserveAss"}],"hash":"884c3e9ea10a62bdcebbe0031f222ec1d6758351d4f7de094f0994594e96f412","nodeId":"d4fe5288-b041-4b7b-bb77-8cefbd510b30","outcome":"completed","summary":"Review complete. The findings file `.imd-findings.json` holds four substantiated findings (one low, three info); no source, config or doc file was changed, and the only additions are the findings file and three scratch tests, both paths excluded from git by the repository's own `.git/info/exclude`.\n\n## Result\n\nNo critical, high or medium defect in `CDPVault`, `ParameterizedVault` or `ImdUSD` at b73a05f. The three earlier rounds' fixes hold, including the dust floor this panel was asked to break. The residuals are one cheap griefing path and three stale NatSpec claims.\n\n| # | Severity | Where | Finding |\n|---|---|---|---|\n| 1 | low | `src/CDPVault.sol:530` | cover's dust path is price-gated. One raw unit of sIMD re-locked on a drained position (gas only) makes every later cover need fresh, agreeing primary, spot, NHI and Chainlink legs. With the price feeds stale by design between purchases, that is two bought attestations per cover. |\n| 2 | info | `src/CDPVault.sol:512` | cover's NatSpec still says the floor is \"the seizure for one wei\"; it is now one imdUSD or a hundredth of the debt. |\n| 3 | info | `src/DeploymentConfig.sol:146` | EARN_MAT_BPS comment gives a 5000 bps cliff and 120% worst case; with mat 170 it is 7000 and 136%, as Parameters already says. |\n| 4 | info | `src/ParameterizedVault.sol:27` | \"never where the price comes from\" is false for reserve pricing, which governance sets per listed asset. The code half of the earlier finding was fixed, the wording was not. |\n\n## Answers to the numbered questions\n\n**1. Positions.** Nothing wrong. Every path checks the ratio against mat with the accrued debt, rounding the ratio down and mat up. Credits are by verified balance delta in both lock paths. All state-changing entry points are nonReentrant and funds move only for the caller, the Treasury in cover, or by formula in bite and cash.\n\n**2. Liquidation.** No one receives more than the formula: the marker and protocol shares split the 20% bonus, and the swept remainder is below one wei's seizure. No freezable state: across 1000 fuzzed crash scenarios a max bite plus at most one 1‑wei bite always drained the position, and anything left is below cover's floor. The grace snapshot can only be taken while unhealthy, and an active mark cannot be replaced or reset.\n\n**3. Cover.** The fix holds. Blocking cover now costs collateral worth 1.20 imdUSD per mark-and-grace cycle, and the swept dust goes to the Treasury. Coverage is bounded by the position's accrued debt, and totalBadDebt moves with the per-position record in every path. The residual is finding 1.\n\n**4. Redemption.** The fee is charged on the whole burn at the post-increase rate and the base nets out same-transaction mints. A candidate's ratio can only improve, and the backing cap with lagged capital held in every scenario I tried. Chunked redemptions paying less remains the accepted low from the vault panel.\n\n**5. Bad debt and the Treasury.** No sequence spends what bad debt needs. Withdraw and payStream both floor on totalBadDebt, and cover retires fees first and remints them, so the Treasury over-reserves rather than under.\n\n**6. Stability fee.** A rate change cannot reprice elapsed time: Parameters drips before writing the new set, the index is linear from its checkpoint, and chiOf can never exceed chi.\n\n**7. Price gating.** Every value-moving action is refused on stale or divergent feeds. lock, wipe and debt-free free are safe without a price. A feed that cannot follow a gap halts borrowing, withdrawal with debt, marking, biting and redemption for the hours the allowance takes to widen, and a mark whose tail passes inside that halt must be retaken with a fresh grace. That is the documented design.\n\n**8. Work issuance.** Nothing mints at wage zero: the attested oracle refuses claims while the wage is zero, so no rights exist. Same-transaction debt is excluded by the transient start-of-transaction record and by the lag, and a reserve listing adds only capital the oper","treeHash":null,"usage":{"cachedInputTokens":4955968,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":108550,"runtime":"claude","turns":48,"wallClockMs":1580033}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"6b37e4ab65246705","findings":[{"citation":"resolved","description":"Q8. The lagged capital that closes audit finding D1 (CDPVault.laggedNow, BACKING_WARMUP) is applied to the work ceiling only while `parameters.wage() != 0` (`_lagApplies`), and the NatSpec at lines 109-110 says the lag 'applies exactly while minting from work is on'. But whether minting from work is on is decided by the ORACLE `earn` reads, not by the wage: `Parameters.proposeWorkOracle` (allowed only while the wage is zero, and documented as the path for integrating upstream work minting without a new vault) installs any contract that answers `vault()` with this vault and `mintingRights(address)`. A replacement whose rights do not derive from `wage()` (the shipped MockWorkOracle, or a future successor that prices tasks itself) grants rights while the wage stays zero, so `earn` mints, `earnLine()` is finite and enforced, and `backedDebt()` is NOT lagged. The same-transaction exclusion (`_debtAtTransactionStart`) still holds, so the adjacent-transaction sequence D1 describes is open again: tx1 lock + draw, tx2 earn 25% of that debt (backedDebt counts it in full), tx3 wipe + free. The work-minted imdUSD outlives the debt that authorised it and has no backing behind it (reserve empty at launch). Actor and preconditions: the governor (APPROVED_OPERATOR) must propose and, after 48 hours, anyone apply a replacement oracle; the operator of that oracle must grant rights to the attacker (for a MockWorkOracle, the same operator). So this sits inside the governance trust the design states ('a governor who could mint through a hostile oracle can already raise the wage'), but raising the wage switches the lag ON while installing an oracle does not, so the two paths are not equivalent: the documented successor path for upstream integration runs without the D1 defence by default. Not reachable with the constants as committed without a governance action. Smallest fix: make the lag unconditional (`return true;` — it only ever tightens a ceiling that is irrelevant while nothing can earn), or at least `return parameters.wage() != 0 || parameters.workOracle() != address(0);`, and correct the NatSpec.","line":112,"path":"src/ParameterizedVault.sol","reproduction":"test/scratch/ReplacementOracleNoLag.t.sol (PASSES: it demonstrates the state; `isolate = true` makes each call its own transaction). WorkBackingFixture (price $1 per 1e18 raw, NHI 0.85, empty reserve). Governor proposes `new MockWorkOracle(address(backedVault))` via parameters.proposeWorkOracle, applied after 48h; operator grants WORKER rights; parameters.wage() == 0. tx1: WORKER locks 200,000e18 and draws 100,000e18. tx2 (same block): backedVault.earnLine() == 25,000e18 (expected under the D1 design: 0, nothing has warmed up) and earn(25,000e18) succeeds. tx3: wipe(100,000e18), free(200,000e18): totalDebt == 0, earnLine() == 0, WORKER holds 25,000e18 work-minted imdUSD. Control test in the same file: with proposeWage(1) applied first, the same lock + draw gives earnLine() == 0 and the earn is refused.","severity":"low","snippet":"        return parameters.wage() != 0;","title":"ParameterizedVault._lagApplies keys the D1 work-ceiling lag to the wage, so a governance-installed replacement work oracle mints against un-warmed debt and a borrow / earn / repay-and-withdraw round t"},{"citation":"resolved","description":"Q3, break-the-fix. The fix in b73a05f (_coverDust = seizure for max(debt / 1e6, min(debt / 100, 1 imdUSD))) holds: I found no way to keep bad debt uncoverable for free. The residual cost and the accounting of it are: a drained borrower (record R >= 100 imdUSD) re-locks collateral worth >= 1.20 imdUSD (at the sIMD scale, 1.2e18 * 1e18 / price raw units; about 1.38e22 raw = 0.0138 sIMD at $8.68e-5 per 1e18 raw) and cover reverts NoRealizedBadDebt. The only route to that collateral is bite after a fresh mark and the full grace (six hours at NHI >= 0.85), sized to debtToRepay = floor(C * price / 1.2e18) (about 1.00 imdUSD); the liquidator then receives C less the protocol cut (10% of the 20% bonus, $0.02) and the marker cut, the position is drained again (any remainder is below the one-wei seizure and is swept by the bite, or by cover), and cover can proceed. A defender who bundles bite + cover in one transaction cannot be interposed; the griefer must front-run each bundle with another $1.20 lock, so the griefing cost is $1.20 per defender attempt (the defender's attempt nets +$0.18 of collateral minus gas), or, if the griefer self-marks and self-bites, about $1.02 in assets plus $1.00 of its own bad debt retired per six-hour cycle. The harm while blocked is unchanged from the earlier finding: the Treasury's imdUSD equal to the record stays behind BadDebtFirst (Treasury.withdraw, payStream). Three NatSpec statements do not match the code: (1) line 580: the collateral that blocks cover (at or above the floor) never reaches the surplus account; a bite pays it to the liquidator and only the protocol cut (2% of the debt repaid, about $0.02) reaches the Treasury; only collateral BELOW the floor is swept there. (2) line 512: 'dust worth under a millionth of its debt (at least the seizure for one wei)' predates the fix; the floor is now at least the seizure for one imdUSD of debt (a hundredth of a debt under 100 imdUSD). (3) line 278: totalBadDebt is 'reduced only by repaying the position's debt (wipe, or cover ...)'; `cash` against a drained-then-relocked candidate reduces it too, through the same _reduceDebt branch. No code change is needed for the economics (the cost is the deliberate choice); the three comments should be corrected.","line":580,"path":"src/CDPVault.sol","reproduction":"test/scratch/CoverCost.t.sol (PASSES: it demonstrates the state). WorkBackingFixture with test/Cover.t.sol's drain (BORROWER 170e18 collateral / 100e18 debt crashed to $0.50, marked, bitten for 70.83e18: record 29.169707762557077567e18), price then set to 1e12 per 1e18 raw (the sIMD scale). _coverDust for that debt is the seizure for a hundredth of it: 350,036,493,150,684,930,000,000 raw units, worth $0.350036. BORROWER locks exactly that many raw units: cover(BORROWER, 1) reverts NoRealizedBadDebt (expected per the fix). After the old mark expires, bark(BORROWER), six hours, bite(BORROWER, 291,697,077,625,570,775) (the exact size: locked * price / 1.2e18): the liquidator burns $0.2917 of imdUSD and receives 338,368,610,045,662,099,000,000 raw ($0.3384); the Treasury receives 5,833,941,552,511,415,500,000 raw ($0.0058, the protocol cut) and the marker the same; positions(BORROWER).collateral == 0 and cover(BORROWER, debtOf) then retires the record. Expected per line 580: the $0.35 'goes to the surplus account'. Actual: $0.0058 does; the rest goes to the liquidator. For a record of 100 imdUSD or more the same cycle costs the griefer $1.20 per attempt instead of $0.35.","severity":"info","snippet":"    /// cover now costs collateral worth about 1.2 imdUSD every cycle, which goes to the surplus account.","title":"cover's dust floor holds, but blocking it costs about $1.02-$1.20 per defender attempt and the collateral that blocks it goes to the liquidator through bite, not 'to the surplus account' as the NatSpe"}],"hash":"d189067298faf54514fdae265363c31070f65870db62b5e41c141ccba9e49ea9","nodeId":"559b418b-095c-4cd4-b731-e38bea718a97","outcome":"completed","summary":"The review is complete. The findings file holds two entries, and no tracked file was changed (only `.imd-findings.json` and four scratch tests under `test/scratch/`).\n\n## Result\n\nThe vault's arithmetic is sound. I found one low (a defence keyed to the wrong signal, reachable only through governance) and one info (three stale or inaccurate comments around cover, with the fix's residual cost quantified). The committed suite passes 556/0 here. The ratio and liquidation arithmetic were fuzzed against 512-bit references at the sIMD price scale with no deviation beyond the documented one-unit floor.\n\n| # | Severity | Where | Finding |\n|---|---|---|---|\n| 1 | low | `src/ParameterizedVault.sol:112` | The D1 lag on the work ceiling applies only while `wage != 0`. A governance-installed replacement oracle grants rights with the wage at zero, so borrow, earn 25%, repay and withdraw across three transactions leaves work-minted imdUSD with nothing behind it. Governor-gated; fix is `return true` or also checking `workOracle()`. |\n| 2 | info | `src/CDPVault.sol:580` | The cover dust floor holds. Blocking it costs $0.35 at the fixture's 29 imdUSD record, $1.20 at records of 100 imdUSD and up, per defender attempt. The blocking collateral goes to the liquidator through bite, not \"to the surplus account\" as the NatSpec claims. |\n\n## Answers\n\n1. **Positions.** Every path recomputes the position's secured term and checkpoints the lag. `lock` checks the balance delta, `lockIMD` credits measured shares, `free` with debt requires fresh agreeing feeds and health on the remainder, `draw` checks health with accrued fees and the ceiling, `wipe` burns only the caller's tokens. Nothing moves anyone else's funds. All five are `nonReentrant`, and the only external calls into user-controlled code are standard token transfers. No double counting found.\n2. **Liquidation.** Payout is floored and the two cuts come out of the bonus, so nobody receives more than the formula plus the sub-one-wei remainder sweep. The largest coverable bite always leaves a remainder that the sweep or one more one-wei bite reaches (fuzzed, 10,000 runs at and around the sIMD scale). Grace is snapshotted at mark time, bounded by grace plus tail, and clearable only by health. I found no gaming beyond the documented self-mark chip recovery.\n3. **Cover.** Sweeps only below the floor; coverage above the debt reverts; the record and `totalBadDebt` move together in every path. The fix for the second-half residual holds. The numbers are in finding 2.\n4. **Redemption.** Fee base is measured against pre-transaction supply, rounded against the redeemer. The ratio guard is exact. The payout is pro-rata on backing, so the reserve cannot be drained below its share. The lag excludes fresh debt and its supply together. Splitting one burn into many pays the lower Riemann sum, which is the approved design from an earlier round, and chunks against a fresh candidate do not move the base, also approved.\n5. **BadDebtFirst.** The Treasury floor is the stale record, which is at most the principal plus fees at drain time. Fees paid through cover remint to the Treasury, so covering a drained position in two calls never needs more than that floor. Unrealized shortfalls are not reserved for, which is the documented \"realized only\" choice.\n6. **Stability fee.** Every rate change passes through `drip` in the same application transaction before the new rate is readable. The index is monotone across checkpoints, so `chiOf` never exceeds `chi` and `stabilityFeeOf` cannot underflow.\n7. **Price gating.** `draw`, priced `free`, `cash`, `bark`, `heel`, `bite`, `earn` and cover's dust path all refuse stale or divergent feeds, including a dead Chainlink leg. `lock`, `wipe`, debt-free `free` and cover of a fully drained position proceed and read no reverting price. During a gap the feed cannot follow, every value action halts for one hour, then until the allowance covers the gap (a 50% fall is followable six hours after the ","treeHash":null,"usage":{"cachedInputTokens":3868065,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":91835,"runtime":"claude","turns":45,"wallClockMs":1455602}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f47f953f8e35921e","findings":[{"citation":"resolved","description":"Q4 (the LAGGED capital) and Q8. `_clampLag` (called from `_resecure` and `_reduceDebt`) lowers `laggedDebt` and `laggedSecured` to the live figure the moment either falls, while `_approach` only credits an increase at elapsed/BACKING_WARMUP per checkpoint and credits nothing within a block. The clamp never asks whether the capital comes straight back. A borrower who calls wipe(debtOf) then draw(the same figure) in ONE transaction (or free(x) then lock(x)) leaves live totalDebt / securedCollateral unchanged but the lagged copies clamped at the lower level for about a day (exponentially longer under activity, per the NatSpec at lines 288-296). `_backingPerUnit` (line 677-689) reads min(live, lagged) at every wage, so the figure every redeemer is paid against (cash, line 632) falls. Two regimes. (A) Wage nonzero with work-minted supply E outstanding (supply = D + E): after a borrower holding D_a of the debt churns, the lagged ratio is 1.7(D - D_a)/(D - D_a + E), below par whenever 0.7(D - D_a) < E, i.e. for a borrower above 1 - E/(0.7 D) of the debt (64% at the maximum earnMat), and ZERO when one position holds all the debt: `cash` reverts ZeroAmount for everyone; recovery is 0.24 after one quiet hour and the churner can repeat every block. (B) Launch configuration, wage 0, E = 0: the debt side cancels but the collateral side does not; when the collateral term binds (after a price fall), a healthy borrower who frees down to 170% and re-locks removes its surplus from laggedSecured: 1.00 -> 0.90 in the reproduction, for the next day. Who profits: the churning borrower when it is the candidate being redeemed against (its debt is cancelled for backing x (1 - fee) of collateral per imdUSD instead of par) and, for the reserve-funded part, the Treasury; who loses: every redeemer paid against the depressed figure (a redeemer with minGemOut set is refused instead), and the peg floor the cash() comment at lines 629-631 presents as min(1 - fee, backing). Reachable with the constants as committed in regime (B); regime (A) needs the wage governance intends to raise (DeploymentConfig.sol:168-172, a 48-hour proposal). NatSpec claims the code does not have: lines 294-296 ('capital brought in one transaction and withdrawn a few later cannot authorise ... a redemption at par') is silent on this direction; line 621 ('Paying pro-rata instead is exactly neutral on backing by construction') and lines 629-631 (peg floor min(1 - fee, backing)) do not hold while a clamp is in force, since the figure paid against is below the honest backing. Smallest fix that keeps the design (decreases count at once for everyone else): record per position the lagged amounts its own decrease clamped (coolingDebt, coolingSecured, cooledAt); when the same position's term or principal rises again within BACKING_WARMUP of cooledAt, add min(increase, cooling) directly to laggedSecured / laggedDebt (bounded by the live figure) instead of routing it through _approach. Alternatively defer the clamp to the end of the external call (compare live against lagged after the position change completes), which closes the atomic variant only. Merged from audit_flow (ad254d80); the launch variant was re-derived and reproduced independently.","line":849,"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 {MockWorkOracle} from \"src/MockWorkOracle.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY} from \"src/DeploymentConfig.sol\";\n\ncontract ChurnFeed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 public value;\n\n    constructor(uint256 v) {\n        value = v;\n    }\n\n    function set(uint256 v) external {\n        value = v;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, uint64(block.timestamp));\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\ncontract ChurnMirror is ISwarmFeed {\n    ISwarmFeed private immutable p;\n\n    constructor(ISwarmFeed p_) {\n        p = p_;\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return p.latestValue();\n    }\n\n    function isStale() external view returns (bool) {\n        return p.isStale();\n    }\n\n    function maxAge() external view returns (uint256) {\n        return p.maxAge();\n    }\n}\n\ncontract ChurnAggregator {\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 borrower that repays and re-borrows inside ONE transaction, so the round trip is atomic\n/// and nothing leaves the system: the same collateral and the same debt afterwards.\ncontract Churner {\n    ParameterizedVault private immutable vault;\n\n    constructor(ParameterizedVault v) {\n        vault = v;\n    }\n\n    function open(MockIMD imd, uint256 collateral, uint256 debt) external {\n        imd.approve(address(vault), collateral);\n        vault.lock(collateral);\n        vault.draw(debt);\n    }\n\n    /// @dev Wipe everything (principal plus the accrued fee) and draw the same figure back.\n    function churnDebt() external {\n        uint256 debt = vault.debtOf(address(this));\n        vault.wipe(debt);\n        vault.draw(debt);\n    }\n}\n\n/// @notice FINDING: the lagged backing (`laggedNow`) is clamped DOWN at once on any decrease and only\n/// warms back up over a day, so a borrower who repays and re-borrows in one transaction converts warm\n/// capital into fresh capital at will. Nothing left the system, yet `backingPerUnit()` — the figure\n/// every redeemer is paid against — falls, and with work-minted supply outstanding it falls to ZERO,\n/// which makes `cash` revert `ZeroAmount` for everyone for up to a day. Repeatable for gas.\n/// This test fails on the committed code and passes once a same-position re-add within\n/// BACKING_WARMUP restores the lagged figure the decrease clamped (or the clamp is otherwise closed).\ncontract LagChurnTest is Test {\n    address private constant WORKER = address(0xCA);\n    address private constant REDEEMER = address(0x4E1);\n    MockIMD private imd;\n    ParameterizedVault private vault;\n    ImdUSD private stable;\n    MockWorkOracle private oracle;\n    Churner private churner;\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 ChurnAggregator()).code);\n        vm.warp(1_000_000);\n        imd = new MockIMD();\n        // 1 IMD = 1/2000 ETH and 1 ETH = $2000, so the vault prices IMD at exactly $1 per 1e18 raw.\n        ChurnFeed primary = new ChurnFeed(uint256(1 ether) * 1e18 / 2000 ether);\n        ChurnFeed health = new ChurnFeed(0.85 ether);\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(primary), address(health), address(new ChurnMirror(primary))\n        );\n        stable = vault.stablecoin();\n        oracle = MockWorkOracle(address(vault.oracle()));\n        churner = new Churner(vault);\n        vm.startPrank(APPROVED_OPERATOR);\n        oracle.grantRights(WORKER, 1_000 ether);\n        imd.mint(address(churner), 10_000 ether);\n        vm.stopPrank();\n        // Minting from work switched on the governed way.\n        Parameters params = vault.parameters();\n        vm.prank(APPROVED_OPERATOR);\n        params.proposeWage(0.01 ether);\n        vm.warp(block.timestamp + params.TIMELOCK());\n        params.applyPending();\n    }\n\n    /// @dev With work-minted imdUSD outstanding, the dominant borrower's atomic wipe-and-redraw drives\n    /// the lagged figure to zero: every redemption reverts for a day although the system is fully backed.\n    function test_atomicRepayAndRedrawDoesNotChangeBackingOrBlockRedemption() public {\n        churner.open(imd, 2_000 ether, 1_000 ether); // 200%, well above mat 170\n        vm.warp(block.timestamp + vault.BACKING_WARMUP() + 1); // the debt is warm\n        vm.prank(WORKER);\n        vault.earn(250 ether); // the ceiling: 1000 x 2500 / 10000\n        vm.warp(block.timestamp + vault.BACKING_WARMUP() + 1); // everything warm\n        vm.startPrank(WORKER);\n        stable.transfer(REDEEMER, 100 ether);\n        stable.transfer(address(churner), 10 ether); // for the fee the churner accrued meanwhile\n        vm.stopPrank();\n\n        assertEq(vault.backingPerUnit(), 1e18, \"fully backed: 2000 of collateral behind 1250 of supply\");\n\n        churner.churnDebt(); // one transaction: wipe principal + fee, draw the same figure back\n\n        (uint256 collateral, uint256 debt) = vault.positions(address(churner));\n        assertEq(collateral, 2_000 ether, \"same collateral\");\n        assertGe(debt, 1_000 ether, \"same principal (plus the fee it just converted)\");\n        assertGe(vault.backingPerUnit() , 1e18 - 1e15, \"an atomic round trip must not move backing below par\");\n\n        vm.prank(REDEEMER);\n        uint256 out = vault.cash(10 ether, 0, address(churner));\n        assertGt(out, 9 ether, \"redemption must stay open and pay about par less the fee\");\n    }\n}","reproduction":"Proof (fails on this code): test/scratch/Proof_ad254d800307.t.sol. ParameterizedVault over an 18-decimal IMD at $1 (IMD/ETH 1/2000 x ETH/USD 2000), NHI 0.85 (mat 170), wage 0.01 applied through Parameters after the 48-hour timelock. A contract borrower locks 2,000 IMD and draws 1,000 imdUSD; a day later a rights holder earns 250 imdUSD (the ceiling); a day later everything is warm: backingPerUnit() == 1e18. The borrower calls wipe(debtOf(self)) then draw(the same figure) in ONE transaction. Expected: same 2,000 collateral and same principal afterwards, so backingPerUnit() stays 1e18 and cash(10e18, 0, borrower) pays about 9.95 IMD. Actual: 'an atomic round trip must not move backing below par: 0 < 999000000000000000' (laggedDebt 0, laggedSecured 0); cash(10e18, 0, borrower) reverts ZeroAmount(). Launch variant, reproduced in test/scratch/Judge.t.sol test_launchCollateralChurnLowersLaggedBacking (passes as a demonstration): wage 0, borrower A 2,000 IMD / 1,000 debt, borrower B 38,000 IMD / 1,000 debt, price falls to $0.05 (A 10%, B 190%), both touched at the new price and warmed: backingPerUnit() == 1e18. B calls free(3,990) then lock(3,990) in one transaction. Expected 1e18. Actual backingPerUnit() == 900250000000000000, laggedSecured 36,010e18 against securedCollateral 40,000e18.","severity":"medium","snippet":"        if (totalDebt < laggedDebt) laggedDebt = totalDebt;","title":"CDPVault._clampLag: a borrower's own atomic repay-and-redraw (or free-and-relock) converts warm capital into fresh capital, so backingPerUnit falls although nothing left the system; with work-minted s"},{"citation":"resolved","description":"Q8. `backedDebt()` (lines 250-259) applies `laggedNow()` only while `_lagApplies()` is true, and `_lagApplies` answers `parameters.wage() != 0`. But whether anything can mint from work is decided by `oracle().mintingRights(msg.sender)` (CDPVault.sol:480-481), which never reads the wage. Two shipped ways to be minting with the lag off. (1) Wage shutdown: SwarmWorkOracle.claim refuses at wage 0 (line 157) but `mintingRights` / `consumeRights` (lines 171-186) keep honouring rights credited earlier, priced at claim. After governance proposes wage > 0, a worker claims, and governance later returns the wage to 0 (documented normal operation), that worker can lock and draw in one transaction, earn against 25% of that zero-second debt in the next transaction of the same block (backedDebt counts it in full: `_debtAtTransactionStart` only excludes the current transaction), then wipe and free in a third. (2) Replacement oracle: Parameters.proposeWorkOracle is allowed ONLY while the wage is zero (Parameters.sol:385) and installs any contract answering vault(), mintingRights and (after a first mint) predecessor; a successor whose rights do not derive from wage() (the shipped MockWorkOracle qualifies; docs/PARAMETERS-2026-10-05.md plans a governed tariff per skill) turns minting on with `_lagApplies()` false. Either way `earnLine()` is the pre-D1 figure and the launch audit's vault-panel medium (D1) is open again: work-minted imdUSD outlives the debt that authorised it with no collateral and no reserve behind it (backingPerUnit 0). Reachability: not at first deployment (WAGE_WAD = 0, no rights); reachable with the committed code after governance has enabled wages and a worker has claimed (then disabled them), or after governance applies a replacement oracle (48-hour timelock). Inside the governance trust the design states, but the two governed paths are not equivalent as the NatSpec claims: raising the wage switches the lag ON, switching it off or installing an oracle switches it OFF while rights stay spendable. NatSpec claims the code does not have: ParameterizedVault.sol:109-110 ('applies exactly while minting from work is on'), CDPVault.sol:864-865 ('turns it on exactly when minting from work is on (a nonzero wage)'), DeploymentConfig.sol:171-172 ('Raising it also switches on the lagged backing'), Parameters.sol:239-244 ('so no rights are ever claimable in two oracles at once' and 'Adds no trust: a governor who could mint through a hostile oracle can already raise the wage'). Smallest fix: make the lag unconditional (`return true;`): the redemption half already reads it at every wage, the figures are tracked from deployment and warm, and with the shipped oracle at wage 0 nothing can earn, so an always-on lag changes nothing at launch. If a zero wage is meant to suspend spending saved rights as well, additionally refuse `earn` while `parameters.wage() == 0` without erasing the rights. Then correct the four comments. Merged from audit_economics (56252d7d, medium), audit_flow (30531255, medium) and audit_math (8f97703e, low): one root cause, one fix.","line":112,"path":"src/ParameterizedVault.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 {Parameters} from \"src/Parameters.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {SwarmWorkOracle} from \"src/SwarmWorkOracle.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY, ERC8004_ADAPTER} from \"src/DeploymentConfig.sol\";\n\n// Only environmental collateral/price/identity stand-ins; accounting is the production vault.\ncontract ERToken {\n    string public name = \"Shares\";\n    string public symbol = \"sIMD\";\n    uint8 public constant decimals = 24;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 amount) external { balanceOf[to] += amount; totalSupply += amount; }\n    function approve(address spender, uint256 amount) external returns (bool) { allowance[msg.sender][spender] = amount; return true; }\n    function transfer(address to, uint256 amount) external returns (bool) { balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true; }\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount; balanceOf[to] += amount; return true;\n    }\n    function asset() external pure returns (address) { return address(0x1AD); }\n    function convertToAssets(uint256 shares) external pure returns (uint256) { return shares * 795 / 100_000_000; }\n}\ncontract ERFeed is ISwarmFeed {\n    uint256 public immutable value;\n    uint256 public constant maxAge = 1 days;\n    constructor(uint256 v) { value = v; }\n    function latestValue() external view returns (uint256, uint64) { return (value, uint64(block.timestamp)); }\n    function isStale() external pure returns (bool) { return false; }\n}\ncontract ERAggregator {\n    function decimals() external pure returns (uint8) { return 8; }\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\ncontract ERAdapter {\n    function isController(uint256, address) external pure returns (bool) { return true; }\n}\ncontract ERWork is SwarmWorkOracle {\n    constructor(address consumer) SwarmWorkOracle(consumer, 1 days) {}\n    function seedRoot(bytes32 root) external { _accept(uint256(root), uint64(block.timestamp)); }\n}\ncontract WageOffResidualTest is Test {\n    ParameterizedVault vault;\n    ERToken shares;\n    ERWork work;\n    ImdUSD stable;\n    address constant BORROWER = address(0xB0B);\n    address constant HOLDER = address(0xCAFE);\n    uint256 constant PRICE = 79_500_000_000_000; // $10/IMD * 7.95 IMD/sIMD, per 1e18 raw shares\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new ERAggregator()).code);\n        vm.etch(ERC8004_ADAPTER, address(new ERAdapter()).code);\n        shares = new ERToken();\n        ERFeed primary = new ERFeed(5e15); // IMD = $10\n        ERFeed nhi = new ERFeed(0.85e18);\n        ERFeed spot = new ERFeed(5e15);\n        address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);\n        work = new ERWork(predicted);\n        vault = new ParameterizedVault(address(shares), address(0), address(work), address(primary), address(nhi), address(spot));\n        assertEq(address(vault), predicted);\n        stable = vault.stablecoin();\n        shares.mint(BORROWER, 30_000e24);\n        vm.prank(BORROWER);\n        shares.approve(address(vault), type(uint256).max);\n    }\n    function _wage(uint256 amount) private {\n        Parameters p = vault.parameters();\n        vm.prank(APPROVED_OPERATOR);\n        p.proposeWage(amount);\n        vm.warp(block.timestamp + 48 hours);\n        p.applyPending();\n    }\n    function _lockDollars(uint256 dollars) private {\n        vm.prank(BORROWER);\n        vault.lock((dollars * 1e18 + PRICE - 1) / PRICE);\n    }\n    function test_zeroWageMustNotDisableLagForPreviouslyClaimedRights() public {\n        _wage(1e18);\n        bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(uint256(1), uint32(250), uint64(250)))));\n        work.seedRoot(root);\n        work.recordRoot();\n        vm.prank(BORROWER);\n        work.claim(1, 250, 250, new bytes32[](0), root);\n        assertEq(work.mintingRights(BORROWER), 250e18);\n        _wage(0);\n        assertEq(vault.parameters().wage(), 0);\n        _lockDollars(2000e18);\n        vm.prank(BORROWER);\n        vault.draw(1000e18);\n        (uint256 lag,) = vault.laggedNow();\n        assertEq(lag, 0);\n        // Separate top-level calls are separate transactions (the repository's isolate=true).\n        // No time elapses between borrowing, earning, repayment and withdrawal.\n        vm.prank(BORROWER);\n        (bool earned,) = address(vault).call(abi.encodeCall(vault.earn, (250e18)));\n        if (!earned) assertEq(vault.totalEarned(), 0);\n        vm.prank(BORROWER);\n        vault.wipe(1000e18);\n        (uint256 collateral,) = vault.positions(BORROWER);\n        vm.prank(BORROWER);\n        vault.free(collateral);\n        assertEq(vault.totalDebt(), 0);\n        assertEq(shares.balanceOf(address(vault)), 0);\n        assertEq(vault.reserveValue(), 0);\n        emit log_named_uint(\"unbacked work supply\", stable.totalSupply());\n        // Either earn must refuse while off, or its ceiling must retain the lag.\n        assertEq(stable.totalSupply(), 0, \"saved rights minted against zero-second debt after wage was switched off\");\n    }\n}","reproduction":"Proof (fails on this code): test/scratch/Proof_56252d7de5fa.t.sol, production ParameterizedVault, Treasury and SwarmWorkOracle accounting over a 24-decimal share at $79.50 per share, NHI 0.85, LINE 1,000,000. Governor proposes wage = 1e18 and applies after 48 h. Worker proves an accepted root for 250 cumulative tasks and claims 250e18 of rights without minting. Governor proposes wage = 0 and applies after 48 h; parameters.wage() == 0. With no existing debt or reserve, the worker locks $2,000 of shares and draws 1000e18 (laggedNow() debt == 0); the next transaction in the same block calls earn(250e18); the next calls wipe(1000e18) and free(all). Expected: earn is refused, or the zero-second debt contributes nothing to the work ceiling. Actual: every call succeeds, totalDebt == 0, vault collateral == 0, reserveValue() == 0 and totalSupply() == 250e18 held by the worker: 'saved rights minted against zero-second debt after wage was switched off: 250000000000000000000 != 0'. Variant (2), run independently from the specialist's proof Proof_30531255a349.t.sol (also fails on this code): wage 0 throughout, a TariffOracle successor applied through proposeWorkOracle, attacker credited 1,000 of rights; lock(2,000) + draw(1,000); one block later earnLine() == 250e18 where the D1 design gives about 0.14e18; earn(250e18), wipe, free: totalSupply() == 250e18 with nothing behind it. With `_lagApplies` returning true both tests pass (earnLine is 0 for a zero-second debt).","severity":"medium","snippet":"        return parameters.wage() != 0;","title":"ParameterizedVault._lagApplies keys the D1 work-ceiling lag to wage != 0, but earn mints at wage 0 from rights the shipped SwarmWorkOracle already credited (after an ordinary wage shutdown) or from a "},{"citation":"resolved","description":"Q4 (the fresh-debt record). `draw` moves `mintedAt` toward the present by ceil((now - mintedAt) x amount / (fresh + amount)). With fresh = 10e18 aged 39,600 s and amount = 400,000e18 the increment is ceil(39,599.01) = 39,600, so mintedAt becomes block.timestamp and the record's principal-time (39,600 s x 10e18) is discarded by the one-second resolution of the weighted date. `_reduceDebt` (lines 1166-1176) then tries to recover the old age as ceil((now - mintedAt) x fresh / remaining) = ceil(0 x ...) = 0, writes recentlyMinted = 10e18 and mintedAt = now: the 10e18 that has been outstanding for eleven hours is dated as minted this second. Repeating the pair every 11 hours keeps any amount of principal inside FRESH_DEBT_WINDOW indefinitely, which is the residual of findings 883fa030 and 5ee3f2bc (the revision notes at lines 454-458 and 1153-1165 state that principal-time is conserved; it is not when a tranche exceeds about (now - mintedAt) x the record). Consequence: `cash` against such a candidate reports freshCancelled == principalCancelled and stores `_redemptionRate(amount - freshCancelled)`, so the base rate everyone after pays is not raised by that burn; a sequence of tranches against a churned candidate each pays the floor plus its own increase instead of a ramping base. The current redeemer still pays the quoted fee. Reachable with the committed launch constants, no governance, no work issuance, no price manipulation; it needs temporary imdUSD within LINE (400,000 against about $2M of posted collateral in the reproduction, or more frequent smaller pairs). Smallest sound fix: keep the fresh record's principal-time in a finer unit (principal x seconds, or an 1e18-scaled weighted timestamp) so a tranche cannot round it to zero, and use that unit in both `draw` and `_reduceDebt`; reversing one rounding direction alone over-ages the residual instead. From audit_economics (221e5297), reproduced.","line":463,"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 {Parameters} from \"src/Parameters.sol\";\nimport {TreasuryFactory} from \"src/TreasuryFactory.sol\";\nimport {SwarmWorkOracle} from \"src/SwarmWorkOracle.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, CHAINLINK_ETH_USD, TREASURY_FACTORY, ERC8004_ADAPTER} from \"src/DeploymentConfig.sol\";\n\n// Only environmental collateral/price/identity stand-ins; accounting is the production vault.\ncontract ERToken {\n    string public name = \"Shares\";\n    string public symbol = \"sIMD\";\n    uint8 public constant decimals = 24;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 amount) external { balanceOf[to] += amount; totalSupply += amount; }\n    function approve(address spender, uint256 amount) external returns (bool) { allowance[msg.sender][spender] = amount; return true; }\n    function transfer(address to, uint256 amount) external returns (bool) { balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true; }\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount; balanceOf[to] += amount; return true;\n    }\n    function asset() external pure returns (address) { return address(0x1AD); }\n    function convertToAssets(uint256 shares) external pure returns (uint256) { return shares * 795 / 100_000_000; }\n}\ncontract ERFeed is ISwarmFeed {\n    uint256 public immutable value;\n    uint256 public constant maxAge = 1 days;\n    constructor(uint256 v) { value = v; }\n    function latestValue() external view returns (uint256, uint64) { return (value, uint64(block.timestamp)); }\n    function isStale() external pure returns (bool) { return false; }\n}\ncontract ERAggregator {\n    function decimals() external pure returns (uint8) { return 8; }\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 2000e8, block.timestamp, block.timestamp, 1);\n    }\n}\ncontract ERAdapter {\n    function isController(uint256, address) external pure returns (bool) { return true; }\n}\ncontract ERWork is SwarmWorkOracle {\n    constructor(address consumer) SwarmWorkOracle(consumer, 1 days) {}\n    function seedRoot(bytes32 root) external { _accept(uint256(root), uint64(block.timestamp)); }\n}\ncontract FreshAgeResidualTest is Test {\n    ParameterizedVault vault;\n    ERToken shares;\n    ERWork work;\n    ImdUSD stable;\n    address constant BORROWER = address(0xB0B);\n    address constant HOLDER = address(0xCAFE);\n    uint256 constant PRICE = 79_500_000_000_000; // $10/IMD * 7.95 IMD/sIMD, per 1e18 raw shares\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        vm.etch(TREASURY_FACTORY, address(new TreasuryFactory()).code);\n        vm.etch(CHAINLINK_ETH_USD, address(new ERAggregator()).code);\n        vm.etch(ERC8004_ADAPTER, address(new ERAdapter()).code);\n        shares = new ERToken();\n        ERFeed primary = new ERFeed(5e15); // IMD = $10\n        ERFeed nhi = new ERFeed(0.85e18);\n        ERFeed spot = new ERFeed(5e15);\n        address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);\n        work = new ERWork(predicted);\n        vault = new ParameterizedVault(address(shares), address(0), address(work), address(primary), address(nhi), address(spot));\n        assertEq(address(vault), predicted);\n        stable = vault.stablecoin();\n        shares.mint(BORROWER, 30_000e24);\n        vm.prank(BORROWER);\n        shares.approve(address(vault), type(uint256).max);\n    }\n    function _wage(uint256 amount) private {\n        Parameters p = vault.parameters();\n        vm.prank(APPROVED_OPERATOR);\n        p.proposeWage(amount);\n        vm.warp(block.timestamp + 48 hours);\n        p.applyPending();\n    }\n    function _lockDollars(uint256 dollars) private {\n        vm.prank(BORROWER);\n        vault.lock((dollars * 1e18 + PRICE - 1) / PRICE);\n    }\n    function test_largeDrawAndWipeMustNotErasePrincipalAge() public {\n        _lockDollars(2_000_000e18);\n        vm.prank(BORROWER);\n        vault.draw(10e18);\n        vm.prank(BORROWER);\n        stable.transfer(HOLDER, 1e18);\n        uint256 started = block.timestamp;\n        // Renew the age at 11 hours. 400,000 is over 39,599 * the original 10.\n        for (uint256 i; i < 3; ++i) {\n            vm.warp(block.timestamp + 11 hours);\n            vm.startPrank(BORROWER);\n            vault.draw(400_000e18);\n            vault.wipe(400_000e18);\n            vm.stopPrank();\n        }\n        assertGt(block.timestamp - started, 12 hours);\n        // Withdraw the temporary collateral, keeping the candidate at 200% (eligible below 220%).\n        (uint256 all, uint256 debt) = vault.positions(BORROWER);\n        uint256 keep = (debt * 2 * 1e18 + PRICE - 1) / PRICE;\n        vm.prank(BORROWER);\n        vault.free(all - keep);\n        assertLt(vault.collateralRatio(BORROWER), vault.redemptionCeilingCR());\n        vm.prank(HOLDER);\n        vault.cash(1e18, 0, BORROWER);\n        emit log_named_uint(\"redemption base\", vault.redemptionBaseRate());\n        // The principal has remained outstanding for 33 hours; draw/wipe pairs should conserve its age.\n        assertGt(vault.redemptionBaseRate(), 0, \"round-trip rounding reset seasoned debt to fresh\");\n    }\n}","reproduction":"Proof (fails on this code): test/scratch/Proof_221e5297df38.t.sol. ParameterizedVault at NHI 0.85, DUTY 444, LINE 1,000,000e18, 24-decimal collateral at $79.50 per share. t0: lock collateral worth $2M, draw 10e18, send 1e18 to HOLDER. At t0+11h: draw(400,000e18) then wipe(400,000e18) with no time elapsed (fresh 10e18, age 39,600: ceil(39,600 x 400,000/400,010) = 39,600, mintedAt = now; the wipe computes age ceil(0 x 400,010/10) = 0). Repeat at +22h and +33h. Free the temporary collateral down to a 200% ratio (eligible below mat + gap = 220). HOLDER calls cash(1e18, 0, BORROWER). Expected: principal outstanding for 33 hours is not fresh, so redemptionBaseRate > 0 afterwards. Actual: freshCancelled == 1e18 and redemptionBaseRate == 0: 'round-trip rounding reset seasoned debt to fresh: 0 <= 0'.","severity":"low","snippet":"                + Math.mulDiv(block.timestamp - position.mintedAt, amount, fresh + amount, Math.Rounding.Ceil);","title":"CDPVault.draw: the fresh-debt record's integer-second weighted date rounds to the present when a tranche dwarfs the record, and _reduceDebt then multiplies a zero age, so a large draw/wipe pair every "},{"citation":"resolved","description":"Q3, break the fix. `cover` takes two paths: with position.collateral == 0 it burns the Treasury's imdUSD with no feed read; with any nonzero collateral it enters the dust path, whose first two statements (lines 530-531) are `_requireFreshFeeds()` and `_requirePriceAgreement()`. One raw unit of sIMD (1e-24 sIMD) re-locked by the drained borrower is far below `_coverDust` and is swept by the next cover, so it no longer blocks cover in capital, but it moves cover onto the gated path. The shipped price feeds are stale between bought attestations by design (PRICE_MAX_AGE = 1 hour, no keep-alive, DeploymentConfig.sol:32-38; the Treasury buys a refresh only for a fall), so whoever covers must first buy or wait for a primary and a spot attestation. The borrower repeats lock(1) after each cover for one lock's gas; `lock` reads no freshness. Bounded: one cover can retire the whole record once feeds are fresh, and feeds go fresh whenever anyone borrows or liquidates; no funds move to the borrower. Harm while blocked: the Treasury's imdUSD equal to the record stays behind BadDebtFirst (Treasury.withdraw, payStream). Reachable with the constants as committed. Smallest fix: decide the sweep without a price when the collateral cannot be reachable at any price, e.g. if position.collateral is below a small absolute raw threshold (or below `_oneWeiSeizure(_priceOrZero())` with a nonzero last price), sweep it to the surplus account and fall through to the no-feed path, keeping the two guards for anything larger. From audit_permissions (7e0bc475), reproduced independently.","line":530,"path":"src/CDPVault.sol","reproduction":"test/scratch/Judge.t.sol test_coverDustPathIsFeedGatedAfterOneUnitRelock (passes as a demonstration). ParameterizedVault over an 18-decimal IMD at $1, NHI 0.85. Borrower A locks 2,000 and draws 1,000; KEEPER locks 20,000 and draws 5,000. Price to $0.50; bark(A); +6 h; bite(A, floor(2,000 x 0.5 / 1.2)) drains A (collateral 0, totalBadDebt > 0). KEEPER funds the Treasury with 500 imdUSD. Primary feed set stale. cover(A, 1e18) succeeds with no fresh feed (expected). A calls lock(1). cover(A, 1e18): expected to succeed (one raw unit backs nothing a bite could reach); actual reverts CDPVault.StaleFeed. With the feed fresh cover sweeps the unit and lands; A calls lock(1) again, the feed goes stale again, and the next cover reverts StaleFeed again.","severity":"low","snippet":"            _requireFreshFeeds();","title":"cover's dust path is feed-gated, so a drained borrower's lock(1) (one raw unit, gas only) makes every later cover require fresh, agreeing primary, spot, NHI and Chainlink legs; between purchased attes"},{"citation":"resolved","description":"Q3, break the fix (docs/AUDIT-FINAL-2-2026-10-07.md finding 4). `_coverDust` (lines 581-587) now sweeps only collateral below the seizure for max(debt / 1e6, min(debt / 100, 1 imdUSD)), so blocking cover costs collateral worth about 1.2 imdUSD (1.38e22 raw sIMD, about 0.0138 sIMD, at the launch collateral price of 86,814,000,000,000 per 1e18 raw) for any record from 100 imdUSD to 1,000,000 imdUSD. No way was found to keep a record uncoverable for free: the fix holds as stated. Two residuals. (1) The cost is paid per BITE, not per cycle, and nobody but the protocol's keeper will bite: the collateral at or above the floor can only be reached through a fresh mark (the drain's mark has expired by the time the griefer re-locks), the full lull (six hours at NHI >= 0.85) and a bite sized to exactly floor(collateral x price / 1.2e18) (a one-wei bite leaves a remainder above `_oneWeiSeizure` that is not swept, line 980; an oversized bite reverts InsufficientCollateral, line 955-956). That bite burns about 1.0 imdUSD and pays the liquidator about 1.18 imdUSD of sIMD (1.16 when it did not mark): two mainnet transactions for about $0.18, so the cycle length is set by the operator's keeper, and until it acts the record stays uncoverable, the Treasury's imdUSD equal to it stays behind BadDebtFirst, and fees accrue on the record. (2) The NatSpec at line 580 ('blocking cover now costs collateral worth about 1.2 imdUSD every cycle, which goes to the surplus account') is wrong on the destination: a bite pays collateralSeized less the protocol cut and the marker cut to the liquidator (lines 990-996); only the protocol cut (10% of the 20% bonus, about $0.02) reaches the Treasury. Only collateral BELOW the floor is swept to the surplus account. Griefing only, no funds move to the borrower, reachable with the constants as committed. Smallest fix: let `bite` skip the mark and grace for a position whose `_recordedBadDebt` is nonzero (it has been drained once; the grace exists for a borrower who could recover), so the keeper's cost per cycle is one transaction; or let `cover` sweep any collateral on such a position whose value at `price` is below its recorded bad debt, which turns the re-lock into a donation to the surplus account as the comment intends. Correct line 580 either way. Merged from audit_flow (87cf642a), audit_math (608ba566) and audit_economics (f0a34598, the line-580 half); reproduced independently.","line":533,"path":"src/CDPVault.sol","reproduction":"test/scratch/Judge.t.sol test_coverFloorCollateralGoesToTheLiquidator (passes as a demonstration). 18-decimal IMD at $1, NHI 0.85 (lull 6 h, tail 1 h). Borrower A (2,000 / 1,000) is drained by a crash to $0.50, mark and bite; price back to $1; the mark expires; the Treasury holds 500 imdUSD; A's record is above 100 imdUSD so _coverDust == 1.2e18 raw ($1.20). A locks exactly 1.2e18 raw. cover(A, 1e18) reverts NoRealizedBadDebt (expected per the fix). bite(A, 1e18) reverts MarkExpired; after bark(A) it reverts GracePeriodNotElapsed; after 6 h bite(A, 1e18) (the exact size: the seizure 1.2e18 fits) succeeds. Expected per line 580: the $1.20 goes to the surplus account. Actual: the liquidator receives 1,180,000,000,000,000,000 raw ($1.18, it was also the marker) and the Treasury 20,000,000,000,000,000 raw ($0.02); positions(A).collateral == 0 and cover(A, 1e18) then succeeds. At the sIMD scale the same cycle costs the griefer 13,822,655,332,089,294,353,446 raw (0.0138 sIMD, about $1.20) per bite that the operator's keeper pays for.","severity":"low","snippet":"            if (position.collateral >= _coverDust(owner, price)) revert NoRealizedBadDebt();","title":"The dust-floor fix holds in capital, but a drained borrower's single $1.20 re-lock keeps cover blocked until someone pays bark, six hours of grace and an exactly sized bite for about $0.18 of collater"},{"citation":"resolved","description":"NatSpec claims the code does not have. (1) Lines 510-513 say cover only cancels debt behind 'a drained position, or one holding dust worth under a millionth of its debt (at least the seizure for one wei)'. Since b73a05f `_coverDust` (lines 581-587) sweeps anything below the seizure for max(debt / 1e6, min(debt / 100, 1e18)): for a 100 imdUSD debt that is collateral worth up to 1.2 imdUSD (1.2% of the debt), for 50 imdUSD up to 0.6 imdUSD, for 1,000,000 imdUSD still 1.2 imdUSD. The inline comment at 526-527 and `_coverDust`'s own NatSpec are correct; the function-level @dev that readers and ABI docs quote is not. (2) Lines 278-279 say totalBadDebt is 'reduced only by repaying the position's debt (`wipe`, or `cover` with the protocol's surplus imdUSD)'. Any path through `_reduceDebt` reduces it (lines 1181-1189): `cash` against a drained-then-relocked candidate and a `bite` of re-locked collateral do as well. Fix: at 512 'worth under about 1.2 imdUSD (the seizure for one imdUSD of debt, or a hundredth of a debt under 100 imdUSD, or a millionth of a debt over a million)'; at 278 'reduced only when the position's debt is repaid or cancelled (wipe, cover, bite, cash)'. Merged from audit_math (608ba566, the 512 and 278 halves), audit_economics (f0a34598), audit_flow (bda21ff5) and audit_permissions (3e3f7663).","line":512,"path":"src/CDPVault.sol","reproduction":"Position with debt 100e18 and collateral worth 1.13 imdUSD (1.3e22 raw at price 86,814,000,000,000; 1.13% of the debt). Expected per line 512: cover reverts NoRealizedBadDebt, the collateral being far above a millionth of the debt. Actual: _coverDust == 13,824,884,792,626,887,383,465 raw > 1.3e22, so cover sweeps it to the Treasury and retires the debt. For 278: in test/scratch/Judge.t.sol the bite of A's re-locked 1.2e18 raw runs _reduceDebt with _recordedBadDebt[A] != 0 and lowers totalBadDebt by the 1e18 repaid before cover is called.","severity":"info","snippet":"    /// holding dust worth under a millionth of its debt (at least the seizure for one wei), which is","title":"cover's function NatSpec still describes the superseded dust rule ('under a millionth of its debt, at least the seizure for one wei'); the code sweeps up to the seizure for one imdUSD (a hundredth of "},{"citation":"resolved","description":"NatSpec claim the code does not have, the documentation half of the 2026-10-05 vault panel's finding 15, which corrected Parameters.sol:82-83 ('7000 is the cliff ... at 2500 it is 136%') and left the same derivation in ParameterizedVault.earnLine (lines 262-266) and DeploymentConfig.sol:146-147 ('which is 5000 at the loosest NHI. 2500 is half that cliff, 120% worst-case backing with an empty reserve'). CDPVault._mat (line 1254) returns 170 at NHI >= 0.85, so mat - 1 = 0.70 = 7000 bps; MAX_EARN_MAT_BPS = 2500 is 2500/7000 = 0.357 of it; with an empty reserve, debt D fully drawn at mat 170 and E = 0.25 D minted, backing is 1.70 D / 1.25 D = 1.36. No behaviour depends on it, but the two source files now state different safety margins for the same constant, and a reader sizing a proposeEarnMat from them believes the cap sits at half the cliff with 20% headroom. Fix: '7000 bps at mat 170; 2500 is about a third of it, 136% worst-case backing' in both files, or point both at Parameters.MAX_EARN_MAT_BPS. Merged from audit_flow (95a91a28) and audit_permissions (bfd16097).","line":266,"path":"src/ParameterizedVault.sol","reproduction":"Compute with the committed constants: mat() at NHI 0.85 == 170 (src/CDPVault.sol:1254); mat - 1 == 70% == 7000 bps; Parameters.MAX_EARN_MAT_BPS() == 2500; 2500 / 7000 == 0.357, not 0.5; worst case (reserve 0, collateral 1.7 D, supply 1.25 D) is 1.36, not 1.20. Expected per the two comments: 5000 bps, half, 120%. Actual: 7000 bps, five-fourteenths, 136%, as src/Parameters.sol:82-83 already states.","severity":"info","snippet":"    /// Parameters caps the ratio at half that cliff.","title":"earnLine's NatSpec ('Parameters caps the ratio at half that cliff') and DeploymentConfig's EARN_MAT_BPS comment ('5000 at the loosest NHI ... 120% worst-case backing') use the pre-170 mat: with mat 17"},{"citation":"resolved","description":"NatSpec claim the code does not have, the documentation half of the 2026-10-05 vault panel's finding 7 (whose code half, Treasury.validateReserveAsset lines 155-161 pinning the collateral token to collateralPriceFeed, is in). The statement is true of collateral pricing: the three feeds, usdPriceFeed and collateralPriceFeed are immutables. It is not true of the two other price-bearing inputs the vault reads: Parameters.proposeReserveAsset(asset, priceFeed, haircutBps) lets the governor list any non-collateral token the Treasury holds against any ISwarmFeed-shaped source after the 48-hour delay, and that source's value is the first term of earnLine (line 268, through reserveValue) and the `others` term of _redemptionReserveBacking (line 165), which sets every redemption payout through _backingPerUnit; and Parameters.proposeWorkOracle replaces the oracle earn mints against. Both are governed behind the timelock, visible for two days and bounded (MAX_RESERVE_VALUE per asset, wage 0 for the oracle): the intended power of the role, to be stated as a trust assumption rather than denied. Fix: 'never where the COLLATERAL price comes from (reserve assets other than the collateral are priced by the source governance lists for them, and the work oracle is governed behind the same delay), where the revenue goes, or which contract governs.' Merged from audit_flow (198401c4) and audit_permissions (7856a406).","line":27,"path":"src/ParameterizedVault.sol","reproduction":"APPROVED_OPERATOR calls parameters.proposeReserveAsset(token, anyFeed, 10000) for a non-collateral token the Treasury holds; after TIMELOCK anyone calls applyPending(). Expected per the header: no governance action changes where a price the vault reads comes from. Actual: Treasury.reserveAsset(token).priceFeed == anyFeed, and vault.reserveValue(), vault.earnLine() and vault.backingPerUnit() move with anyFeed.latestValue() (test/ReserveValuation.t.sol exercises the same path).","severity":"info","snippet":"/// are, never where the price comes from, where the revenue goes, or which contract governs.","title":"ParameterizedVault's header says the governor can change 'never where the price comes from'; the governor chooses every non-collateral reserve asset's price source (Parameters.proposeReserveAsset) and"}],"hash":"ea55636b1e3cd14487cdbef290c7c97ba6cc285cd38c013e8ca3f81f0552d4e0","nodeId":"2d7e4ed0-ebe1-4299-ac41-e23cafafb4f7","outcome":"completed","summary":"Review complete. The findings file holds 8 findings, merged from the 15 the four specialists reported, each reproduced against the committed code. No source file was changed; the only additions are `.imd-findings.json` and five scratch tests under `test/scratch/`.\n\n**Result**\n\n| # | Severity | Where | Finding |\n|---|---|---|---|\n| 1 | medium | CDPVault.sol:849 `_clampLag` | A borrower's atomic wipe-and-redraw or free-and-relock clamps the lagged backing at once and it only warms back over a day. With work supply outstanding the dominant borrower drives backingPerUnit to zero and every `cash` reverts, repeatable per block for gas. At launch (wage 0) the collateral side alone drops it 1.00 to 0.90. Proof attached. |\n| 2 | medium | ParameterizedVault.sol:112 `_lagApplies` | The work-ceiling lag is keyed to wage != 0, but `earn` mints at wage 0 from rights SwarmWorkOracle already credited (after an ordinary wage shutdown) or from a governed replacement oracle. The D1 adjacent-transaction round trip leaves 250 unbacked imdUSD in the proof. Three specialist findings merged. Proof attached. |\n| 3 | low | CDPVault.sol:463 `draw` | A tranche that dwarfs the fresh record rounds its weighted date to the present and `_reduceDebt` then multiplies a zero age, so draw/wipe pairs keep seasoned principal fresh and redemptions against it never raise the base rate. Proof attached. |\n| 4 | low | CDPVault.sol:530 `cover` | `lock(1)` for gas moves cover onto the feed-gated dust path, so cover reverts StaleFeed between purchased attestations. Reproduced by my own test. |\n| 5 | low | CDPVault.sol:533 `cover` | The dust-floor fix holds in capital, but the $1.20 re-lock stays until someone pays bark, six hours and an exactly sized bite for about $0.18, and the collateral goes to the liquidator, not the surplus account as line 580 says. Reproduced: 1.18 to the liquidator, 0.02 to the Treasury. |\n| 6 | info | CDPVault.sol:512 | cover's NatSpec still states the superseded dust rule; line 278 omits that cash and bite reduce totalBadDebt. |\n| 7 | info | ParameterizedVault.sol:266 | earnLine and DeploymentConfig state a 5000 bps cliff, \"half\", 120%; the code gives 7000, five-fourteenths, 136%. |\n| 8 | info | ParameterizedVault.sol:27 | \"never where the price comes from\" is false for governed reserve price sources and the work oracle. |\n\n**How the specialists fared.** All four attached proofs fail on this code for the stated reasons and are kept. I merged the three lag-gating findings (one root cause, one fix), the four cover-NatSpec findings, the two cliff-number findings and the two header findings. Nothing was dropped as non-reproducing; two findings reported as \"passes, demonstrates the state\" were confirmed by my own tests and folded into findings 4 and 5.\n\n**Answers where nothing is wrong.** Positions (Q1): all five entry points are nonReentrant, lockIMD credits only the measured share delta, funds move only for the caller. Liquidation (Q2): nobody receives more than the formula, the marker's share comes out of the bonus, dust below the one-wei seizure is swept, and the grace snapshot can only be reset by actually restoring health. Bad debt and the Treasury (Q5): withdraw, payStream and cover all respect totalBadDebt; cover moves the per-position record and the total together. Stability fee (Q6): Parameters drips before a rate lands, chiOf is only ever written from chi. Price gating (Q7): every value-moving action except lock, wipe and debt-free free reads freshness and agreement; a feed that cannot follow a gap halts them all, liquidation included, which is the documented trade-off. Work at WAGE_WAD 0 (Q8): claim refuses, so no rights exist and earn reverts; a reserve donation cannot be round-tripped except through cash at or below par. Arithmetic (Q9): ratios saturate, every payout division rounds against the recipient, and the per-1e18-raw convention holds through SharePriceFeed.\n\n**Coverage.** Read in full: CDPVault, ParameterizedVault, ImdUSD, Para","treeHash":null,"usage":{"cachedInputTokens":2008334,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":47868,"runtime":"claude","turns":28,"wallClockMs":684965}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b3450b02f80aa5c2","findings":[],"hash":"fb536bce7e48c2017eeda1d498a30abaa0af6ef939ab87d7ff745f590141fdf0","nodeId":"559b418b-095c-4cd4-b731-e38bea718a97","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":4,"wallClockMs":1216057}}],"verification":[]}