{"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":"59d97f98-3694-4e00-9c77-f2195bd39818","kind":"audit","nodes":[{"acceptedSubmissionHash":"fcc30349938ec6906ca7221da055468591a685c5931d9ad76a95808f5c601fdf","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"ce8d43d0601456caa423edd0f371f9225c8f9057ebfc943b3ed9104a0776cb02","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"886c79112335f9fc22490595bac189cbe1c090d9a0b15d5ad4b1eec76bcee0ec","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"4138b2168aad628d278f9b4e57d206b918953859951dff937e13a6202903a8dc","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"229e0e130ca21284ce2709e99581ae3f2b4178f8602a8478cbb84e5faa3ac286","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit src/Ledger.sol, src/LedgerBase.sol, src/LedgerSettlement.sol, src/PositionNFT.sol, src/SortedPositions.sol, src/Join.sol and src/Liquidator.sol, src/Redeemer.sol, src/Pricer.sol, src/Pulse.sol, at the pinned commit, for a capped mainnet beta. Read whatever else in src/ these depend on, but report on this scope.\n\nimdUSD v2 is a multi-collateral CDP stablecoin; this is its capped mainnet beta on Ethereum. Positions are NFT ids; borrowers set their own interest rate and redemptions take from the lowest rates first; liquidation is mark then bite, with a standing bid from Backstop sleeves; the peg pool is a Uniswap v4 pool with our own hook; prices come from Chainlink and from swarm-attested feeds (EIP-712 attestations bound to pinned questions). README.md gives the scope, the key invariants, the trust model and the known issues: read it first.\n\nBUILD AND TEST (required; Foundry, solc 0.8.26 via_ir per foundry.toml). Never build or test the whole project: with every suite it needs about 49 GB and the compiler will be killed. Run only this panel's tests: `forge test --match-path 'test/unit/{Core,Positions,Redemption,StandingBid,ReviewLedger,panel/PanelLiquidation}.t.sol'` (measured: about 4.4 GB peak, 6 minutes). One build at a time on your machine. Write each proof as a new file test/unit/Proof<Name>.t.sol, add it to the glob and run it; paste it with its output into the finding, then delete it: your submission must leave the tree unchanged. With Foundry 1.9 or later, `forge test --use solar --no-match-path 'test/fork/*'` builds everything and runs all 1,179 tests in seconds under 1 GB (Solar accepts a little syntax solc refuses: confirm a proof with the solc command).\n\nThis panel covers two areas whose money flows meet: give both the same depth, and answer every question.\n\nAnswer each numbered question, including the ones where nothing is wrong:\n1. DEBT BOOKKEEPING (_record, _accrue, _debtOf, _mintInterest; IlkState debt and weighted). Prove or break: a type's debt always covers the sum of its positions' debt (the aggregate rounds up, each position down), weighted always equals the sum of recorded debt x rate, and interest minted equals interest owed, across adjust, setRate, harvest, redeemPosition, liquidatePosition and writeOff.\n2. HARVEST (_harvest, yieldMark, _blendMark). Prove or break: yield is credited only from real growth of the vault rate, at a priced quote, and no sequence (deposit, NFT transfer, a rate dip, an unpriced window, a flash deposit) credits more than the shares' growth or lets one position harvest another's.\n3. UPFRONT FEE AND RATE COOLDOWN (_upfrontFee, _premium, setRate, premiumRate, lastRateChange). Find the cheapest way to borrow at a lower rate than one's own recent rate without paying seven days at the old rate: a fresh position, repay-and-reopen, an operator, an NFT transfer, or a flash loan.\n4. RISK VERSIONS (Parameters.liqRisk and _bindAt, Position.version, the 7-day grace). Can a position escape a harsher version past its grace, or be put on one before its grace ends?\n5. POSITIONS AND RIGHTS (PositionNFT rights, epochs and permits; Ledger openFor, borrowFor, addFor, repayFor, execute; holder-bound joins). Any path to act on a position without the owner's rights or signature, to replay a permit, or to move holder-bound collateral to anyone but its holder.\n6. SORTED LIST (SortedPositions, _relist, hints). Prove or break: on a redeemable type a position is listed exactly while its recorded debt is non-zero; bad hints cannot corrupt the order or block redemption.\n7. THE SPLIT (LedgerSettlement by delegatecall). Storage layout agreement with Ledger, the shared reentrancy slot, and every entry point reachable on the settlement module directly.\n8. MARK, GRACE, BITE (bark, bite, bite(id, minSeized), liquidate, clear, tick; marks, graces, clocks, MARK_TTL, the priced clock). Liquidate a position that is healthy, hold an unhealthy one off, or take a marker's chip that is not yours.\n9. STANDING BID (bite's sleeve branch, Backstop.openBid and closeBid, liquidationNeed, partial liquidation to target). Make a sleeve pay more than the collateral is worth, or a caller extract value from the bid; prove a partial liquidation always leaves the position healthier and never under dust.\n10. BAD DEBT (_cover, COVER_MIN_GAS, Treasury.coverBadDebt, the edge sleeve's first loss). Can anyone make the Treasury pay before the edge sleeve, starve the cover of gas, or leave booked bad debt that nobody can cover?\n11. REDEMPTION (Redeemer.redeem and redeemPosition, base rate and its decay, fee floor and 5% cap, redeemCap per 2-hour window, minCollateralOut, the payout leg). Redeem below fair value, take a type past its window cap, push the fee for others, or grief honest redeemers.\n12. PRICER (quote: low, high, borrow, payout; agree, borrowable, priced; the depeg breaker; Parameters.cap). Any combination of leg readings that allows borrowing or a withdrawal at a price the legs do not support, or a redemption paying more than the collateral is worth.\n13. PULSE (sequencer grace, on chains that have one). Can a liquidation or a redemption run inside a grace it should wait out?\n\nNot findings: anything listed under README.md 'Known issues and accepted risks' (say so if you find it worse than written); src/bridge, src/dex, src/earn/LoopVault.sol and src/stocks (after the beta) unless a question names them; test/ and its mocks; gas optimisations unless they change behaviour.\n\nFor every finding: severity by impact; file, line and the lines copied; the call sequence from an external caller; a concrete failing input or state with expected against actual; whether it is reachable with the values in script/DeployConfig.sol (BetaConfig); the smallest fix; and a Foundry test that fails on this commit, RUN as BUILD says and pasted in full in the finding with its output (the judge reruns it). The test/ tree has harnesses to reuse (test/utils/System.sol, the unit suites). Answer every numbered question, including those where nothing is wrong. Give a coverage row (holds, finding or unreached, with a reason) for every external function of the contracts in scope. Also report every comment or NatSpec that claims a property the code does not have.","parentJobId":null,"planHash":"78bddfc1b4d2e103f521b20e06e07da3584263645b0c6ac45afbdd5025fbd171","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"59d97f98-3694-4e00-9c77-f2195bd39818","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":"51481","feedbackHash":"7d8d086c346b5cbe134f6eb1b40663a504366c28255c84c41ef3333c35a65359","nodeKey":"audit_economics","submissionHash":"fcc30349938ec6906ca7221da055468591a685c5931d9ad76a95808f5c601fdf","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51020","feedbackHash":"7aa0ca7549307282146bc3f6e6e70490ca06cbdf7c2d066232eca7530cdaccc4","nodeKey":"audit_flow","submissionHash":"ce8d43d0601456caa423edd0f371f9225c8f9057ebfc943b3ed9104a0776cb02","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53917","feedbackHash":"dae8222cb4fd8e4fe906651a72b2af892f81b8d6bd03470910a0de0d65465e9d","nodeKey":"audit_judge","submissionHash":"886c79112335f9fc22490595bac189cbe1c090d9a0b15d5ad4b1eec76bcee0ec","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52621","feedbackHash":"feef2a5bc9b729d6943ae9cebe522bf83b1e584561264fa5c1c8a52939767602","nodeKey":"audit_math","submissionHash":"4138b2168aad628d278f9b4e57d206b918953859951dff937e13a6202903a8dc","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"54234","feedbackHash":"d9b72679b8247b73e6bd984b3b7d5fe0c83e3b65d5596980a81a7567b92d0491","nodeKey":"audit_permissions","submissionHash":"229e0e130ca21284ce2709e99581ae3f2b4178f8602a8478cbb84e5faa3ac286","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"e9d7a99d124862f1e05804455dee092494505e4a3e6445fb8b01bc2e42fdd3ae","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2042b7cb7b952e30","findings":[{"citation":"resolved","description":"Pricer.sol:28-29 (\"`borrow = min(low, cap)`: the price new debt, withdrawals and a charged rate change are measured at\") and ParametersBase.sol:196-197 (\"borrowing, withdrawing and a charged rate change are measured at min(low, cap)\") promise that the governed borrow ceiling binds a charged rate change. Ledger.setRate adds the cooldown premium to the position's debt (line 159-161, minted as interest at line 165) and then checks health at `q.low` (line 168), not `q.borrow`; the in-code comment at line 167 even says the cap does not apply. So while a cap below market refuses every 1-wei borrow on a position (Ledger._adjust line 328 uses `_adjustPrice` = `q.borrow`), the same owner can still add premium debt to it through setRate. Asymmetry between the two debt-adding entry points; the cap's stated guarantee (ParametersBase.sol:197-198: it can only make borrowing harder) is weaker than written. Reachable in BetaConfig only once the governor sets a cap (none at genesis; `lowerCap` is an instant governor act). Impact is bounded by the premium (7 days of interest), so low. Fix: measure the charged change at `q.borrow` (one token change at line 168) and correct the comment at line 167, or correct the Pricer/Parameters NatSpec if the uncapped measurement is intended. BUILD NOTE: on the review machine (31 GB, no swap) every `forge test` invocation compiled the whole tree (196-198 files, via_ir) and solc was SIGKILLed (OOM) three times, with and without `--skip`; the attached proof file is therefore UNRUN here and should be run by the verifier as `forge test --match-path test/scratch/ProofPermissions.t.sol -vv`. Its expected result on this commit: the three tests fail (the setRate call succeeds where the test expects a revert, and the fresh-salt fee is 9.59 against a 19.18 premium).","line":168,"path":"src/Ledger.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {System} from \"../utils/System.sol\";\nimport {MockERC20, MockFeed} from \"../utils/Mocks.sol\";\nimport {ParametersBase} from \"../../src/ParametersBase.sol\";\nimport {Ledger} from \"../../src/Ledger.sol\";\nimport {LedgerBase} from \"../../src/LedgerBase.sol\";\nimport {Join} from \"../../src/Join.sol\";\nimport {IFeed} from \"../../src/interfaces/IFeed.sol\";\nimport {Adjust} from \"../../src/interfaces/IPositions.sol\";\n\n/// @dev Permissions panel proofs: each test encodes the property the code's own NatSpec claims and fails on this commit.\ncontract ProofPermissionsTest is System {\n    bytes32 constant ETH = \"ETH\";\n\n    MockERC20 weth;\n    MockERC20 v1;\n    MockFeed feed;\n    Join ethJoin;\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        weth = new MockERC20(\"WETH\", 18);\n        v1 = new MockERC20(\"imdUSDv1\", 18);\n        feed = new MockFeed(2000e18);\n        _deploySystem(IERC20(address(v1)));\n        ethJoin = _addIlk(ETH, address(weth), feed, feed, IFeed(address(0)), address(0), address(0), true, _risk(1.4e18, 1_000_000e18));\n        params.setGenesisCap(ETH, 1500e18); // a borrow price ceiling below the market (Sky's capped OSM)\n        params.seal();\n        address[2] memory us = [alice, bob];\n        for (uint256 i; i < 2; ++i) {\n            weth.mint(us[i], 1000e18);\n            vm.prank(us[i]);\n            weth.approve(address(ledger), type(uint256).max);\n        }\n    }\n\n    function _open(address u, uint256 salt, uint256 coll, uint256 debt, uint64 rate) internal returns (uint256 id) {\n        vm.prank(u);\n        id = ledger.open(ETH, salt, Adjust(int256(coll), int256(debt), rate, 0, 0, false, type(uint256).max, address(0)));\n    }\n\n    /// Pricer.sol:28-29 and ParametersBase.sol:196-197 say a CHARGED RATE CHANGE is measured at `borrow = min(low, cap)`.\n    /// Ledger.setRate measures it at the uncapped `low` (Ledger.sol:168): with the cap binding, a borrow of 1 wei is\n    /// refused (Unhealthy) while a charged rate change adds premium debt to the same position.\n    function test_chargedRateChangeIgnoresBorrowCap() public {\n        uint256 id = _open(alice, 0, 10e18, 10_000e18, 0.05e18); // CR at the cap (1500): 150%\n        vm.prank(governor);\n        params.lowerCap(ETH, 1300e18); // CR at the cap: 130% < mat 140%; at `low` (2000): 200%\n        vm.prank(alice);\n        vm.expectRevert(LedgerBase.Unhealthy.selector);\n        ledger.adjust(id, Adjust(0, 1, 0, 0, 0, false, type(uint256).max, address(0)));\n        uint256 before = ledger.debtOf(id);\n        vm.prank(alice);\n        vm.expectRevert(LedgerBase.Unhealthy.selector); // what the Pricer/Parameters NatSpec promises\n        ledger.setRate(id, 0.06e18, 0, 0);\n        assertEq(ledger.debtOf(id), before, \"no premium debt added below the capped health line\");\n    }\n\n    /// LedgerBase.sol:164-166 and :320-321: while `unbacked()` is past the brake, \"no new debt on any type\". `_adjust`\n    /// checks the brake on a borrow (Ledger.sol:301); `setRate`'s charged premium adds debt without it (Ledger.sol:159-166).\n    function test_chargedRateChangeBypassesUnbackedBrake() public {\n        uint256 a = _open(alice, 0, 10e18, 10_000e18, 0.05e18); // CR at the cap (1500): 150%\n        uint256 b = _open(bob, 0, 100e18, 20_000e18, 0.05e18);\n        feed.set(500e18); // alice: 10 ETH = 5,000 against ~10,010 of debt; bob stays at CR 250%\n        vm.prank(bob);\n        liquidator.liquidate(a); // bob pays 5,000, ~5,010 is booked bad debt; the Treasury covers only its ~29 of fees\n        assertGt(ledger.unbacked(), usd.circulating() / 100, \"braked\");\n        vm.prank(bob);\n        vm.expectRevert(LedgerBase.Braked.selector);\n        ledger.adjust(b, Adjust(0, 1, 0, 0, 0, false, type(uint256).max, address(0)));\n        uint256 before = ledger.debtOf(b);\n        vm.prank(bob);\n        vm.expectRevert(LedgerBase.Braked.selector); // expected: Braked, as for any other new debt\n        ledger.setRate(b, 0.06e18, 0, 0);\n        assertEq(ledger.debtOf(b), before, \"no new debt while braked\");\n    }\n\n    /// LedgerBase.sol:126-127: \"reopening at another rate inside the cooldown pays the premium (a flash mint cannot\n    /// launder a rate drop)\". It holds for the same id only: repay, withdraw, and reopen under a fresh salt pays the\n    /// upfront fee at the type's average (the new rate itself when the type is otherwise empty), half the premium here.\n    function test_freshSaltDropsRateBelowOldRatePremium() public {\n        uint256 id0 = _open(alice, 0, 10e18, 10_000e18, 0.10e18);\n        vm.prank(address(ledger));\n        usd.mint(alice, 100e18); // covers the upfront fee so the repayment can clear the debt\n        uint256 premium = (10_000e18 * uint256(0.10e18) * 7 days + 365 days * 1e18 - 1) / (365 days * 1e18);\n        vm.startPrank(alice);\n        ledger.repayFor(id0, type(uint256).max);\n        ledger.adjust(id0, Adjust(-10e18, 0, 0, 0, 0, false, 0, address(0)));\n        uint256 id1 = ledger.open(ETH, 1, Adjust(10e18, 10_000e18, 0.05e18, 0, 0, false, type(uint256).max, address(0)));\n        vm.stopPrank();\n        uint256 feePaid = ledger.debtOf(id1) - 10_000e18;\n        emit log_named_uint(\"setRate premium (7d at 10%)\", premium);\n        emit log_named_uint(\"fresh-salt fee (7d at 5%)\", feePaid);\n        assertGe(feePaid, premium, \"a rate drop costs at least 7 days at the old rate\");\n    }\n}","reproduction":"ETH type, mat 140%, feed 2000, genesis cap 1500. Alice opens 10 ETH / 10,000 imdUSD at 5% (CR at the cap 150%). Governor `lowerCap(ETH, 1300e18)`: CR at the cap 130% < 140%, at `low` 200%. Alice `adjust(id, dDebt = 1)` -> reverts Unhealthy (expected, measured at borrow = 1300). Alice `setRate(id, 0.06e18, 0, 0)` inside the cooldown -> expected per NatSpec: revert Unhealthy; actual: succeeds, debt rises by the premium (10,000 x 5% x 7/365 = 9.59 imdUSD) with the position below the capped health line. Test `test_chargedRateChangeIgnoresBorrowCap` in the attached proof fails on this commit for that reason.","severity":"low","snippet":"            if (!_healthy(p.collateral, debt, q.low, q.mat)) revert Unhealthy();","title":"setRate measures a charged rate change at the uncapped low, contradicting the Pricer/Parameters cap NatSpec"},{"citation":"resolved","description":"LedgerBase.sol:164-166 (`Braked`: \"no new debt on any type until cover\") and :320-321 (\"F3's brake: new debt stops on every type while `unbacked()` exceeds the governed fraction\"). Ledger._adjust runs `_checkBrake()` after the line, chain-line and edge checks on every borrow (line 301). Ledger.setRate's charged branch adds the cooldown premium to `s.debt` and to the position (lines 159-161), mints it (line 165) and runs the line, chain-line and edge checks (lines 162-164) but not `_checkBrake()`. The two paths that create debt are asymmetric: while the brake is on, a borrower can still raise the type's debt and the supply through a charged rate change. The amount per call is bounded (7 days of interest on the position's debt, about 0.1-0.2% of it at typical rates) and each change restarts the cooldown, so the leak is small and the severity low; it is still debt minted against a hole the brake exists to stop. Reachable in BetaConfig (any booked bad debt above 1% of circulating with a thin Treasury). Fix: add `_checkBrake();` after `_checkEdge(ilk);` at line 164. BUILD NOTE: on the review machine (31 GB, no swap) every `forge test` invocation compiled the whole tree (196-198 files, via_ir) and solc was SIGKILLed (OOM) three times, with and without `--skip`; the attached proof file is therefore UNRUN here and should be run by the verifier as `forge test --match-path test/scratch/ProofPermissions.t.sol -vv`. Its expected result on this commit: the three tests fail (the setRate call succeeds where the test expects a revert, and the fresh-salt fee is 9.59 against a 19.18 premium).","line":162,"path":"src/Ledger.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {System} from \"../utils/System.sol\";\nimport {MockERC20, MockFeed} from \"../utils/Mocks.sol\";\nimport {ParametersBase} from \"../../src/ParametersBase.sol\";\nimport {Ledger} from \"../../src/Ledger.sol\";\nimport {LedgerBase} from \"../../src/LedgerBase.sol\";\nimport {Join} from \"../../src/Join.sol\";\nimport {IFeed} from \"../../src/interfaces/IFeed.sol\";\nimport {Adjust} from \"../../src/interfaces/IPositions.sol\";\n\n/// @dev Permissions panel proofs: each test encodes the property the code's own NatSpec claims and fails on this commit.\ncontract ProofPermissionsTest is System {\n    bytes32 constant ETH = \"ETH\";\n\n    MockERC20 weth;\n    MockERC20 v1;\n    MockFeed feed;\n    Join ethJoin;\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        weth = new MockERC20(\"WETH\", 18);\n        v1 = new MockERC20(\"imdUSDv1\", 18);\n        feed = new MockFeed(2000e18);\n        _deploySystem(IERC20(address(v1)));\n        ethJoin = _addIlk(ETH, address(weth), feed, feed, IFeed(address(0)), address(0), address(0), true, _risk(1.4e18, 1_000_000e18));\n        params.setGenesisCap(ETH, 1500e18); // a borrow price ceiling below the market (Sky's capped OSM)\n        params.seal();\n        address[2] memory us = [alice, bob];\n        for (uint256 i; i < 2; ++i) {\n            weth.mint(us[i], 1000e18);\n            vm.prank(us[i]);\n            weth.approve(address(ledger), type(uint256).max);\n        }\n    }\n\n    function _open(address u, uint256 salt, uint256 coll, uint256 debt, uint64 rate) internal returns (uint256 id) {\n        vm.prank(u);\n        id = ledger.open(ETH, salt, Adjust(int256(coll), int256(debt), rate, 0, 0, false, type(uint256).max, address(0)));\n    }\n\n    /// Pricer.sol:28-29 and ParametersBase.sol:196-197 say a CHARGED RATE CHANGE is measured at `borrow = min(low, cap)`.\n    /// Ledger.setRate measures it at the uncapped `low` (Ledger.sol:168): with the cap binding, a borrow of 1 wei is\n    /// refused (Unhealthy) while a charged rate change adds premium debt to the same position.\n    function test_chargedRateChangeIgnoresBorrowCap() public {\n        uint256 id = _open(alice, 0, 10e18, 10_000e18, 0.05e18); // CR at the cap (1500): 150%\n        vm.prank(governor);\n        params.lowerCap(ETH, 1300e18); // CR at the cap: 130% < mat 140%; at `low` (2000): 200%\n        vm.prank(alice);\n        vm.expectRevert(LedgerBase.Unhealthy.selector);\n        ledger.adjust(id, Adjust(0, 1, 0, 0, 0, false, type(uint256).max, address(0)));\n        uint256 before = ledger.debtOf(id);\n        vm.prank(alice);\n        vm.expectRevert(LedgerBase.Unhealthy.selector); // what the Pricer/Parameters NatSpec promises\n        ledger.setRate(id, 0.06e18, 0, 0);\n        assertEq(ledger.debtOf(id), before, \"no premium debt added below the capped health line\");\n    }\n\n    /// LedgerBase.sol:164-166 and :320-321: while `unbacked()` is past the brake, \"no new debt on any type\". `_adjust`\n    /// checks the brake on a borrow (Ledger.sol:301); `setRate`'s charged premium adds debt without it (Ledger.sol:159-166).\n    function test_chargedRateChangeBypassesUnbackedBrake() public {\n        uint256 a = _open(alice, 0, 10e18, 10_000e18, 0.05e18); // CR at the cap (1500): 150%\n        uint256 b = _open(bob, 0, 100e18, 20_000e18, 0.05e18);\n        feed.set(500e18); // alice: 10 ETH = 5,000 against ~10,010 of debt; bob stays at CR 250%\n        vm.prank(bob);\n        liquidator.liquidate(a); // bob pays 5,000, ~5,010 is booked bad debt; the Treasury covers only its ~29 of fees\n        assertGt(ledger.unbacked(), usd.circulating() / 100, \"braked\");\n        vm.prank(bob);\n        vm.expectRevert(LedgerBase.Braked.selector);\n        ledger.adjust(b, Adjust(0, 1, 0, 0, 0, false, type(uint256).max, address(0)));\n        uint256 before = ledger.debtOf(b);\n        vm.prank(bob);\n        vm.expectRevert(LedgerBase.Braked.selector); // expected: Braked, as for any other new debt\n        ledger.setRate(b, 0.06e18, 0, 0);\n        assertEq(ledger.debtOf(b), before, \"no new debt while braked\");\n    }\n\n    /// LedgerBase.sol:126-127: \"reopening at another rate inside the cooldown pays the premium (a flash mint cannot\n    /// launder a rate drop)\". It holds for the same id only: repay, withdraw, and reopen under a fresh salt pays the\n    /// upfront fee at the type's average (the new rate itself when the type is otherwise empty), half the premium here.\n    function test_freshSaltDropsRateBelowOldRatePremium() public {\n        uint256 id0 = _open(alice, 0, 10e18, 10_000e18, 0.10e18);\n        vm.prank(address(ledger));\n        usd.mint(alice, 100e18); // covers the upfront fee so the repayment can clear the debt\n        uint256 premium = (10_000e18 * uint256(0.10e18) * 7 days + 365 days * 1e18 - 1) / (365 days * 1e18);\n        vm.startPrank(alice);\n        ledger.repayFor(id0, type(uint256).max);\n        ledger.adjust(id0, Adjust(-10e18, 0, 0, 0, 0, false, 0, address(0)));\n        uint256 id1 = ledger.open(ETH, 1, Adjust(10e18, 10_000e18, 0.05e18, 0, 0, false, type(uint256).max, address(0)));\n        vm.stopPrank();\n        uint256 feePaid = ledger.debtOf(id1) - 10_000e18;\n        emit log_named_uint(\"setRate premium (7d at 10%)\", premium);\n        emit log_named_uint(\"fresh-salt fee (7d at 5%)\", feePaid);\n        assertGe(feePaid, premium, \"a rate drop costs at least 7 days at the old rate\");\n    }\n}","reproduction":"ETH type, mat 140%, feed 2000, genesis cap 1500, no sleeve. Alice opens 10 ETH / 10,000 at 5%; Bob opens 100 ETH / 20,000 at 5%. Feed -> 500. Bob `liquidator.liquidate(aliceId)`: repaid 5,000, bad debt ~5,010; the Treasury covers only its ~29 imdUSD of upfront fees, `unbacked()` ~4,980 > 1% of ~25,000 circulating. Bob `adjust(bobId, dDebt = 1)` -> reverts Braked (expected). Bob `setRate(bobId, 0.06e18, 0, 0)` inside the cooldown -> expected: Braked like any other new debt; actual: succeeds, Bob's debt and the ETH type's debt rise by the premium (20,000 x 5% x 7/365 = 19.18 imdUSD) and that much imdUSD is minted while the brake is on. Test `test_chargedRateChangeBypassesUnbackedBrake` in the attached proof fails on this commit.","severity":"low","snippet":"            if (uint256(s.debt) + s.badDebt > params.risk(ilk).line) revert CeilingExceeded(); // `r` is the stored risk: the depth rule binds here\n            _checkLines(ilk);\n            _checkEdge(ilk);","title":"setRate's charged premium creates new debt while the unbacked-supply brake is on (no _checkBrake)"},{"citation":"resolved","description":"Answer to question 3 (the cheapest way to borrow at a lower rate than one's own recent rate). The per-position memory (`rate`, `lastRateChange`, `premiumRate`) makes repay-and-reopen on the SAME id, an operator (SET_RATE alone cannot pay a premium, Ledger.sol:158) and an NFT transfer (state travels with the id) all pay 7 days at the old rate. The cheapest path is a fresh id: repay to zero (flash mint if needed), withdraw the collateral (no price is needed at zero debt), open `positionId(owner, ilk, salt+1)` at the lower rate and borrow. That path pays only the F1 upfront fee: 7 days at the TYPE'S AVERAGE rate (LedgerBase._upfrontFee), and when the type is otherwise empty (`weighted == 0` after the repayment) at the borrower's OWN NEW rate. Ledger.sol:254-256 acknowledges the fresh-position route (\"costs at least the market's 7 days of interest\"), so this is the intended Liquity-v2 design and not a defect; but the sentence quoted here, and the setRate NatSpec, read as if a rate drop always costs 7 days at the old rate. In the beta most types have few borrowers, so 'the market's rate' is often the borrower's own new rate. Not an exploit: the amounts are 7 days of interest. Fix: reword the NatSpec (or, if the stronger property is wanted, charge the fresh open at max(average, the owner's rate on any of their positions in the type in cooldown), a design change). (The same unrun proof file, test `test_freshSaltDropsRateBelowOldRatePremium`, prints both figures.)","line":126,"path":"src/LedgerBase.sol","reproduction":"ETH type, Alice is the only borrower: open salt 0 with 10 ETH / 10,000 at 10%. setRate to 5% inside the cooldown would charge 10,000 x 10% x 7/365 = 19.18 imdUSD. Instead, same block: `repayFor(id0, max)`, `adjust(id0, dCollateral = -10e18)`, `open(ETH, 1, {10e18, 10,000, 5%})`: the only charge is the upfront fee 10,000 x 5% x 7/365 = 9.59 imdUSD (`UpfrontFee` event; `debtOf(id1) - 10,000`). Expected (per the quoted sentence): at least 19.18; actual: 9.59. Test `test_freshSaltDropsRateBelowOldRatePremium` in the attached proof shows the two figures.","severity":"info","snippet":"    /// costs what one reopen does. A position's rate and `lastRateChange` survive a repayment (or redemption) to\n    /// zero, so reopening at another rate inside the cooldown pays the premium (a flash mint cannot launder a rate drop).","title":"NatSpec: 'a flash mint cannot launder a rate drop' holds for the same id only; a fresh salt pays the type-average fee instead of 7 days at the old rate"},{"citation":"resolved","description":"On a chain with a Pulse (Robinhood; mainnet beta has none, `pulse == address(0)`), Liquidator.bite returns zeros while `Pulse.inGrace()` and `settle` reverts SequencerGrace, so no collateral is seized inside the grace that follows a silence longer than `gap`. Redeemer.redeem never reads the Pulse: it calls `pricer.quote` and walks the list; `Ledger.redeemPosition` -> `_accrue` pokes the Pulse (which can only START a grace) and ignores `inGrace`. So the first redemption after an outage takes collateral from the lowest-rate positions in the same block the chain returns, while a liquidation of the same positions would wait. Pulse.sol:7-8 frames the grace as protecting borrowers who could not act during the outage; a redemption also acts on them, though at $1 of collateral per imdUSD and without a penalty, so the harm is the lost chance to repay or re-rate first. Unreachable in BetaConfig (no Pulse). Severity info; if the design wants redemptions held too, add `if (address(ledger.pulse()) != 0) { pulse.poke(); if (inGrace) revert; }` at the top of `redeem`.","line":114,"path":"src/Redeemer.sol","reproduction":"Deploy with a Pulse(gap = 10 min, grace = 1 h) (RobinhoodConfig-style). Warp 2 hours with no poke (a silence). In the first block after: `liquidator.liquidate(id)` of an unhealthy position returns (0,0,0) and emits HeldByGrace; `redeemer.redeem(ilk, amount, maxFee, 10, 0)` in the same block succeeds and takes collateral from the lowest-rate position. Expected (if the grace is meant for borrowers): the redemption waits like the liquidation; actual: it runs.","severity":"info","snippet":"    function redeem(bytes32 ilk, uint256 amount, uint256 maxFee, uint256 maxPositions, uint256 minCollateralOut)","title":"Redeemer does not consult the Pulse: a redemption can run inside a sequencer grace that holds liquidations (question 13; not the beta chain)"},{"citation":"resolved","description":"`borrowFor` builds an Adjust with `rate = 0` and hands it to `Ledger.execute`. If the position's debt is zero when the relayer submits (repaid, redeemed to zero, liquidated, or harvested to zero between signing and inclusion), Ledger._adjust takes the `!hadDebt` branch and treats `a.rate = 0` as the opening rate: with `rateFloor == 0` (allowed by Parameters._checkRisk, which only bounds the floor above) the position would open at 0% and sit first in the redemption order, and, if its kept rate was non-zero and inside the cooldown, pay the premium for a rate change the signer never asked for. With every BetaConfig floor >= 0.5% the call reverts RateOutOfRange, so in the beta the claim fails closed (a liveness nit: a signed borrow on a yield position whose harvest just cleared the debt reverts). Fix: in Ledger.execute (or `_adjust` when `authorized` came from BorrowFor) revert if `p.debt == 0` and `a.rate == 0`, or have `borrowFor` sign and pass the position's current rate.","line":141,"path":"src/PositionNFT.sol","reproduction":"Type with `rateFloor = 0` (allowed). Owner signs BorrowFor(id, 1,000, to, maxFee) on a position with debt at 5%; before the relayer submits, the owner repays to zero (or a redemption takes it to zero). Relayer calls `borrowFor`: expected per NatSpec: revert (no debt); actual: the position reopens at rate 0% (and, inside the cooldown, pays a 5% premium on the 1,000). With BetaConfig floors (>= 0.005e18) the call reverts RateOutOfRange instead.","severity":"info","snippet":"    /// @notice Borrow `amount` on position `id` (which must already have debt: the rate is the position's) to `to` (zero:","title":"borrowFor NatSpec says the position 'must already have debt'; nothing enforces it (fails closed in the beta only because every rateFloor > 0)"}],"hash":"229e0e130ca21284ce2709e99581ae3f2b4178f8602a8478cbb84e5faa3ac286","nodeId":"755b89e0-9f70-4801-8426-cd50cf99a462","outcome":"completed","summary":"Review complete. The findings file `.imd-findings.json` holds 5 findings (2 low, 3 info); the tree is left unchanged (scratch test removed).\n\n**Build status, stated first.** Every `forge test` on this machine compiled the whole tree (196 to 198 files, via_ir) and solc was OOM-killed three times (31 GB, no swap), with and without `--skip`. The panel suite and my proof file therefore did not run here. The proof file source is attached to the two low findings with the expected result; the verifier must run it.\n\n**Findings**\n\n- **Low. setRate measures a charged rate change at uncapped `low`** (`src/Ledger.sol:168`). Pricer.sol:28-29 and ParametersBase.sol:196-197 promise the borrow cap binds a charged rate change. With a cap below market, a 1-wei borrow reverts Unhealthy while setRate still adds premium debt. Fix: use `q.borrow` there or correct the NatSpec.\n- **Low. setRate's premium bypasses the unbacked brake** (`src/Ledger.sol:162-164`). The charged branch runs line, chain-line and edge checks but not `_checkBrake()`, unlike `_adjust` (line 301). New debt is minted while braked, bounded to 7 days of interest per call. Fix: add `_checkBrake()`.\n- **Info. Q3 answer.** Cheapest rate drop is a fresh salt: repay to zero (flash mint), withdraw at zero debt, reopen lower. It pays the upfront fee at the type average (the new rate itself when the type is otherwise empty), half the old-rate premium in the example. Design-accepted at Ledger.sol:254-256, but LedgerBase.sol:126-127 overstates the guarantee.\n- **Info. Q13.** Redeemer never consults the Pulse, so a redemption runs inside a sequencer grace that holds bites. Not reachable in the beta (no Pulse).\n- **Info.** `borrowFor` NatSpec says the position must already have debt; nothing enforces it. Fails closed in the beta only because every rateFloor is positive.\n\n**Question answers (nothing wrong unless noted)**\n\n1. Debt bookkeeping holds: every write to position debt/rate goes through `_record`, aggregate rounds up per accrual and positions down over their whole span, so `s.debt >= Σ debtOf`; fees, premiums and interest are minted exactly as added to `s.debt`.\n2. Harvest holds: yield only above the per-position mark, at `q.low`, skipped while unpriced; `_blendMark` keeps unharvested yield invariant on deposit, so flash deposits and NFT transfers gain nothing; a dip never lowers the mark.\n3. See the info finding above.\n4. Risk versions hold: `_bindAt` compares against the version in force and all pending ones, postpones softer pendings, and `liqRisk` applies only newer bound versions. Taking risk rebinds at the stored (new) mat, consistently.\n5. Rights hold: `execute` is NFT-only with owner check; signatures cover every value-moving field; sequential nonces with `bumpNonce`; per-id rights keyed by epoch; holder-bound units only in from and out to the owner. One NatSpec gap (borrowFor) noted.\n6. Sorted list holds: `_relist` keyed on recorded debt before and after each path; `_valid` fully checks adjacency and order, else `_walk` from head or a valid lower start.\n7. Split holds: both contracts add only immutables over LedgerBase, same slot layout; the transient lock is on the Ledger's storage; the module's three entry points revert DirectCall, remaining surface is views on empty storage.\n8. Mark/grace/bite hold: bite re-checks unhealthy at `low`, expiry uses a priced clock whose span is closed before `pausedAtMark` is read, marker chip cannot be taken while a mark is live; `clear` needs health.\n9. Standing bid: sleeve pays at `q.low` only when legs agree; `closeBid` enforces `received x low >= spent` and room. Partial liquidation math leaves CR at `mat + buffer` and never under dust, but `targetBuffer` is 0 in BetaConfig so it is unreached.\n10. Bad debt: Treasury orders edge sleeve first; `cover` is permissionless; 2M gas floor holds. Known-issue behaviour confirmed, not worse.\n11. Redemption: payout is max of legs, skip on underwater positions, dust rule, window cap conservative over 1","treeHash":null,"usage":{"cachedInputTokens":3228169,"cost":{"source":"runtime","usdMicros":8567133},"inputTokens":486,"model":"claude-fable-5-1","outputTokens":70244,"runtime":"claude","turns":35,"wallClockMs":2967004}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bfeaa965edf57623","findings":[{"citation":"resolved","description":"_harvest moves yieldShares (worth Y at q.low) out of the position and credits only (1 - yieldFee) * Y against its debt. That changes the health ratio from C/D to (C - Y)/(D - (1-f)Y), which is LOWER whenever C/D < 1/(1-f). With BetaConfig S-USDS and S-USDC-STEAKHOUSE (mat 1.08, yieldFee 0.1, so 1/(1-f) = 111%), every position between 108% and 111% loses health when it is harvested. Ledger.harvest(ilk, ids) (src/Ledger.sol:181) has no access control. Ledger.healthy (src/Ledger.sol:471-477), Liquidator._requireUnhealthy and LedgerSettlement.liquidatePosition all read the UN-harvested state (_debtOf, p.collateral; liquidatePosition never calls _harvest). So a position that the protocol itself reports healthy, with no price move, can be made liquidatable by a third party in the same transaction (harvest, then liquidate, taking the marker chip or the caller-path collateral at a 5% penalty). The mirror case is a type where mat*(1-f) > 1 (WSTETH: 1.45*0.9): there a pending harvest would RAISE health, but liquidation ignores it, so a keeper liquidates a position that its own harvest would heal. Either way the liquidator picks whichever of the two states is worse for the borrower. This also falsifies the LedgerBase NatSpec (lines 82-84, 'on every touch (and on harvest) the shares that are pure yield ... move to the Treasury'): liquidatePosition and writeOff touch without harvesting. Call sequence: Bob -> Ledger.harvest(S-USDS, [aliceId]) -> Liquidator.liquidate(aliceId). Reachable with BetaConfig values (S-USDS 12.5k line, S-USDC 12.5k line; mat 1.08, yieldFee 0.1, chop 0.05, chip 0.1). Smallest fix: measure liquidation health on the post-harvest state. Either have LedgerSettlement.liquidatePosition and Ledger.healthy apply the pending harvest (a view version of _harvest for healthy(), and _harvest + _record at the top of liquidatePosition), so the harvest is no longer a choice the liquidator gets to make. Or, more narrowly, have _harvest keep the mark and skip when the harvest would leave a position that is healthy at q.low unhealthy at its bound mat. Severity medium: it needs a position within about (yield x fee) of mat, and the loss is the liquidation penalty (5% of debt) on positions that were reported healthy.","line":372,"path":"src/LedgerBase.sol","reproduction":"State: S-USDS type with BetaConfig risk (mat 108%, chop 5%, yieldFee 10%). Alice opens 10,000 shares at $1 (rate 1.0) with about 9,124 imdUSD of debt at 6%, then never touches it. A year later the share rate is 1.045 and the price $1.045. Her debt is about 9,671 and her collateral is worth 10,450: ledger.healthy(id) = (true, priced) at a ratio of 1.080562, and Liquidator.liquidate(id) reverts Healthy(). Bob calls ledger.harvest(S-USDS, [id]) and her ratio becomes 1.079227 at the same price in the same block. Expected: still healthy, liquidate reverts Healthy. Actual: healthy(id) = false and Bob's Liquidator.liquidate(id) succeeds and seizes her collateral with the 5% penalty. Proof (forge test --match-path test/scratch/ProofHarvestHealth.t.sol, solc 0.8.26 via_ir), output:\nRan 2 tests for test/scratch/ProofHarvestHealth.t.sol:ProofHarvestHealth\n[FAIL: next call did not revert as expected] test_harvestThenLiquidateInOneGo() (gas: 2236073)\n[FAIL: a third party's harvest turned a healthy position unhealthy] test_thirdPartyHarvestMakesHealthyPositionLiquidatable() (gas: 1927636)\nLogs:\n  ratio before harvest: 1.080561789518469992\n  ratio after harvest : 1.079226519179238607\nSuite result: FAILED. 0 passed; 2 failed; 0 skipped","severity":"medium","snippet":"        uint256 net = Math.mulDiv(Math.mulDiv(yieldShares, q.low, 1e18), 1e18 - r.yieldFee, 1e18);","title":"Anyone can harvest a healthy position on a yield-stable type into liquidation: health is measured before the pending yield fee"},{"citation":"resolved","description":"src/Pricer.sol:196 says '`borrow = min(low, Parameters.cap)`: the price new debt, withdrawals and a charged rate change are measured at.' But Ledger.setRate checks the charged premium's health at q.low, and its own comment (line 167) says the cap does not apply. With a governed Parameters.cap below market, an owner can add premium debt through a charged setRate that an equivalent borrow would be refused for. The amount is bounded by 7 days of interest at at most 100%. One of the two needs to change: the NatSpec, or the check (use q.borrow).","line":168,"path":"src/Ledger.sol","reproduction":"Parameters.cap(ilk) = 0.9 x low. A position at exactly mat measured at low (ratio 1.0 x mat at low, so below mat at the cap). Inside its cooldown it calls setRate(newRate) with a premium p > 0. Expected per Pricer NatSpec: reverts Unhealthy (measured at borrow = cap). Actual: succeeds whenever collateral x low >= (debt + p) x mat.","severity":"info","snippet":"            if (!_healthy(p.collateral, debt, q.low, q.mat)) revert Unhealthy();","title":"Pricer NatSpec says a charged rate change is measured at the capped borrow price; Ledger.setRate measures it at the uncapped low"},{"citation":"resolved","description":"Documentation only, no behaviour change. Redeemer.sol:19 carries a stray '(-10-30)'. Pricer.sol:210-213 (the `agree` field NatSpec) is broken mid-sentence: 'The standing bid reads it, and a charged rate change / : failing it sends a liquidation to the caller path'. Read literally, it also says a charged rate change only needs `agree`, but setRate also requires `borrowable` (Ledger.sol:166).","line":19,"path":"src/Redeemer.sol","reproduction":"Read src/Redeemer.sol:19 and src/Pricer.sol:210-213 against Ledger.sol:166 (`if (!q.borrowable || !q.agree) revert NotPriced();`).","severity":"info","snippet":"/// The fee's collateral leaves the redeemed position for the Treasury (-10-30): were it left in the","title":"Garbled NatSpec in Redeemer header and Pricer.Quote.agree (the latter also omits that a charged setRate needs borrowable)"}],"hash":"4138b2168aad628d278f9b4e57d206b918953859951dff937e13a6202903a8dc","nodeId":"3dc6298b-fa7f-4cf7-b09c-a907c7ada1be","outcome":"completed","summary":"I found one medium-severity defect, with a Foundry proof that fails on this commit under solc, plus two documentation-only items. All three are in `.imd-findings.json`. All 13 questions are answered below. I deleted the proof file afterwards, and `git status` shows the tree unchanged.\n\nI did not run the panel suites (`Core`, `Positions`, `Redemption`, `StandingBid`, `ReviewLedger`, `PanelLiquidation`); I only built and ran my own proof file. The twelve specialty passes were done by me alone, with no helpers. I spent most of the depth on Ledger, LedgerBase, LedgerSettlement, Liquidator, Redeemer, Pricer and the Backstop bid and cover paths. I read Parameters only for `liqRisk` and `_bindAt`.\n\n## Findings\n\n**1. Medium: anyone can harvest a healthy position on a yield-stable type into liquidation** (`src/LedgerBase.sol:372`)\n```solidity\nuint256 net = Math.mulDiv(Math.mulDiv(yieldShares, q.low, 1e18), 1e18 - r.yieldFee, 1e18);\n```\n- **Mechanism:** a harvest removes yield worth Y from the collateral but takes only (1 − yieldFee)·Y off the debt. The health ratio C/D becomes (C − Y)/(D − (1 − f)·Y), which is lower whenever C/D is below 1/(1 − f).\n- **Why it matters in the beta:** S-USDS and S-USDC-STEAKHOUSE in BetaConfig have mat 1.08 and yieldFee 0.1. Every position between 108% and 111% therefore loses health when it is harvested.\n- **Why a third party can trigger it:** `Ledger.harvest` has no access control. `Ledger.healthy`, `Liquidator._requireUnhealthy` and `liquidatePosition` all read the state before the harvest; `liquidatePosition` never harvests.\n- **Call sequence:** Bob → `Ledger.harvest(S-USDS, [aliceId])` → `Liquidator.liquidate(aliceId)`, with no price move, in one transaction.\n- **Mirror case:** on WSTETH (1.45 × 0.9 > 1) a harvest would raise health, but liquidation ignores it. In both cases the liquidator effectively picks whichever state is worse for the borrower.\n- **Concrete state:** Alice has 10,000 shares and about 9,124 debt at 6%. After a year the share rate is 1.045. She reads healthy at a ratio of 1.080562 and `liquidate` reverts `Healthy`. After Bob's harvest her ratio is 1.079227 at the same price, and `liquidate` succeeds with the 5% penalty.\n- **Proof output:** `[FAIL: a third party's harvest turned a healthy position unhealthy]` and `[FAIL: next call did not revert as expected]`.\n- **Reachable with BetaConfig:** yes.\n- **Smallest fix:** measure liquidation health after the harvest. Run `_harvest` and `_record` at the top of `liquidatePosition`, and make `healthy()` apply the pending harvest.\n- This also breaks the NatSpec at `LedgerBase.sol:82-84` (\"on every touch\"): `liquidatePosition` and `writeOff` touch a position without harvesting it.\n\n**2. Info: rate-change NatSpec mismatch** (`src/Ledger.sol:168`). `Pricer.sol:196` says a charged rate change is measured at the capped `borrow` price. `setRate` measures it at the uncapped `q.low`. With a governed cap below market, a charged rate change adds premium debt that an equivalent borrow would be refused.\n\n**3. Info: garbled NatSpec.** `Redeemer.sol:19` has a stray \"(-10-30)\". The `Quote.agree` NatSpec at `Pricer.sol:210-213` breaks off mid-sentence, and read as written it omits that a charged `setRate` also needs `borrowable` (`Ledger.sol:166`).\n\n## The 13 questions\n\n1. **Debt bookkeeping: holds.**\n   - `_accrue` rounds the type's interest up (`Ceil`) and `_debtOf` rounds each position down.\n   - `weighted` only changes in `_record`, so between records the aggregate and each position accrue the same simple interest on recorded debt.\n   - Every path that changes debt moves the type and the position together: upfront fee and premium are added to both, and harvest, redemption, liquidation and write-off subtract from both.\n   - The type's debt is never below the sum of its positions; the gap is a residue of a few wei per accrual. Minted interest equals what positions owe plus that residue, which stays in `s.debt`, so the supply equation still balances.\n2. **","treeHash":null,"usage":{"cachedInputTokens":2746774,"cost":{"source":"runtime","usdMicros":2871610},"inputTokens":48,"model":"claude-opus-5-5","outputTokens":54294,"runtime":"claude","turns":27,"wallClockMs":783414}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1eba9b0f789fcc77","findings":[{"citation":"resolved","description":"Merged from audit_economics (WSTETH side) and audit_math (S-USDC side); same root cause, both reproduced. Every borrower touch (adjust, setRate, harvest, redeemPosition) runs _harvest first: the yield shares since yieldMark leave the position and (1 - yieldFee) of their value is credited against the debt. Liquidation does not: Ledger.healthy (Ledger.sol:471-477) measures _debtOf(p) against p.collateral, and LedgerSettlement.liquidatePosition line 120 takes debt = _debtOf(p) with no _harvest, so the liquidator chooses which of the two states the borrower is measured in. The harvest changes the ratio from C/D to (C-Y)/(D-(1-f)YP): it LOWERS the ratio when CR < 1/(1-f) = 111% (BetaConfig S-USDC-STEAKHOUSE and other _stable types: mat 1.08, yieldFee 0.1, chop 0.05) and RAISES it when (1-f) x mat > 1 (BetaConfig WSTETH: 0.9 x 1.45 = 1.305, chop 0.1). S-USDC: Ledger.harvest(ilk, ids) is permissionless (Ledger.sol:181), so Bob calls harvest then Liquidator.liquidate in one block at an unchanged price on a position the Ledger itself reported healthy and liquidate() had just refused; Alice pays the 5% chop and Bob the marker's chip. WSTETH: a keeper simply does not call harvest; a position between about 143.2% and 145% after a year of 3% staking yield reads unhealthy and is closed at 10% penalty, yield shares included in what is seized, though its own pending harvest would leave it healthy. liquidationNeed and the standing bid's need use the same un-harvested debt. Reachable with BetaConfig values (WSTETH line 50k, S-USDC line 12.5k). Also falsifies the NatSpec at LedgerBase.sol:82-84 / Ledger.sol:43-45 ('on every touch (and on harvest) the shares that are pure yield ... move to the Treasury'): liquidatePosition and writeOff touch without harvesting. Smallest fix: apply the pending harvest in liquidation: run _harvest (and _record) at the top of LedgerSettlement.liquidatePosition as redeemPosition does at line 73, and make Ledger.healthy / liquidationNeed compute on the post-harvest state (collateral - yieldShares, debt - credit) via a view twin of _harvest, so health is one well-defined function of state and price. Severity medium: needs a position within (yield x fee) of mat; loss is the liquidation penalty on a position the protocol reports healthy, or a liquidation of a position whose own harvest heals it.","line":120,"path":"src/LedgerSettlement.sol","reproduction":"Proof test/scratch/ProofHarvestHealth.t.sol, run `forge test --match-path 'test/scratch/ProofHarvestHealth.t.sol' -vv` (solc 0.8.26 via_ir, also under --use solar). S-USDC: type mat 1.08e18, chop 0.05e18, yieldFee 0.1e18, rateFloor 0.005e18, one feed at 1e18 in both legs, yieldRate 1e18. Alice opens 10,000 shares with 9,112e18 debt at 6% (upfront fee 10.5 at her own rate). Warp 365 days; rate 1.045e18, feed 1.045e18. Before: collateral worth 10,450 vs debt 9,669.8 x 1.08 = 10,443: healthy(id) = true, liquidate(id) reverts Healthy. Bob calls ledger.harvest(SUSDC,[id]): collateral 9,569.38 shares (worth 10,000.0), debt 9,264.83, 9,264.83 x 1.08 = 10,006 > 10,000: healthy(id) = false, liquidate(id) succeeds. Expected: a position reported healthy cannot be made liquidatable by a third party at the same price. Actual: it is. WSTETH: mat 1.45e18, chop 0.1e18, yieldFee 0.1e18. Alice 10 wstETH at 2000 with 13,700 debt at 1%. Warp 365 days, rate 1.03e18, feed 1995e18. debt 13,837; before harvest 19,950 < 20,064 (unhealthy); after the pending harvest (Y 0.2913 shares, credit 523) 19,369 >= 19,305 (healthy). Expected: liquidate reverts Healthy (it does if anyone calls harvest first, control test passes). Actual: liquidated with 10% penalty. Output: [FAIL: a third party's harvest turned a healthy position unhealthy] test_susdc_thirdPartyHarvestMakesHealthyPositionLiquidatable() (gas: 1542549) Logs: collateral after harvest: 9569377990430622009570, debt after harvest: 9264834143561643835617; [FAIL: next call did not revert as expected] test_wsteth_healthyAfterPendingHarvestIsLiquidated() (gas: 1544338); [PASS] test_wsteth_sameStateAfterHarvestIsNotLiquidatable() (gas: 1425590).","severity":"medium","snippet":"        uint256 debt = _debtOf(p);","title":"Liquidation health ignores the pending yield harvest: on S-USDC (mat 108%) anyone can harvest a healthy position into liquidation; on WSTETH (mat 145%) a position that is healthy after its own harvest"},{"citation":"resolved","description":"From audit_flow; reproduced. LedgerBase.sol:125-127 and Ledger.sol:281-283 promise that debt added inside the cooldown of a charged rate change pays RATE_COOLDOWN of interest at the rate BEFORE that change, so 'a dust reopen followed by a top-up costs what one reopen does'. setRate line 172 overwrites p.premiumRate with the CURRENT rate on every charged change, and line 159 charges the change's own premium only on the position's current debt. Inside the cooldown the owner repays to dust (no price needed), changes the rate twice (old -> X -> target; both premiums are on about 101 imdUSD, the second sets premiumRate = X, any X >= rateFloor), then borrows the whole amount back: the C2 top-up premium (Ledger.sol:285-287) is charged at X instead of the old rate. Bounded: the borrower still pays the upfront fee at the type's average on the re-borrow, so the route is never cheaper than a fresh position; what is lost is the 7 days at the OLD rate the premium was meant to collect, and the position keeps its id, operator grants and collateral. Only owner calls (Ledger.adjust, Ledger.setRate) are needed; reachable with BetaConfig (rateFloor 0.5-1%, dust 100e18). Smallest fix: in setRate, when inCooldown && newRate != oldRate, keep an already-set premiumRate (p.premiumRate = p.premiumRate != 0 ? p.premiumRate : uint64(oldRate)); mirror on the reopen path at Ledger.sol:276.","line":172,"path":"src/Ledger.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {ParametersBase} from \"src/ParametersBase.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {Pricer} from \"src/Pricer.sol\";\nimport {Treasury, ICoverable} from \"src/Treasury.sol\";\nimport {PositionNFT} from \"src/PositionNFT.sol\";\nimport {Ledger, IPulse} from \"src/Ledger.sol\";\nimport {LedgerBase} from \"src/LedgerBase.sol\";\nimport {Redeemer} from \"src/Redeemer.sol\";\nimport {Liquidator} from \"src/Liquidator.sol\";\nimport {Migrator} from \"src/Migrator.sol\";\nimport {Join} from \"src/Join.sol\";\nimport {IFeed} from \"src/interfaces/IFeed.sol\";\nimport {Adjust} from \"src/interfaces/IPositions.sol\";\n\ncontract PLToken is ERC20 {\n    constructor() ERC20(\"WETH\", \"WETH\") {}\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\ncontract PLFeed is IFeed {\n    uint256 public value;\n    uint64 public updatedAt;\n    constructor(uint256 v) { value = v; updatedAt = uint64(block.timestamp); }\n    function set(uint256 v) external { value = v; updatedAt = uint64(block.timestamp); }\n    function latestValue() external view returns (uint256, uint64) { return (value, updatedAt); }\n    function isStale() external view returns (bool) { return block.timestamp - updatedAt > 365 days; }\n    function maxAge() external pure returns (uint256) { return 365 days; }\n}\n\n/// @dev Q3: the C2 rule says debt added inside the cooldown of a charged rate change pays RATE_COOLDOWN of interest at the\n/// rate BEFORE that change (\"a dust reopen followed by a top-up costs what one reopen does\"). Two in-cooldown setRate\n/// calls on a dust-sized debt overwrite `premiumRate` with the freely chosen intermediate rate, so the top-up pays the\n/// premium at that rate (here 2%) instead of the original 10%.\ncontract ProofPremiumLaunder is Test {\n    bytes32 constant ETH = \"ETH\";\n    address governor = makeAddr(\"governor\");\n    address alice = makeAddr(\"alice\");\n\n    ImdUSD usd;\n    Parameters params;\n    Pricer pricer;\n    Treasury treasury;\n    Ledger ledger;\n    PLToken weth;\n    PLFeed feed;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        weth = new PLToken();\n        feed = new PLFeed(2000e18);\n        PLToken v1 = new PLToken();\n\n        usd = new ImdUSD(address(this), 10_000_000e18);\n        params = new Parameters(governor, address(this));\n        pricer = new Pricer(params);\n        treasury = new Treasury(governor, IERC20(address(usd)), address(this));\n        PositionNFT nft_ = new PositionNFT(address(this));\n        ledger = new Ledger(usd, params, pricer, address(treasury), address(this), IPulse(address(0)), nft_);\n        nft_.bind(address(ledger));\n        Redeemer redeemer = new Redeemer(ledger);\n        Liquidator liquidator = new Liquidator(ledger, pricer);\n        ledger.wire(address(redeemer), address(liquidator));\n        Migrator migrator = new Migrator(IERC20(address(v1)), usd, params);\n        usd.wire(address(ledger), address(migrator), address(params));\n        params.wire(address(ledger), address(usd));\n        treasury.wire(ICoverable(address(ledger)));\n\n        Join join = new Join(address(weth), address(this));\n        join.wire(address(ledger));\n        params.addGenesisIlk(\n            ETH,\n            ParametersBase.Config({\n                token: address(weth), join: address(join), median: feed, spot: feed, nhi: IFeed(address(0)),\n                yieldRate: address(0), pegAggregator: address(0), liveSpot: IFeed(address(0)), redeemable: true, auction: false\n            }),\n            ParametersBase.Risk({\n                mat: 1.4e18, chop: 0.1e18, cut: 0.5e18, chip: 0.1e18, rateFloor: 0.01e18, yieldFee: 0, skew: 0.05e18,\n                pegFloor: 0, line: 1_000_000e18, dust: 100e18, redeemFloor: 0.005e18, redeemDivisor: 2, redeemCap: 0\n            })\n        );\n        params.seal();\n\n        weth.mint(alice, 100e18);\n        vm.startPrank(alice);\n        weth.approve(address(ledger), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function _premium(uint256 amount, uint256 rate) internal pure returns (uint256) {\n        return (amount * rate * 7 days + 365 days * 1e18 - 1) / (365 days * 1e18);\n    }\n\n    function test_twoInCooldownRateChangesLaunderThePremiumRate() public {\n        uint256 id = ledger.positionId(alice, ETH, 0);\n        // Alice borrows 10,000 at 10%: lastRateChange = now, so a change inside 7 days is charged at 10%.\n        vm.prank(alice);\n        ledger.open(ETH, 0, Adjust(int256(100e18), int256(10_000e18), 0.10e18, 0, 0, false, type(uint256).max, address(0)));\n        vm.warp(block.timestamp + 1 days);\n\n        // The honest price of dropping 10,000 of debt from 10% to 1% inside the cooldown: 7 days at 10%.\n        uint256 honest = _premium(10_000e18, 0.10e18);\n\n        // 1. Repay down to dust (free, no price needed).\n        uint256 debt = ledger.debtOf(id);\n        vm.prank(alice);\n        ledger.adjust(id, Adjust(0, -int256(debt - 101e18), 0, 0, 0, false, type(uint256).max, address(0)));\n        assertApproxEqAbs(ledger.debtOf(id), 101e18, 1);\n\n        // 2. Two rate changes on the dust: 10% -> 2% (charged at 10% on 101) then 2% -> 1% (charged at 2% on ~101).\n        vm.startPrank(alice);\n        ledger.setRate(id, 0.02e18, 0, 0);\n        ledger.setRate(id, 0.01e18, 0, 0);\n        vm.stopPrank();\n        LedgerBase.Position memory p = ledger.position(id);\n        assertEq(p.rate, 0.01e18);\n        uint256 dust = p.debt;\n        uint256 paidSoFar = dust - 101e18; // the two tiny premiums (about 0.19 + 0.04 imdUSD)\n\n        // 3. Top up the 9,900 back inside the cooldown.\n        ledger.accrue(ETH);\n        uint256 upfront = ledger.upfrontFeeFor(ETH, 9_899e18, 0.01e18);\n        vm.prank(alice);\n        ledger.adjust(id, Adjust(0, int256(9_899e18), 0, 0, 0, false, type(uint256).max, address(0)));\n        uint256 topUpPremium = ledger.debtOf(id) - dust - 9_899e18 - upfront;\n\n        emit log_named_uint(\"honest premium (7d at 10% on 10,000)\", honest);\n        emit log_named_uint(\"premiumRate after the two changes   \", p.premiumRate);\n        emit log_named_uint(\"premium charged on the 9,899 top-up \", topUpPremium);\n        emit log_named_uint(\"total premium paid over the sequence\", topUpPremium + paidSoFar);\n\n        // Documented rule (LedgerBase 125-127, Ledger 281-283): the top-up pays at the rate BEFORE the charged change (10%).\n        // Actual: `premiumRate` is the freely chosen intermediate rate (2%), and the top-up pays about a fifth.\n        assertEq(p.premiumRate, 0.10e18, \"premiumRate should still be the rate the cooldown was entered at\");\n        assertGe(topUpPremium + paidSoFar, honest, \"the sequence must cost at least one honest rate change\");\n    }\n}","reproduction":"Proof test/scratch/ProofPremiumLaunder.t.sol, `forge test --match-path 'test/scratch/ProofPremiumLaunder.t.sol' -vv`. ETH type mat 1.4e18, rateFloor 0.01e18, dust 100e18, price 2000. Alice opens 100 ETH / 10,000 at 10%. At +1 day: adjust dDebt = -(debt - 101e18); setRate(id, 2%) (premium 0.19, premiumRate 10%); setRate(id, 1%) (premium 0.04, premiumRate becomes 2%); adjust dDebt = +9,899e18. Expected: premiumRate stays 10% and the top-up pays 7 days at 10% on 9,899 = 18.98. Actual: premiumRate 2%, top-up premium 3.7969, whole sequence 4.03 against 19.18 for one honest change. Output: [FAIL: premiumRate should still be the rate the cooldown was entered at: 20000000000000000 != 100000000000000000] test_twoInCooldownRateChangesLaunderThePremiumRate() (gas: 1656559) Logs: honest premium (7d at 10% on 10,000): 19178082191780821918; premiumRate after the two changes: 20000000000000000; premium charged on the 9,899 top-up: 3796876712328767124; total premium paid over the sequence: 4029389363858134736.","severity":"low","snippet":"        if (newRate != oldRate) p.premiumRate = inCooldown ? uint64(oldRate) : 0;","title":"Cooldown premium laundering: two in-cooldown setRate calls on a dust debt overwrite premiumRate with a chosen rate, so the top-up pays about 1/5 of the documented premium"},{"citation":"resolved","description":"Merged from audit_permissions (two findings) and audit_math (cap NatSpec); both reproduced. The charged branch of Ledger.setRate adds the cooldown premium to p.debt and s.debt (159-161), mints it (165), and checks line, chain line and edge (162-164), but (a) checks health at q.low (168) with the comment 'the cap does not measure its premium', while Pricer.sol:28-29 and ParametersBase.sol:196-197 say 'borrow = min(low, cap): the price new debt, withdrawals and a charged rate change are measured at'; and (b) never calls _checkBrake(), which _adjust runs on every borrow (Ledger.sol:301) and which LedgerBase.sol:164-166/320-321 describe as 'no new debt on any type until cover'. So with a governed cap below market, a 1-wei borrow on a position is refused Unhealthy while the same owner adds premium debt through setRate; and while the brake is on, a borrower still raises the type's debt and mints imdUSD through a charged rate change. Impact bounded to 7 days of interest on the position's debt per change (each change restarts the cooldown). Cap case reachable once the governor sets a cap (lowerCap is instant); brake case reachable in BetaConfig whenever booked bad debt exceeds 1% of circulating and the Treasury is thin. Smallest fix: measure line 168 at q.borrow and add _checkBrake() after _checkEdge(ilk) at line 164; or correct the Pricer/Parameters NatSpec if the uncapped measurement is intended (the brake gap still needs the call).","line":159,"path":"src/Ledger.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {ParametersBase} from \"src/ParametersBase.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {Pricer} from \"src/Pricer.sol\";\nimport {Treasury, ICoverable} from \"src/Treasury.sol\";\nimport {PositionNFT} from \"src/PositionNFT.sol\";\nimport {Ledger, IPulse} from \"src/Ledger.sol\";\nimport {LedgerBase} from \"src/LedgerBase.sol\";\nimport {Redeemer} from \"src/Redeemer.sol\";\nimport {Liquidator} from \"src/Liquidator.sol\";\nimport {Migrator} from \"src/Migrator.sol\";\nimport {Join} from \"src/Join.sol\";\nimport {IFeed} from \"src/interfaces/IFeed.sol\";\nimport {Adjust} from \"src/interfaces/IPositions.sol\";\n\ncontract SRToken is ERC20 {\n    constructor() ERC20(\"WETH\", \"WETH\") {}\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\ncontract SRFeed is IFeed {\n    uint256 public value;\n    uint64 public updatedAt;\n    constructor(uint256 v) { value = v; updatedAt = uint64(block.timestamp); }\n    function set(uint256 v) external { value = v; updatedAt = uint64(block.timestamp); }\n    function latestValue() external view returns (uint256, uint64) { return (value, updatedAt); }\n    function isStale() external view returns (bool) { return block.timestamp - updatedAt > 365 days; }\n    function maxAge() external pure returns (uint256) { return 365 days; }\n}\n\n/// @dev setRate's charged premium is new debt (minted, added to s.debt and p.debt) but skips two checks every borrow runs in\n/// Ledger._adjust: the borrow-price cap (`q.borrow`, Pricer NatSpec: \"a charged rate change\" is measured at min(low, cap))\n/// and the unbacked-supply brake (`_checkBrake`, \"no new debt on any type\").\ncontract ProofSetRateChecks is Test {\n    bytes32 constant ETH = \"ETH\";\n    address governor = makeAddr(\"governor\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    ImdUSD usd;\n    Parameters params;\n    Pricer pricer;\n    Treasury treasury;\n    Ledger ledger;\n    Liquidator liquidator;\n    SRToken weth;\n    SRFeed feed;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        weth = new SRToken();\n        feed = new SRFeed(2000e18);\n        usd = new ImdUSD(address(this), 10_000_000e18);\n        params = new Parameters(governor, address(this));\n        pricer = new Pricer(params);\n        treasury = new Treasury(governor, IERC20(address(usd)), address(this));\n        PositionNFT nft_ = new PositionNFT(address(this));\n        ledger = new Ledger(usd, params, pricer, address(treasury), address(this), IPulse(address(0)), nft_);\n        nft_.bind(address(ledger));\n        Redeemer redeemer = new Redeemer(ledger);\n        liquidator = new Liquidator(ledger, pricer);\n        ledger.wire(address(redeemer), address(liquidator));\n        Migrator migrator = new Migrator(IERC20(address(new SRToken())), usd, params);\n        usd.wire(address(ledger), address(migrator), address(params));\n        params.wire(address(ledger), address(usd));\n        treasury.wire(ICoverable(address(ledger)));\n\n        Join join = new Join(address(weth), address(this));\n        join.wire(address(ledger));\n        params.addGenesisIlk(\n            ETH,\n            ParametersBase.Config({\n                token: address(weth), join: address(join), median: feed, spot: feed, nhi: IFeed(address(0)),\n                yieldRate: address(0), pegAggregator: address(0), liveSpot: IFeed(address(0)), redeemable: true, auction: false\n            }),\n            ParametersBase.Risk({\n                mat: 1.4e18, chop: 0.1e18, cut: 0.5e18, chip: 0.1e18, rateFloor: 0.01e18, yieldFee: 0, skew: 0.05e18,\n                pegFloor: 0, line: 1_000_000e18, dust: 100e18, redeemFloor: 0.005e18, redeemDivisor: 2, redeemCap: 0\n            })\n        );\n        params.setGenesisCap(ETH, 1500e18); // a borrow-price ceiling below the market (Sky's capped OSM)\n        params.seal();\n\n        address[2] memory us = [alice, bob];\n        for (uint256 i; i < 2; ++i) {\n            weth.mint(us[i], 1000e18);\n            vm.startPrank(us[i]);\n            weth.approve(address(ledger), type(uint256).max);\n            usd.approve(address(ledger), type(uint256).max);\n            vm.stopPrank();\n        }\n    }\n\n    function _open(address u, uint256 salt, uint256 coll, uint256 debt, uint64 rate) internal returns (uint256 id) {\n        vm.prank(u);\n        id = ledger.open(ETH, salt, Adjust(int256(coll), int256(debt), rate, 0, 0, false, type(uint256).max, address(0)));\n    }\n\n    /// Pricer.sol:28-29 / ParametersBase.sol:196-197: a charged rate change is measured at `borrow = min(low, cap)`.\n    /// Ledger.setRate measures it at the uncapped `low` (Ledger.sol:168).\n    function test_chargedRateChangeIgnoresBorrowCap() public {\n        uint256 id = _open(alice, 0, 10e18, 10_000e18, 0.05e18); // CR at the cap (1500): 150%\n        vm.prank(governor);\n        params.lowerCap(ETH, 1300e18); // CR at the cap: 130% < mat 140%; at `low` (2000): 200%\n        vm.prank(alice);\n        vm.expectRevert(LedgerBase.Unhealthy.selector);\n        ledger.adjust(id, Adjust(0, 1, 0, 0, 0, false, type(uint256).max, address(0)));\n        uint256 before = ledger.debtOf(id);\n        vm.prank(alice);\n        vm.expectRevert(LedgerBase.Unhealthy.selector); // what the Pricer / Parameters NatSpec promises\n        ledger.setRate(id, 0.06e18, 0, 0);\n        assertEq(ledger.debtOf(id), before, \"no premium debt added below the capped health line\");\n    }\n\n    /// LedgerBase.sol:164-166 / :320-321: while `unbacked()` is past the brake, \"no new debt on any type\". `_adjust` runs\n    /// `_checkBrake()` on a borrow (Ledger.sol:301); setRate's charged premium is minted without it (Ledger.sol:159-166).\n    function test_chargedRateChangeBypassesUnbackedBrake() public {\n        uint256 a = _open(alice, 0, 10e18, 10_000e18, 0.05e18);\n        uint256 b = _open(bob, 0, 100e18, 20_000e18, 0.05e18);\n        feed.set(500e18); // alice: 10 ETH = 5,000 against ~10,010 of debt; bob stays at CR 250%\n        vm.prank(bob);\n        liquidator.liquidate(a); // bob pays 5,000; ~5,010 is booked bad debt; the Treasury covers only its fees\n        assertGt(ledger.unbacked(), usd.circulating() / 100, \"braked\");\n        vm.prank(bob);\n        vm.expectPartialRevert(LedgerBase.Braked.selector);\n        ledger.adjust(b, Adjust(0, 1, 0, 0, 0, false, type(uint256).max, address(0)));\n        uint256 before = ledger.debtOf(b);\n        uint256 supply = usd.totalSupply();\n        vm.prank(bob);\n        vm.expectPartialRevert(LedgerBase.Braked.selector); // expected: Braked, as for any other new debt\n        ledger.setRate(b, 0.06e18, 0, 0);\n        assertEq(ledger.debtOf(b), before, \"no new debt while braked\");\n        assertEq(usd.totalSupply(), supply, \"nothing minted while braked\");\n    }\n}","reproduction":"Proof test/scratch/ProofSetRateChecks.t.sol, `forge test --match-path 'test/scratch/ProofSetRateChecks.t.sol' -vv`. ETH type mat 140%, feed 2000, genesis cap 1500. Cap: Alice opens 10 ETH / 10,000 at 5% (CR at the cap 150%). Governor lowerCap(ETH, 1300e18) (CR at the cap 130%). adjust(id, dDebt=1) reverts Unhealthy (as expected). setRate(id, 0.06e18, 0, 0) inside the cooldown: expected Unhealthy per the NatSpec; actual succeeds and debt rises by 9.59. Brake: Alice 10 ETH / 10,000 at 5%, Bob 100 ETH / 20,000 at 5%; feed -> 500; Bob liquidates Alice (repaid 5,000, bad debt ~5,010, Treasury covers ~29): unbacked() = 4,980.8 > 1% of circulating (250). adjust(bobId, dDebt=1) reverts Braked(4980821917808219178081, 250000000000000000000). setRate(bobId, 0.06e18, 0, 0): expected Braked; actual succeeds, Bob's debt and totalSupply rise by 19.18. Output: [FAIL: next call did not revert as expected] test_chargedRateChangeBypassesUnbackedBrake() (gas: 2136199); [FAIL: next call did not revert as expected] test_chargedRateChangeIgnoresBorrowCap() (gas: 1152826).","severity":"low","snippet":"            premium = _premium(debt, oldRate);\n            debt += premium;\n            s.debt += premium.toUint128();\n            if (uint256(s.debt) + s.badDebt > params.risk(ilk).line) revert CeilingExceeded(); // `r` is the stored risk: the depth rule binds here\n            _checkLines(ilk);\n            _checkEdge(ilk);\n            _mintInterest(ilk, premium);\n            if (!q.borrowable || !q.agree) revert NotPriced(); // a pair above the pool is not a price to add debt at\n            // A rate change is not a borrow, so the cap does not measure its premium.\n            if (!_healthy(p.collateral, debt, q.low, q.mat)) revert Unhealthy();","title":"setRate's charged premium is new debt that skips two checks every borrow runs: the borrow-price cap (measured at uncapped low, contradicting the Pricer/Parameters NatSpec) and the unbacked-supply brak"},{"citation":"resolved","description":"Merged from audit_economics and audit_flow; reproduced. Parameters.riskVersion returns _versions[ilk].length - 1, which includes a version pushed by SET_RISK whose bindAt is still RISK_GRACE away (ParametersRules._bindAt). Ledger._adjust line 329 (and setRate line 169) write that index into p.version, and Parameters.liqRisk(ilk, v) (Parameters.sol:347-359) returns vs[v] itself when v is the newest, with no bindAt check, so the pending mat and chop bind that position immediately. The health check that precedes the write is already at the new stored mat (ParametersRules writes _risk[ilk] = r at apply), so the position passes the new rule at that moment; but from then on it is liquidated at the pending mat with the pending chop while an identical untouched position keeps the old version until bindAt. It triggers on any call with dDebt > 0 or dCollateral < 0 and debt left, including the call the Ledger itself classifies as de-risking at Ledger.sol:349-351 (repay plus a smaller withdraw), a 1-wei borrow by an operator holding BORROW, or a signed borrowFor. Contradicts ParametersBase.sol:300-303 and README 'Versioned liquidation risk' ('open positions are never made liquidatable by a governance change without a week to repair'). No position can escape a BOUND harsher version (liqRisk scans newer versions with bindAt <= now), so the other half of question 4 holds. Reachable with BetaConfig values on any type after one SET_RISK raise. Smallest fix: bind to the newest version whose bindAt <= block.timestamp (walk back from the end in riskVersion or in a Ledger helper), or have liqRisk fall back to the newest bound version when vs[v].bindAt > block.timestamp.","line":329,"path":"src/Ledger.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {ParametersBase} from \"src/ParametersBase.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {Pricer} from \"src/Pricer.sol\";\nimport {Treasury, ICoverable} from \"src/Treasury.sol\";\nimport {PositionNFT} from \"src/PositionNFT.sol\";\nimport {Ledger, IPulse} from \"src/Ledger.sol\";\nimport {Redeemer} from \"src/Redeemer.sol\";\nimport {Liquidator} from \"src/Liquidator.sol\";\nimport {Migrator} from \"src/Migrator.sol\";\nimport {Join} from \"src/Join.sol\";\nimport {IFeed} from \"src/interfaces/IFeed.sol\";\nimport {Adjust} from \"src/interfaces/IPositions.sol\";\n\ncontract RVToken is ERC20 {\n    constructor() ERC20(\"WETH\", \"WETH\") {}\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\ncontract RVFeed is IFeed {\n    uint256 public value;\n    uint64 public updatedAt;\n    constructor(uint256 v) { value = v; updatedAt = uint64(block.timestamp); }\n    function set(uint256 v) external { value = v; updatedAt = uint64(block.timestamp); }\n    function latestValue() external view returns (uint256, uint64) { return (value, updatedAt); }\n    function isStale() external view returns (bool) { return block.timestamp - updatedAt > 365 days; }\n    function maxAge() external pure returns (uint256) { return 365 days; }\n}\n\n/// @dev Q4: `Parameters.riskVersion` is the NEWEST version, pending or not, and `liqRisk(ilk, newest)` returns it with no\n/// bindAt check. Ledger._adjust / setRate write it into `p.version` on any borrow or withdrawal, so a position touched\n/// during the 7-day grace is liquidated at the pending (harsher) mat and chop at once, while an untouched twin keeps the\n/// old version until `bindAt`.\ncontract ProofRiskVersionRebind is Test {\n    bytes32 constant ETH = \"ETH\";\n    address governor = makeAddr(\"governor\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address keeper = makeAddr(\"keeper\");\n\n    ImdUSD usd;\n    Parameters params;\n    Pricer pricer;\n    Treasury treasury;\n    Ledger ledger;\n    Liquidator liquidator;\n    RVToken weth;\n    RVFeed feed;\n\n    function _risk(uint64 mat, uint64 chop) internal pure returns (ParametersBase.Risk memory) {\n        return ParametersBase.Risk({\n            mat: mat, chop: chop, cut: 0.5e18, chip: 0.1e18, rateFloor: 0.01e18, yieldFee: 0, skew: 0.05e18,\n            pegFloor: 0, line: 1_000_000e18, dust: 100e18, redeemFloor: 0.005e18, redeemDivisor: 2, redeemCap: 0\n        });\n    }\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        weth = new RVToken();\n        feed = new RVFeed(2000e18);\n        usd = new ImdUSD(address(this), 10_000_000e18);\n        params = new Parameters(governor, address(this));\n        pricer = new Pricer(params);\n        treasury = new Treasury(governor, IERC20(address(usd)), address(this));\n        PositionNFT nft_ = new PositionNFT(address(this));\n        ledger = new Ledger(usd, params, pricer, address(treasury), address(this), IPulse(address(0)), nft_);\n        nft_.bind(address(ledger));\n        Redeemer redeemer = new Redeemer(ledger);\n        liquidator = new Liquidator(ledger, pricer);\n        ledger.wire(address(redeemer), address(liquidator));\n        Migrator migrator = new Migrator(IERC20(address(new RVToken())), usd, params);\n        usd.wire(address(ledger), address(migrator), address(params));\n        params.wire(address(ledger), address(usd));\n        treasury.wire(ICoverable(address(ledger)));\n        Join join = new Join(address(weth), address(this));\n        join.wire(address(ledger));\n        params.addGenesisIlk(\n            ETH,\n            ParametersBase.Config({\n                token: address(weth), join: address(join), median: feed, spot: feed, nhi: IFeed(address(0)),\n                yieldRate: address(0), pegAggregator: address(0), liveSpot: IFeed(address(0)), redeemable: true, auction: false\n            }),\n            _risk(1.4e18, 0.1e18)\n        );\n        params.seal();\n        address[2] memory us = [alice, bob];\n        for (uint256 i; i < 2; ++i) {\n            weth.mint(us[i], 1000e18);\n            vm.startPrank(us[i]);\n            weth.approve(address(ledger), type(uint256).max);\n            usd.approve(address(ledger), type(uint256).max);\n            vm.stopPrank();\n        }\n        deal(address(usd), keeper, 100_000e18);\n        vm.prank(keeper);\n        usd.approve(address(ledger), type(uint256).max);\n    }\n\n    function _open(address u, uint256 coll, uint256 debt) internal returns (uint256 id) {\n        vm.prank(u);\n        id = ledger.open(ETH, 0, Adjust(int256(coll), int256(debt), 0.05e18, 0, 0, false, type(uint256).max, address(0)));\n    }\n\n    function test_deRiskingAdjustBindsToPendingVersionBeforeItsGrace() public {\n        uint256 a = _open(alice, 10e18, 13_000e18); // CR 153.8% under version 0 (mat 140%)\n        uint256 b = _open(bob, 10e18, 13_000e18); // identical twin, never touched\n\n        // Governance raises mat 140% -> 160%: version 1 pushed with bindAt = now + 7 days.\n        uint8 kind = params.SET_RISK();\n        vm.prank(governor);\n        params.propose(abi.encode(kind, ETH, _risk(1.6e18, 0.1e18)));\n        vm.warp(block.timestamp + 48 hours);\n        params.applyPending();\n        feed.set(2000e18);\n        assertEq(params.riskVersion(ETH), 1);\n\n        // Alice repays half and withdraws 40% of her collateral: ratio UP (153.8% -> 184.6%), passes the health check.\n        vm.prank(alice);\n        ledger.adjust(a, Adjust(-4e18, -6_500e18, 0, 0, 0, false, 0, address(0)));\n\n        // Price falls 15%. Alice: 6 x 1700 = 10,200 / ~6,501 = 156.9%: healthy at 140%, unhealthy at 160%.\n        feed.set(1700e18);\n        (bool okB,) = ledger.healthy(b); // Bob: 17,000 / 13,000 = 130.8%: unhealthy either way (control: version 0 works)\n        assertFalse(okB);\n        (bool okA,) = ledger.healthy(a);\n        (uint256 matA,) = params.liqRisk(ETH, ledger.position(a).version);\n        emit log_named_uint(\"alice.version\", ledger.position(a).version);\n        emit log_named_uint(\"mat binding alice now\", matA);\n        // EXPECTED (ParametersBase.sol:300-303, README): version 1 binds nobody before its bindAt, so Alice is healthy\n        // at 140% for the rest of the week. ACTUAL: she is on version 1 already and reads unhealthy at 160%.\n        assertEq(ledger.position(a).version, 0, \"bound to a version whose bindAt has not passed\");\n        assertTrue(okA, \"healthy at the version in force (140%)\");\n        vm.prank(keeper);\n        vm.expectRevert(Liquidator.Healthy.selector);\n        liquidator.liquidate(a);\n    }\n}","reproduction":"Proof test/scratch/ProofRiskVersionRebind.t.sol, `forge test --match-path 'test/scratch/ProofRiskVersionRebind.t.sol' -vv`. ETH type mat 1.4e18 (version 0), price 2000. Alice and Bob each open 10 ETH / 13,000 at 5% (CR 153.8%). Governor proposes SET_RISK mat 1.6e18 (chop unchanged), applies after 48 h: version 1 with bindAt = now + 7 days. Alice adjust(id, dCollateral -4e18, dDebt -6,500e18): ratio rises to 184.6%, passes. Price -> 1700: Alice 6 x 1700 = 10,200 / 6,501 = 156.9%. Expected: position(a).version == 0, healthy(a) == true (140% binds until bindAt), liquidate(a) reverts Healthy. Actual: version 1, liqRisk returns mat 1.6e18, healthy(a) == false and she is liquidatable on day 0 of the grace. Output: [FAIL: bound to a version whose bindAt has not passed: 1 != 0] test_deRiskingAdjustBindsToPendingVersionBeforeItsGrace() (gas: 2337134) Logs: alice.version: 1; mat binding alice now: 1600000000000000000.","severity":"low","snippet":"            p.version = uint32(params.riskVersion(ilk)); // a risk-increasing action rebinds to the newest version","title":"A borrow, withdrawal or charged rate change during a risk version's 7-day grace binds the position to the pending harsher version at once (including a purely de-risking repay-and-withdraw, or an opera"},{"citation":"resolved","description":"Answer to question 3, merged from audit_economics and audit_permissions; reproduced (test_freshSaltDropsRateBelowOldRatePremium in the audit_permissions proof, run here under Solar: fee 9.589 against premium 19.178). The cheapest way to borrow below one's own recent rate without paying 7 days at the old rate is a fresh position: repay to zero (flash mint if needed), withdraw the collateral (no price needed at zero debt), open positionId(owner, ilk, salt+1) at the lower rate. The old id keeps rate, lastRateChange and premiumRate, so same-id reopen, setRate, an operator (SET_RATE alone cannot pay a premium, Ledger.sol:158) and an NFT transfer all pay 7 days at the old rate; a new id starts clean and pays only the F1 upfront fee at the type's average (LedgerBase._upfrontFee), which is the borrower's own new rate when weighted == 0. Ledger.sol:254-256 documents this ('costs at least the market's 7 days of interest'), so it is the intended Liquity-v2 design, not a defect; but the sentence quoted here and the setRate NatSpec read as if any rate drop costs 7 days at the old rate, and on the capped beta several types will have one or very few borrowers, where 'the market's rate' is the escaping borrower's own choice. Reword, or (design change) charge a fresh open at max(average, the owner's in-cooldown rate on the type).","line":126,"path":"src/LedgerBase.sol","reproduction":"ETH type, Alice the only borrower: open salt 0 with 10 ETH / 10,000 at 10%. setRate to 5% inside the cooldown would cost 10,000 x 10% x 7/365 = 19.178. Instead, same block: repayFor(id0, max), adjust(id0, dCollateral -10e18), open(ETH, 1, {10e18, 10,000, 5%}). Only charge: upfront fee 10,000 x 5% x 7/365 = 9.589 (debtOf(id1) - 10,000). Expected per the quoted sentence: at least 19.178; actual 9.589.","severity":"info","snippet":"    /// costs what one reopen does. A position's rate and `lastRateChange` survive a repayment (or redemption) to\n    /// zero, so reopening at another rate inside the cooldown pays the premium (a flash mint cannot launder a rate drop).","title":"NatSpec: 'a flash mint cannot launder a rate drop' holds for the same id only; a fresh salt pays 7 days at the type's average (the borrower's own new rate on an otherwise empty type), not at the old r"},{"citation":"resolved","description":"Merged from three specialists; confirmed by reading. Liquidator.bite/settle poke the Pulse and hold seizing while inGrace() (Liquidator.sol:147-150, 210). Redeemer.redeem never reads the Pulse: LedgerSettlement.redeemPosition -> _accrue pokes it (LedgerBase.sol:252, which can only start a grace) and nothing checks inGrace(), so the first transaction after a sequencer silence can redeem against the lowest-rate positions before their owners can raise their rate or repay. Pulse.sol:7-8 frames the grace as protecting borrowers who could not act during the outage; the Pulse and Liquidator docstrings promise to hold liquidations only, so this is a design point to decide, not a code defect. Unreachable in BetaConfig (mainnet, pulse = address(0)). If redemptions should wait: at the top of redeem, if ledger.pulse() != 0 { poke; if inGrace revert; }.","line":114,"path":"src/Redeemer.sol","reproduction":"Deploy with Pulse(gap 10 min, grace 1 h) (Robinhood-style), a redeemable type with fresh feeds. Warp 2 hours with no poke. First block after: liquidator.liquidate(id) of an unhealthy position returns (0,0,0) and emits HeldByGrace; redeemer.redeem(ilk, amount, 0.05e18, 10, 0) in the same block fills and burns. Expected (if the grace is meant for borrowers): held like the liquidation; actual: it runs.","severity":"info","snippet":"    function redeem(bytes32 ilk, uint256 amount, uint256 maxFee, uint256 maxPositions, uint256 minCollateralOut)","title":"Redeemer.redeem does not consult the Pulse: a redemption can run inside a sequencer grace that holds liquidations (question 13; not reachable in the beta, pulse = 0)"},{"citation":"resolved","description":"From audit_economics; confirmed by reading. In the standing-bid branch the chip goes to m.marker (line 172 -> LedgerSettlement.sol:165,188). Every beta type has lull 0 (Parameters.maxLull = 0, no NHI type at genesis), so liquidate(id) (lines 221-225) barks and bites in one call and the caller IS the marker: it receives chip x bonus (BetaConfig chip 0.1e18) in collateral. The bite() docstring ('the caller pays nothing there') describes only the two-step case. Behaviour is fine (the kick must be paid to someone); the comment is wrong.","line":161,"path":"src/Liquidator.sol","reproduction":"Beta ETH type, Bob's position unhealthy, blue sleeve with room: Carol calls liquidator.liquidate(id). Expected per the comment: Carol receives nothing. Actual: Carol (marker set by bark inside liquidate) receives toMarker = bonus x 0.1 in WETH.","severity":"info","snippet":"        // the sleeve's. Otherwise the caller pays, as before. The caller earns nothing here: the marker's chip is the kick.","title":"NatSpec: 'The caller earns nothing here: the marker's chip is the kick' is false on every beta type, where liquidate() makes the caller the marker and pays it the chip from the sleeve's purchase"},{"citation":"resolved","description":"From audit_permissions; confirmed by reading. borrowFor builds an Adjust with rate = 0 and hands it to Ledger.execute. If the position's debt is zero when the relayer submits (repaid, redeemed or harvested to zero between signing and inclusion), _adjust takes the !hadDebt branch and treats a.rate = 0 as the opening rate: with rateFloor == 0 (Parameters._checkRisk only bounds the floor above) the position would open at 0%, first in the redemption order, and inside a cooldown pay a premium for a rate change the signer never asked for. With every BetaConfig floor >= 0.5% the call reverts RateOutOfRange (a liveness nit: a signed borrow on a yield position whose harvest just cleared the debt reverts). Fix: in execute (or _adjust when the action came from borrowFor) revert if p.debt == 0, or sign and pass the position's current rate.","line":141,"path":"src/PositionNFT.sol","reproduction":"Type with rateFloor = 0. Owner signs BorrowFor(id, 1,000, to, maxFee) on a position with debt at 5%; before inclusion the owner repays to zero. Relayer calls borrowFor: expected per NatSpec: revert (no debt); actual: the position reopens at rate 0% (and inside the cooldown pays a 5% premium on the 1,000). With BetaConfig floors the call reverts RateOutOfRange instead.","severity":"info","snippet":"    /// @notice Borrow `amount` on position `id` (which must already have debt: the rate is the position's) to `to` (zero:\n    /// its owner), by its owner's `BorrowFor` signature.","title":"borrowFor NatSpec says the position 'must already have debt'; nothing enforces it (fails closed in the beta only because every rateFloor > 0)"},{"citation":"resolved","description":"From audit_math; confirmed by reading. Pricer.sol:43-44 breaks mid-sentence ('and a charged rate change / : failing it sends a liquidation to the caller path') and read literally says a charged rate change needs only `agree`, but Ledger.sol:166 requires both `q.borrowable` and `q.agree`. Redeemer.sol:19 carries a stray '(-10-30)'. Documentation only.","line":43,"path":"src/Pricer.sol","reproduction":"Read src/Pricer.sol:43-44 against src/Ledger.sol:166 (`if (!q.borrowable || !q.agree) revert NotPriced();`) and src/Redeemer.sol:19.","severity":"info","snippet":"        /// not below `low` by more than `skew`. The standing bid reads it, and a charged rate change\n        /// : failing it sends a liquidation to the caller path, so pushing the pool away can only make the sleeve stand","title":"Garbled NatSpec in Pricer.Quote.agree (also omits that a charged setRate needs `borrowable`) and a stray '(-10-30)' in the Redeemer header"}],"hash":"886c79112335f9fc22490595bac189cbe1c090d9a0b15d5ad4b1eec76bcee0ec","nodeId":"98598d7c-770d-4550-aafd-f9d24541cc9b","outcome":"completed","summary":"The findings file is written and verified, the scratch tests are deleted, and the tree is unchanged. Here is the report.\n\n## Outcome\n\nNine findings are in `.imd-findings.json`: one medium, three low, five info. Four carry a self-contained Foundry proof that I ran under solc 0.8.26 via_ir (and Solar) and that fails on this commit for the stated reason. Every specialist finding was reproduced or confirmed by reading; none was dropped, but 16 reports collapsed into 9 after merging duplicates.\n\n**Kept, with proof (all failed under solc, 2m14s compile):**\n- **Medium.** Liquidation health ignores the pending yield harvest (`LedgerSettlement.sol:120`). Merged the WSTETH side (audit_economics) and the S-USDC side (audit_math): same root cause, opposite direction depending on whether (1 - yieldFee) x mat exceeds 1. On S-USDC a third party harvests a healthy position into liquidation at an unchanged price. On WSTETH a position its own harvest would heal is closed at 10% penalty. The specialist's WSTETH proof reproduced; I added the S-USDC test in the same file.\n- **Low.** Premium laundering through two in-cooldown setRate calls on dust (`Ledger.sol:172`). The specialist's proof reproduced unchanged.\n- **Low.** setRate's charged premium skips the borrow cap and the unbacked brake (`Ledger.sol:159-168`). Merged three reports. The specialist's proof depended on the test harness and used the wrong revert matcher for an error with arguments, so I rewrote it self-contained.\n- **Low.** Any borrow, withdrawal or charged rate change during a risk version's grace binds the position to the pending harsher version at once (`Ledger.sol:329`). Merged audit_economics and audit_flow. I wrote the proof.\n\n**Kept as info, no proof:** fresh-salt rate drop NatSpec (merged two), Redeemer ignores the Pulse (merged three, unreachable in the beta), and three NatSpec defects (Liquidator 161, PositionNFT 141, Pricer 43 with Redeemer 19).\n\n## Answers to the numbered questions\n\n1. **Debt bookkeeping.** Holds. `_record` moves `weighted` by exactly the recorded debt times rate on every path; `_accrue` rounds up, `_debtOf` rounds down; harvest credits leave `weighted` untouched until the caller records. Fees and premiums are added to the type and minted together.\n2. **Harvest.** Holds for the sequences listed. The mark only moves up, a dip holds it, an unpriced window keeps it, and `_blendMark` keeps unharvested yield constant across a deposit. The defect is the liquidation path, which is the medium finding.\n3. **Upfront fee and cooldown.** Cheapest route is a fresh salt after a flash repay, which pays 7 days at the type average (own rate on an empty type). Documented design, so info. The same-id laundering through two dust setRate calls is the low finding.\n4. **Risk versions.** No escape from a bound harsher version. A position can be put on one early: the low finding.\n5. **Positions and rights.** Holds. `execute` is NFT-only and re-reads the owner, nonces are shared and bumpable, per-id rights are epoch-keyed, and holder-bound collateral is refused in from a non-owner and out to a non-owner.\n6. **Sorted list.** Holds. Every path that moves recorded debt across zero relists; bad hints fall back to a walk.\n7. **The split.** Holds. Both contracts inherit only `LedgerBase` for storage, the lock is transient and shared by delegatecall, and the module refuses direct calls.\n8. **Mark, grace, bite.** Holds. Health is re-checked at bite, a live mark cannot be displaced, and the chip goes to the recorded marker. The NatSpec about the caller earning nothing is wrong on no-grace types.\n9. **Standing bid.** Holds by reading. The sleeve pays at `low` only while the legs agree, and `_liquidationNeed` closes in full when the remainder would be dust. Not independently tested.\n10. **Bad debt.** Holds by reading. Treasury orders the sleeve first, and the gas floor is in the README's known issues. Not independently tested.\n11. **Redemption.** Holds by reading. Payout is the maximum leg","treeHash":null,"usage":{"cachedInputTokens":2100740,"cost":{"source":"runtime","usdMicros":5990733},"inputTokens":482,"model":"claude-fable-5-1","outputTokens":43705,"runtime":"claude","turns":29,"wallClockMs":830859}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a31e321b410aaa02","findings":[{"citation":"resolved","description":"Q3 (upfront fee and rate cooldown). LedgerBase.sol:125-127 and Ledger.sol:281-283 claim that debt added inside the cooldown of a charged rate change pays RATE_COOLDOWN of interest at the rate BEFORE that change, so that 'a dust reopen followed by a top-up costs what one reopen does'. setRate overwrites `premiumRate` with the CURRENT rate on every charged change (line 172) and charges the change's own premium only on the position's current debt (line 159, `_premium(debt, oldRate)`). An owner inside the cooldown therefore repays to dust (free, no price needed), changes the rate twice (10% -> X -> target; both premiums are on ~dust and the second sets premiumRate = X, where X is any rate >= rateFloor the owner picks), then borrows the whole amount back: the C2 top-up premium (Ledger.sol:285-287) is now charged at X, not at the 10% the cooldown was entered at. With BetaConfig (rateFloor 0.5% on stables/ETH types, dust 100e18) the top-up premium on 10,000 imdUSD falls from 19.18 (7 days at 10%) to about 0.96 (at 0.5%) plus two dust-sized premiums; the owner keeps the position id, its operator grants and its collateral in place. Reachable with the deployed values: it needs only Ledger.adjust and Ledger.setRate as owner (an operator with SET_RATE cannot pay a premium). Impact is bounded: the borrower still pays the upfront fee at the type's average rate on the re-borrowed amount, so the route is never cheaper than the design's accepted floor of a fresh position (7 days at the average); what is lost is the 7 days at the OLD rate that the premium was meant to collect for in-cooldown re-rates on the same position, and the NatSpec guarantee is false. Smallest fix: in setRate, when `inCooldown && newRate != oldRate`, keep the existing premiumRate if it is already set (`p.premiumRate = p.premiumRate != 0 ? p.premiumRate : uint64(oldRate)`), and apply the same rule on the reopen path at Ledger.sol:276; alternatively charge the change's premium on max(debt, debt at the cooldown's start).","line":172,"path":"src/Ledger.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {ParametersBase} from \"src/ParametersBase.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {Pricer} from \"src/Pricer.sol\";\nimport {Treasury, ICoverable} from \"src/Treasury.sol\";\nimport {PositionNFT} from \"src/PositionNFT.sol\";\nimport {Ledger, IPulse} from \"src/Ledger.sol\";\nimport {LedgerBase} from \"src/LedgerBase.sol\";\nimport {Redeemer} from \"src/Redeemer.sol\";\nimport {Liquidator} from \"src/Liquidator.sol\";\nimport {Migrator} from \"src/Migrator.sol\";\nimport {Join} from \"src/Join.sol\";\nimport {IFeed} from \"src/interfaces/IFeed.sol\";\nimport {Adjust} from \"src/interfaces/IPositions.sol\";\n\ncontract PLToken is ERC20 {\n    constructor() ERC20(\"WETH\", \"WETH\") {}\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\ncontract PLFeed is IFeed {\n    uint256 public value;\n    uint64 public updatedAt;\n    constructor(uint256 v) { value = v; updatedAt = uint64(block.timestamp); }\n    function set(uint256 v) external { value = v; updatedAt = uint64(block.timestamp); }\n    function latestValue() external view returns (uint256, uint64) { return (value, updatedAt); }\n    function isStale() external view returns (bool) { return block.timestamp - updatedAt > 365 days; }\n    function maxAge() external pure returns (uint256) { return 365 days; }\n}\n\n/// @dev Q3: the C2 rule says debt added inside the cooldown of a charged rate change pays RATE_COOLDOWN of interest at the\n/// rate BEFORE that change (\"a dust reopen followed by a top-up costs what one reopen does\"). Two in-cooldown setRate\n/// calls on a dust-sized debt overwrite `premiumRate` with the freely chosen intermediate rate, so the top-up pays the\n/// premium at that rate (here 2%) instead of the original 10%.\ncontract ProofPremiumLaunder is Test {\n    bytes32 constant ETH = \"ETH\";\n    address governor = makeAddr(\"governor\");\n    address alice = makeAddr(\"alice\");\n\n    ImdUSD usd;\n    Parameters params;\n    Pricer pricer;\n    Treasury treasury;\n    Ledger ledger;\n    PLToken weth;\n    PLFeed feed;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        weth = new PLToken();\n        feed = new PLFeed(2000e18);\n        PLToken v1 = new PLToken();\n\n        usd = new ImdUSD(address(this), 10_000_000e18);\n        params = new Parameters(governor, address(this));\n        pricer = new Pricer(params);\n        treasury = new Treasury(governor, IERC20(address(usd)), address(this));\n        PositionNFT nft_ = new PositionNFT(address(this));\n        ledger = new Ledger(usd, params, pricer, address(treasury), address(this), IPulse(address(0)), nft_);\n        nft_.bind(address(ledger));\n        Redeemer redeemer = new Redeemer(ledger);\n        Liquidator liquidator = new Liquidator(ledger, pricer);\n        ledger.wire(address(redeemer), address(liquidator));\n        Migrator migrator = new Migrator(IERC20(address(v1)), usd, params);\n        usd.wire(address(ledger), address(migrator), address(params));\n        params.wire(address(ledger), address(usd));\n        treasury.wire(ICoverable(address(ledger)));\n\n        Join join = new Join(address(weth), address(this));\n        join.wire(address(ledger));\n        params.addGenesisIlk(\n            ETH,\n            ParametersBase.Config({\n                token: address(weth), join: address(join), median: feed, spot: feed, nhi: IFeed(address(0)),\n                yieldRate: address(0), pegAggregator: address(0), liveSpot: IFeed(address(0)), redeemable: true, auction: false\n            }),\n            ParametersBase.Risk({\n                mat: 1.4e18, chop: 0.1e18, cut: 0.5e18, chip: 0.1e18, rateFloor: 0.01e18, yieldFee: 0, skew: 0.05e18,\n                pegFloor: 0, line: 1_000_000e18, dust: 100e18, redeemFloor: 0.005e18, redeemDivisor: 2, redeemCap: 0\n            })\n        );\n        params.seal();\n\n        weth.mint(alice, 100e18);\n        vm.startPrank(alice);\n        weth.approve(address(ledger), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function _premium(uint256 amount, uint256 rate) internal pure returns (uint256) {\n        return (amount * rate * 7 days + 365 days * 1e18 - 1) / (365 days * 1e18);\n    }\n\n    function test_twoInCooldownRateChangesLaunderThePremiumRate() public {\n        uint256 id = ledger.positionId(alice, ETH, 0);\n        // Alice borrows 10,000 at 10%: lastRateChange = now, so a change inside 7 days is charged at 10%.\n        vm.prank(alice);\n        ledger.open(ETH, 0, Adjust(int256(100e18), int256(10_000e18), 0.10e18, 0, 0, false, type(uint256).max, address(0)));\n        vm.warp(block.timestamp + 1 days);\n\n        // The honest price of dropping 10,000 of debt from 10% to 1% inside the cooldown: 7 days at 10%.\n        uint256 honest = _premium(10_000e18, 0.10e18);\n\n        // 1. Repay down to dust (free, no price needed).\n        uint256 debt = ledger.debtOf(id);\n        vm.prank(alice);\n        ledger.adjust(id, Adjust(0, -int256(debt - 101e18), 0, 0, 0, false, type(uint256).max, address(0)));\n        assertApproxEqAbs(ledger.debtOf(id), 101e18, 1);\n\n        // 2. Two rate changes on the dust: 10% -> 2% (charged at 10% on 101) then 2% -> 1% (charged at 2% on ~101).\n        vm.startPrank(alice);\n        ledger.setRate(id, 0.02e18, 0, 0);\n        ledger.setRate(id, 0.01e18, 0, 0);\n        vm.stopPrank();\n        LedgerBase.Position memory p = ledger.position(id);\n        assertEq(p.rate, 0.01e18);\n        uint256 dust = p.debt;\n        uint256 paidSoFar = dust - 101e18; // the two tiny premiums (about 0.19 + 0.04 imdUSD)\n\n        // 3. Top up the 9,900 back inside the cooldown.\n        ledger.accrue(ETH);\n        uint256 upfront = ledger.upfrontFeeFor(ETH, 9_899e18, 0.01e18);\n        vm.prank(alice);\n        ledger.adjust(id, Adjust(0, int256(9_899e18), 0, 0, 0, false, type(uint256).max, address(0)));\n        uint256 topUpPremium = ledger.debtOf(id) - dust - 9_899e18 - upfront;\n\n        emit log_named_uint(\"honest premium (7d at 10% on 10,000)\", honest);\n        emit log_named_uint(\"premiumRate after the two changes   \", p.premiumRate);\n        emit log_named_uint(\"premium charged on the 9,899 top-up \", topUpPremium);\n        emit log_named_uint(\"total premium paid over the sequence\", topUpPremium + paidSoFar);\n\n        // Documented rule (LedgerBase 125-127, Ledger 281-283): the top-up pays at the rate BEFORE the charged change (10%).\n        // Actual: `premiumRate` is the freely chosen intermediate rate (2%), and the top-up pays about a fifth.\n        assertEq(p.premiumRate, 0.10e18, \"premiumRate should still be the rate the cooldown was entered at\");\n        assertGe(topUpPremium + paidSoFar, honest, \"the sequence must cost at least one honest rate change\");\n    }\n}","reproduction":"State: ETH type with the BetaConfig-shaped risk (rateFloor 1% in the test, dust 100e18, mat 140%), price $2000. Alice opens 100 ETH / 10,000 imdUSD at 10% at t0. At t0 + 1 day (inside the 7-day cooldown): (1) adjust(id, dDebt = -(debt - 101e18)) -> debt ~101; (2) setRate(id, 2%) -> premium ~0.19, premiumRate = 10%; (3) setRate(id, 1%) -> premium ~0.04, premiumRate becomes 2% (expected per NatSpec: stays 10%); (4) adjust(id, dDebt = +9,899e18) -> top-up premium charged = 3.7969 imdUSD (expected 7 days at 10% on 9,899 = 18.98). Totals: the whole sequence pays 4.03 imdUSD of cooldown premium against 19.18 for one honest in-cooldown rate change on 10,000. Output of the attached test on this commit: '[FAIL: premiumRate should still be the rate the cooldown was entered at: 20000000000000000 != 100000000000000000]' with logs honest premium 19178082191780821918, premiumRate 20000000000000000, top-up premium 3796876712328767124, total 4029389363858134736. Run: forge test --match-path test/scratch/ProofPremiumLaunder.t.sol -vv","severity":"low","snippet":"        if (newRate != oldRate) p.premiumRate = inCooldown ? uint64(oldRate) : 0;","title":"Cooldown premium laundering: two in-cooldown setRate calls on a dust debt reset premiumRate to a chosen rate, so a top-up pays ~1/5 of the documented premium"},{"citation":"resolved","description":"Q4 (risk versions). The comment and README promise that a position stays on the version it last took risk under until a newer version reaches its bindAt (RISK_GRACE = 7 days for a raise of mat or chop). Ledger._adjust sets p.version to Parameters.riskVersion(ilk), the NEWEST version even while its bindAt is still in the future, on every call with `a.dDebt > 0 || a.dCollateral < 0` and debt left. That includes the call the Ledger itself classifies as de-risking at Ledger.sol:349-351 (_adjustPrice uses the uncapped low because the ratio does not fall). Parameters.liqRisk(ilk, v) (Parameters.sol:347-359) returns vs[v] itself when v is the newest, with no bindAt check on the position's own version, so from that moment the position is liquidated at the pending version's higher mat and pays its higher chop, days before any other position of the type. Concrete: governance applies SET_RISK raising ETH mat 140% -> 160% and chop 10% -> 15% (binds in 7 days). A borrower at CR 150% repays half the debt and withdraws 40% of the collateral (CR 180%, passes the health check at the new 160%). Immediately after, liqRisk reports mat 160% / chop 15% for that position while untouched positions keep 140% / 10% until bindAt; a 12% price drop makes only the de-risked borrower liquidatable, with the higher penalty. Reachable with BetaConfig values (any type; needs one pending SET_RISK). Smallest fix: rebind only when the call is risk-increasing (`a.dDebt > 0 || _adjustPrice(...) == q.borrow`), or have liqRisk fall back to the newest BOUND version when vs[v].bindAt > block.timestamp.","line":329,"path":"src/Ledger.sol","reproduction":"Setup as test/unit/RiskVersions.t.sol: open a position at CR 150% under version 0 (mat 1.4e18). Governor proposes and applies SET_RISK with mat 1.6e18 (harsher: bindAt = now + 7 days; Parameters.riskVersion == 1). Borrower calls adjust(id, dCollateral = -40% of collateral, dDebt = -50% of debt). Expected: ledger.position(id).version == 0 and params.liqRisk(ilk, 0) still returns mat 1.4e18 until bindAt. Actual: version == 1 and params.liqRisk(ilk, 1) returns mat 1.6e18 at once; ledger.healthy(id) flips false at a price where an untouched twin position is still healthy.","severity":"low","snippet":"            p.version = uint32(params.riskVersion(ilk)); // a risk-increasing action rebinds to the newest version","title":"A purely de-risking adjust (repay + smaller withdraw) rebinds the position to a pending harsher risk version before its 7-day grace ends"},{"citation":"resolved","description":"Q13 (Pulse). On a chain with a Pulse (none on mainnet; the beta deploys pulse = 0), Liquidator.bite and Liquidator.settle poke the Pulse and refuse to seize while inGrace() (Liquidator.sol:147-150, 210). Redeemer.redeem only pokes it indirectly (LedgerSettlement.redeemPosition -> _accrue -> pulse.poke()) and never reads inGrace(), so the first transaction after a sequencer silence can redeem against the lowest-rate positions at a price that just became fresh, while their owners had no chance to raise their rate or repay. Pulse.sol and Liquidator.sol document the grace as a liquidation-only hold, so this is a design gap to decide rather than a code defect, and it is unreachable in the beta.","line":114,"path":"src/Redeemer.sol","reproduction":"Robinhood-style deployment with Pulse(gap = 10 min, grace = 1 h), a redeemable type with fresh feeds. Warp 2 hours with no poke, then call Redeemer.redeem: expected (if the grace is meant to cover redemptions) a revert or a zero fill while Pulse.inGrace() is true; actual: the redemption fills and burns, and Pulse.graceUntil is set by the same call's poke.","severity":"info","snippet":"    function redeem(bytes32 ilk, uint256 amount, uint256 maxFee, uint256 maxPositions, uint256 minCollateralOut)","title":"Redeemer.redeem is not held by the Pulse sequencer grace (liquidations are)"}],"hash":"ce8d43d0601456caa423edd0f371f9225c8f9057ebfc943b3ed9104a0776cb02","nodeId":"599abfe1-712a-4a01-87f3-6087d12efcc1","outcome":"completed","summary":"The panel suite passed in full and the tree is back to its original state. Here is the review.\n\n## Outcome\n\nThree findings went into `.imd-findings.json`: two low, one info. Nothing critical or high. The one proof test failed on this commit as described and was removed afterwards; the required panel run passed.\n\n| Run | Result |\n|---|---|\n| Panel glob (Core, Positions, Redemption, StandingBid, ReviewLedger, PanelLiquidation) | 176 passed, 0 failed |\n| Proof test (premium laundering) | failed as intended, premiumRate 2% vs expected 10% |\n\n**Finding 1, low (Ledger.sol:172).** Inside the cooldown, repay to dust, change the rate twice (10% to X to target), then borrow back. The second change sets `premiumRate` to X, so the top-up premium is charged at X instead of the rate the cooldown was entered at. On 10,000 imdUSD the whole sequence cost 4.03 against 19.18 for one honest change. The NatSpec claim \"a dust reopen followed by a top-up costs what one reopen does\" is false. Bounded because the upfront fee at the type's average still applies, so it is never cheaper than a fresh position. Fix: keep an already-set `premiumRate` on a charged change.\n\n**Finding 2, low (Ledger.sol:329).** Any adjust with a withdraw or borrow rebinds `p.version` to the newest version even while its `bindAt` is pending, and `liqRisk` returns the position's own version without a bindAt check. A purely de-risking repay-plus-withdraw, which the Ledger itself prices as de-risking, puts the position on the harsher mat and chop days before the grace ends. No proof test, stated reproduction only.\n\n**Finding 3, info (Redeemer.sol:114).** On a Pulse chain, redemptions are not held by the sequencer grace; only bite and settle are. Unreachable in the beta, which has no Pulse.\n\n## Answers to the thirteen questions\n\n1. **Debt bookkeeping holds.** `_accrue` rounds the aggregate up, `_debtOf` rounds each position down, and every path (adjust, setRate, harvest, redeemPosition, liquidatePosition, writeOff) goes through `_record`, which moves `weighted` by exactly old debt x old rate minus new debt x new rate. Every premium and fee is added to both the position and `s.debt` and minted in the same call. The only residue is the documented rounding dust left in `s.debt`.\n2. **Harvest holds.** Yield shares are `collateral x (rate - mark) / rate` at the capped rate, valued at `low`, only when priced, taken only from the position's own collateral. A rate dip keeps the mark. `_blendMark` preserves the unharvested yield exactly on a deposit. A no-debt period only moves the mark.\n3. **Cooldown.** The cheapest route is the design's own fresh position at 7 days of the average rate. The premium laundering in finding 1 is the only same-position path that undercuts the documented premium. Operator, NFT transfer and flash-loan routes hold.\n4. **Risk versions.** No escape past grace: `liqRisk` walks to the newest bound version. Early binding is possible through a de-risking adjust, finding 2.\n5. **Positions and rights hold.** `execute` is nft-only and checks ownership; signatures cover every field but the gas-only hints; nonces are sequential with `bumpNonce`; epochs kill per-position grants on transfer; holder-bound collateral enters only from the owner and leaves only to the owner, independent of `authorized`.\n6. **Sorted list holds.** `_relist` is driven by recorded debt before and after in every mutating path; `_valid` requires adjacency and strict ordering, otherwise `_walk` ignores the hint. Config is written only at `_addIlk`, so `redeemable` cannot flip under live positions.\n7. **The split holds.** Both contracts inherit exactly LedgerBase's storage; `settlement` and `ledger` are immutables. The guard is one transient slot shared by the delegatecall. All three module entry points are `onlyLedger` by `address(this)`; direct calls revert, and the module's own getters read empty storage.\n8. **Mark, grace, bite hold.** Health is rechecked at bark, bite and settle with the uncapped `low","treeHash":null,"usage":{"cachedInputTokens":1391839,"cost":{"source":"runtime","usdMicros":6168567},"inputTokens":354,"model":"claude-fable-5-1","outputTokens":52426,"runtime":"claude","turns":32,"wallClockMs":1538825}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f91b58cf7cd2d45","findings":[{"citation":"resolved","description":"Every touch of a yield type (adjust, setRate, harvest, redeemPosition) first runs _harvest: the shares that are pure yield since yieldMark leave the collateral and (1 - yieldFee) of their value is credited against the debt (LedgerBase.sol:82-87 says this happens 'on every touch'). Liquidation does not: Ledger.healthy (Ledger.sol:471-477) measures _debtOf(p) against p.collateral, and LedgerSettlement.liquidatePosition (line 120) takes debt = _debtOf(p) with no harvest, so the un-harvested yield is treated as collateral and sold at the liquidation price with the chop, instead of repaying debt at (1 - yieldFee) x price. On a type where (1 - yieldFee) x mat > 1 the harvest RAISES the ratio, so a position can read unhealthy before its pending harvest and healthy after it. BetaConfig WSTETH (mat 1.45, yieldFee 0.10, yieldRate set): 0.9 x 1.45 = 1.305 > 1. With a year of ~3% staking yield the band is about 1.2% of mat (positions between ~143.2% and 145%). The keeper simply does not call harvest; the Liquidator's _requireUnhealthy -> ledger.healthy returns false and the position is closed at a 10% penalty, the yield shares included in what is seized. Reachable with BetaConfig values (WSTETH; S-USDS and S-USDC-STEAKHOUSE have mat 1.08 so 0.9 x 1.08 < 1 and the band does not exist there). The same gap makes liquidationNeed and the standing bid's 'need' use the un-harvested debt. Smallest fix: in LedgerSettlement.liquidatePosition run _harvest before computing debt (as redeemPosition does at line 73; the Redeemer path shows the quote is available), and make Ledger.healthy/liquidationNeed apply the same pending credit in their view (collateral - yieldShares, debt - credit); or document that liquidation does not harvest and have the keeper/site call harvest(ilk,[id]) first. Also a NatSpec defect: LedgerBase.sol:82-84 'on every touch (and on harvest) the shares that are pure yield ... move to the Treasury' is false for the liquidation and writeOff touches.","line":120,"path":"src/LedgerSettlement.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {ImdUSD} from \"src/ImdUSD.sol\";\nimport {Parameters} from \"src/Parameters.sol\";\nimport {ParametersBase} from \"src/ParametersBase.sol\";\nimport {Pricer} from \"src/Pricer.sol\";\nimport {Treasury, ICoverable} from \"src/Treasury.sol\";\nimport {PositionNFT} from \"src/PositionNFT.sol\";\nimport {Ledger} from \"src/Ledger.sol\";\nimport {IPulse} from \"src/LedgerBase.sol\";\nimport {Redeemer} from \"src/Redeemer.sol\";\nimport {Liquidator} from \"src/Liquidator.sol\";\nimport {Migrator} from \"src/Migrator.sol\";\nimport {Join} from \"src/Join.sol\";\nimport {IFeed} from \"src/interfaces/IFeed.sol\";\nimport {Adjust} from \"src/interfaces/IPositions.sol\";\n\ncontract Tok is ERC20 {\n    constructor() ERC20(\"wstETH\", \"wstETH\") {}\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\ncontract Feed is IFeed {\n    uint256 public value;\n    uint64 public at;\n    constructor(uint256 v) { set(v); }\n    function set(uint256 v) public { value = v; at = uint64(block.timestamp); }\n    function latestValue() external view returns (uint256, uint64) { return (value, at); }\n    function isStale() external view returns (bool) { return value == 0 || block.timestamp - at > 1 hours; }\n    function maxAge() external pure returns (uint256) { return 1 hours; }\n}\n\n/// @dev The yield source the Ledger harvests against (Config.yieldRate): a share rate, 1e18.\ncontract RateSrc {\n    uint256 public rate = 1e18;\n    function set(uint256 r) external { rate = r; }\n}\n\n/// @notice Liquidation health ignores the position's pending harvest. On a yield type (beta: WSTETH, mat 145%,\n/// yieldFee 10%) the yield shares accrued since the last touch are still in the position; every touch harvests them\n/// (collateral -Y, debt -0.9*Y*P), which RAISES the ratio when 0.9*mat > 1. `Ledger.healthy` and `liquidatePosition`\n/// read `_debtOf` without harvesting, so a position that is healthy after its own pending harvest is liquidated with\n/// the full 10% penalty. Expected: `liquidate` reverts Healthy (as it does once anyone calls `harvest` first).\ncontract ProofLiquidationIgnoresHarvest is Test {\n    bytes32 constant WSTETH = \"WSTETH\";\n    address governor = makeAddr(\"governor\");\n    address alice = makeAddr(\"alice\");\n    address keeper = makeAddr(\"keeper\");\n\n    ImdUSD usd;\n    Parameters params;\n    Pricer pricer;\n    Treasury treasury;\n    Ledger ledger;\n    Liquidator liquidator;\n    Tok wsteth;\n    Feed feed;\n    RateSrc rateSrc;\n    Join join;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        wsteth = new Tok();\n        feed = new Feed(2000e18);\n        rateSrc = new RateSrc();\n        usd = new ImdUSD(address(this), 10_000_000e18);\n        params = new Parameters(governor, address(this));\n        pricer = new Pricer(params);\n        treasury = new Treasury(governor, IERC20(address(usd)), address(this));\n        PositionNFT nft = new PositionNFT(address(this));\n        ledger = new Ledger(usd, params, pricer, address(treasury), address(this), IPulse(address(0)), nft);\n        nft.bind(address(ledger));\n        Redeemer redeemer = new Redeemer(ledger);\n        liquidator = new Liquidator(ledger, pricer);\n        ledger.wire(address(redeemer), address(liquidator));\n        Migrator migrator = new Migrator(IERC20(address(new Tok())), usd, params);\n        usd.wire(address(ledger), address(migrator), address(params));\n        params.wire(address(ledger), address(usd));\n        treasury.wire(ICoverable(address(ledger)));\n\n        join = new Join(address(wsteth), address(this));\n        join.wire(address(ledger));\n        // BetaConfig WSTETH: mat 145%, chop 10%, cut 50%, chip 10%, rateFloor 1%, yieldFee 10%, skew 5%, dust 100.\n        params.addGenesisIlk(\n            WSTETH,\n            ParametersBase.Config({\n                token: address(wsteth),\n                join: address(join),\n                median: feed,\n                spot: feed,\n                nhi: IFeed(address(0)),\n                yieldRate: address(rateSrc),\n                pegAggregator: address(0),\n                liveSpot: IFeed(address(0)),\n                redeemable: true,\n                auction: false\n            }),\n            ParametersBase.Risk({\n                mat: 1.45e18, chop: 0.1e18, cut: 0.5e18, chip: 0.1e18, rateFloor: 0.01e18, yieldFee: 0.1e18,\n                skew: 0.05e18, pegFloor: 0, line: 50_000e18, dust: 100e18, redeemFloor: 0.005e18, redeemDivisor: 2,\n                redeemCap: 0\n            })\n        );\n        params.seal();\n\n        wsteth.mint(alice, 10e18);\n        vm.prank(alice);\n        wsteth.approve(address(ledger), type(uint256).max);\n    }\n\n    function test_healthyAfterPendingHarvestIsLiquidated() public {\n        // Alice: 10 wstETH at $2,000, 13,700 imdUSD of debt: CR 146% > 145%.\n        vm.prank(alice);\n        uint256 id = ledger.open(WSTETH, 0, Adjust(10e18, 13_700e18, 0.01e18, 0, 0, false, type(uint256).max, address(0)));\n\n        // A year later the share rate has grown 3% (wstETH's staking yield). Nobody touched the position.\n        vm.warp(block.timestamp + 365 days);\n        rateSrc.set(1.03e18);\n        feed.set(1995e18); // the market dips 0.25%; the year of 1% interest (debt 13,837) did the rest\n\n        // Pending harvest (what ANY touch would do): Y = 10 * 0.03 / 1.03 = 0.2913 shares off the collateral,\n        // 0.9 * Y * 1995 = 523 imdUSD off the debt. Before it: 19,950 < 13,837 * 1.45 = 20,064 (unhealthy).\n        // After it: 9.7087 * 1995 = 19,369 >= (13,837 - 523) * 1.45 = 19,305 (healthy).\n        uint256 debt = ledger.debtOf(id);\n        uint256 yieldShares = uint256(10e18) * uint256(0.03e18) / uint256(1.03e18);\n        uint256 credit = yieldShares * 1995e18 / 1e18 * 0.9e18 / 1e18;\n        bool healthyAfterHarvest = (10e18 - yieldShares) * 1995e18 / 1e18 >= (debt - credit) * 1.45e18 / 1e18;\n        assertTrue(healthyAfterHarvest, \"the position is healthy once its own yield is harvested\");\n        (bool okNow,) = ledger.healthy(id);\n        assertFalse(okNow, \"but the Ledger reads it unhealthy: the pending harvest is ignored\");\n\n        // A keeper liquidates it at once, with the 10% penalty: it pays the debt and takes collateral worth 110% of it.\n        deal(address(usd), keeper, 20_000e18);\n        vm.prank(keeper);\n        usd.approve(address(ledger), type(uint256).max);\n        vm.prank(keeper);\n        // EXPECTED: Healthy() (the position is healthy at the protocol's own accounting). ACTUAL: liquidated.\n        vm.expectRevert(Liquidator.Healthy.selector);\n        liquidator.liquidate(id);\n    }\n\n    function test_sameStateAfterHarvestIsNotLiquidatable() public {\n        vm.prank(alice);\n        uint256 id = ledger.open(WSTETH, 0, Adjust(10e18, 13_700e18, 0.01e18, 0, 0, false, type(uint256).max, address(0)));\n        vm.warp(block.timestamp + 365 days);\n        rateSrc.set(1.03e18);\n        feed.set(1995e18);\n        uint256[] memory ids = new uint256[](1);\n        ids[0] = id;\n        ledger.harvest(WSTETH, ids); // anyone's harvest first: the same position, the same block\n        (bool ok,) = ledger.healthy(id);\n        assertTrue(ok, \"healthy after the harvest\");\n        vm.expectRevert(Liquidator.Healthy.selector);\n        liquidator.liquidate(id);\n    }\n}","reproduction":"State: WSTETH type as in BetaConfig (mat 1.45e18, chop 0.1e18, cut 0.5e18, chip 0.1e18, rateFloor 0.01e18, yieldFee 0.1e18, skew 0.05e18, dust 100e18), one feed at 2000e18 in both legs, yieldRate rate() = 1e18. Alice opens 10e18 wstETH with 13_700e18 debt at rate 0.01e18 (CR 146%). Warp 365 days (debt 13_837e18), yieldRate rate() = 1.03e18, feed 1995e18. Pending harvest: Y = 10e18 x 0.03/1.03 = 0.2913e18 shares, credit = 0.9 x Y x 1995 = 523e18. Before harvest: 10 x 1995 = 19,950 < 13,837 x 1.45 = 20,064 (ledger.healthy(id) = false). After harvest: 9.7087 x 1995 = 19,369 >= (13,837 - 523) x 1.45 = 19,305 (healthy). Expected: liquidator.liquidate(id) reverts Healthy(), as it does in the same block if anyone calls ledger.harvest(WSTETH,[id]) first (control test). Actual: liquidate succeeds, repays 13,837 and seizes 7.63 wstETH (110% of the debt at 1995), the marker/treasury take the penalty cuts. Ran: forge test --match-path 'test/scratch/ProofLiquidationIgnoresHarvest.t.sol' (solc 0.8.26 via_ir; also under --use solar): [FAIL: next call did not revert as expected] test_healthyAfterPendingHarvestIsLiquidated() (gas: 1892659); [PASS] test_sameStateAfterHarvestIsNotLiquidatable() (gas: 1423138).","severity":"medium","snippet":"        uint256 debt = _debtOf(p);\n        if (debt == 0) revert NoDebt();","title":"Liquidation health and liquidatePosition ignore the position's pending yield harvest: a wstETH position that is healthy once its own yield lands is liquidated with the full penalty"},{"citation":"resolved","description":"Answer to question 3. The cheapest way to borrow at a lower rate than one's own recent rate without paying 7 days at the old rate is a fresh position after repaying the old one: _upfrontFee prices the new debt at the type's average rate (weighted / debt) read after the repayment removed one's own debt from `weighted`, and at the borrower's OWN chosen rate when weighted == 0. The old position's rate, lastRateChange and premiumRate survive the repayment (so setRate and a same-id reopen pay at the old rate, as documented), but a new id (any salt) starts with rate 0, premiumRate 0 and no cooldown. An NFT transfer carries the clock, an operator cannot pay a premium (NotOwner), and a flash mint only removes the capital need: it repays the old id, the collateral is withdrawn (no price needed at zero debt), re-deposited on the new id and borrowed at the floor. This is the documented Liquity-v2 design (LedgerBase.sol:304-307, Ledger.sol:254-256 'costs at least the market's 7 days of interest') and not a defect; it is reported because on the capped beta's small lines several types will have one or very few borrowers for a while, where 'the market's rate' is the escaping borrower's own choice.","line":311,"path":"src/LedgerBase.sol","reproduction":"ETH type, Alice the only borrower: 10_000e18 debt at 0.10e18, lastRateChange = now. setRate(id, 0.01e18) inside the cooldown costs _premium(10_000e18, 0.10e18) = 19.18e18. Instead: repayFor/adjust -10_000e18 (flash-mintable), adjust -collateral, open(ETH, salt 1, {+collateral, +10_000e18, rate 0.01e18}): weighted == 0 so avg = 0.01e18 and the fee is _premium(10_000e18, 0.01e18) = 1.92e18. Expected by the cooldown's stated intent ('a flash mint cannot launder a rate drop'): about 19.18e18. Actual: 1.92e18 (one tenth), the rate now 1%. With other borrowers present the fee is 7 days at their average, which may still be below the borrower's own old rate.","severity":"info","snippet":"        uint256 avg = s.weighted == 0 ? ownRate : s.weighted / (s.debt + harvested);","title":"Rate cooldown: on a type with no other borrower the repay-and-open-fresh path drops the rate for 7 days at the NEW rate (not the old one)"},{"citation":"resolved","description":"Answer to question 13. Liquidator.bite/settle poke the Pulse and hold seizing while inGrace() (Liquidator.sol:147-150, 210). Redeemer.redeem never reads the Pulse: ledger.redeemPosition -> _accrue pokes it (LedgerBase.sol:252) but nothing checks inGrace(), so redemptions against the lowest-rate positions proceed in the first blocks after a sequencer silence, before borrowers can raise their rate or repay. Not reachable in the beta: mainnet deploys with pulse = address(0) (LedgerBase.sol:145), and the Pulse docstring only promises to hold liquidations. Reported as an open design point for the Robinhood deployment; if redemptions are meant to wait too, add `if (address(ledger.pulse()) != address(0)) { pulse.poke(); if (inGrace) revert; }` at the top of redeem.","line":114,"path":"src/Redeemer.sol","reproduction":"Chain with Pulse(gap 10 minutes, grace 1 hour); 2 hours of silence; first transaction after it: Redeemer.redeem(ilk, amount, 0.05e18, 10, 0). Expected (if redemptions share the grace): revert SequencerGrace / held. Actual: the redemption fills and burns; Liquidator.bite in the same block returns (0,0,0) HeldByGrace.","severity":"info","snippet":"    function redeem(bytes32 ilk, uint256 amount, uint256 maxFee, uint256 maxPositions, uint256 minCollateralOut)","title":"Redeemer does not observe the Pulse sequencer grace (redemptions run inside a grace that holds liquidations)"},{"citation":"resolved","description":"In the standing-bid branch the chip goes to m.marker (line 172). Every beta type has lull 0 (Parameters.maxLull = 0 and no NHI type at genesis), so liquidate(id) (line 221-225) barks and bites in one call and the caller IS the marker: it receives chip x bonus of the sleeve's penalty (BetaConfig chip 0.1e18). Not a defect in behaviour (the kick must be paid to someone), but the comment and the bite() docstring ('the caller pays nothing there') describe only the two-step case. Also: a marker whose mark is live cannot be displaced (AlreadyMarked, line 119) until MARK_TTL of priced time has run, after which anyone may re-bark and take the chip; on grace types the marker who waited 6 hours can lose the chip to a re-bark only after 24 h, which matches the docstring.","line":161,"path":"src/Liquidator.sol","reproduction":"Beta ETH type, Bob's position unhealthy, blue sleeve with room: Carol calls liquidate(id). Expected per the comment: Carol receives nothing. Actual: Carol (the marker set by bark inside liquidate) receives toMarker = bonus x 0.1 in WETH from the sleeve's purchase (LedgerSettlement.sol:165,188).","severity":"info","snippet":"        // the sleeve's. Otherwise the caller pays, as before. The caller earns nothing here: the marker's chip is the kick.","title":"NatSpec: 'The caller earns nothing here: the marker's chip is the kick' is false on no-grace types, where liquidate() makes the caller the marker"},{"citation":"resolved","description":"Answer to question 4. Parameters.riskVersion returns _versions[ilk].length - 1, which includes a version pushed by SET_RISK whose bindAt is still in the future (a raise of mat or chop binds RISK_GRACE later, ParametersRules._bindAt). Ledger._adjust (line 329) and setRate (line 169) write that index into p.version, and Parameters.liqRisk(ilk, v) then returns vs[v] itself (the loop only looks for NEWER bound versions), so the pending mat and chop apply to that position immediately. The health check that precedes it is already at the new stored mat (q.mat = storedRisk.mat, written at apply), so the position is healthy under the new rule at that moment; but an identical position that does not touch its debt stays on the old version until bindAt, and the new (higher) chop applies to the rebound position at once. The action can be a 1-wei borrow by an operator holding BORROW (to the owner), or a signed borrowFor. No position can escape a bound harsher version (liqRisk scans every newer version with bindAt <= now from the newest down), and a version cannot bind earlier than max(now + RISK_GRACE, pending bindAt) for anyone who does not act. This matches ParametersBase.sol:300-303 ('stays on the version it last took on risk under'); it is reported because README 'Versioned liquidation risk' and the same comment promise 'open positions are never made liquidatable by a governance change without a week to repair', which does not hold for a position whose operator tops up its debt during the grace. Smallest change if unwanted: bind to the newest version whose bindAt <= block.timestamp (walk back from the end) rather than length - 1.","line":329,"path":"src/Ledger.sol","reproduction":"ETH type mat 1.40e18 (version 0). Governance applies SET_RISK with mat 1.50e18, chop unchanged: version 1 pushed with bindAt = now + 7 days; _risk[ETH].mat = 1.5e18 at once. Alice holds 10 WETH at 2000 with 13,000 debt (CR 153.8%). Her operator with BORROW calls adjust(id, {dDebt: 1}) -> p.version = 1. Price falls to 1900: CR 146.2%. Expected (README: a week to repair after a raise): ledger.healthy(id) = true until bindAt (1.462 >= 1.40). Actual: liqRisk(ETH, 1) returns mat 1.5e18 and healthy(id) = false; liquidator.liquidate(id) closes it on day 0 of the grace, while Bob's untouched identical position stays safe until day 7.","severity":"info","snippet":"            p.version = uint32(params.riskVersion(ilk)); // a risk-increasing action rebinds to the newest version","title":"A risk-increasing action binds the position to the NEWEST risk version at once, even one whose bindAt (7-day grace) has not passed; an operator with BORROW can do it with a 1-wei borrow"}],"hash":"fcc30349938ec6906ca7221da055468591a685c5931d9ad76a95808f5c601fdf","nodeId":"fecf84a6-14d1-44be-add5-ee5c07a1574f","outcome":"completed","summary":"Review complete. The findings file `.imd-findings.json` holds five findings, one with a Foundry proof that fails under solc 0.8.26 via_ir and under Solar, and the tree is back to the pinned state (the scratch test is deleted).\n\n## Findings\n\n| Severity | Finding | Where |\n|---|---|---|\n| Medium | Liquidation health and `liquidatePosition` ignore the position's pending yield harvest. On WSTETH (mat 145%, yieldFee 10%) a position that is healthy once its own yield lands is liquidated with the 10% penalty. Proof included and run under solc. | `src/LedgerSettlement.sol:120`, `src/Ledger.sol:471` |\n| Info | Q3: with no other borrower on the type, repay and open a fresh id drops the rate for 7 days at the NEW rate. Documented Liquity v2 design, flagged for the beta's thin types. | `src/LedgerBase.sol:311` |\n| Info | Q4: a risk action binds a position to a pending harsher version before its grace; an operator with BORROW can do it with 1 wei. | `src/Ledger.sol:329` |\n| Info | Q13: `Redeemer.redeem` never reads the Pulse grace. Not reachable in the beta (pulse is zero on mainnet). | `src/Redeemer.sol:114` |\n| Info | NatSpec \"the caller earns nothing here\" is false on no-grace types, where `liquidate()` makes the caller the marker. | `src/Liquidator.sol:161` |\n\nProof output under solc (the control test harvests first in the same block and is refused as expected):\n\n```\nRan 2 tests for test/scratch/ProofLiquidationIgnoresHarvest.t.sol:ProofLiquidationIgnoresHarvest\n[FAIL: next call did not revert as expected] test_healthyAfterPendingHarvestIsLiquidated() (gas: 1892659)\n[PASS] test_sameStateAfterHarvestIsNotLiquidatable() (gas: 1423138)\n```\n\n## Answers to the numbered questions\n\n1. **Debt bookkeeping: holds.** The type accrues `ceil(weighted * dt / Y)` and each position `floor(d * r * dt / Y)` since its own `lastUpdate`; every touch accrues the type first, then records the position's debt with interest into `weighted`, so the type's debt is never below the sum of positions. `adjust`, `setRate` (premium added to both sides), `harvest`, `redeemPosition`, `liquidatePosition` (`s.debt -= debt - left`) and `writeOff` all move `s.debt` and `_record` together. Interest minted equals the type-side accrual; the rounding residue stays in the type's debt and counts against the line, which the code documents.\n2. **Harvest: holds.** Yield shares are `collateral * (rateNow - mark) / rateNow`, so exactly the shares' growth, priced at `q.low` only when `priced`. A rate dip holds the mark; a deposit while unpriced blends the mark so the unharvested yield in shares is unchanged; a withdrawal needs a borrowable price, so a harvest runs first; a position with no debt only moves its mark; transfers carry the mark. Each position harvests its own collateral only. The only gap is the liquidation touch, finding 1.\n3. **Upfront fee and cooldown.** Cheapest path is the fresh id after repaying the old one: 7 days at the type's average excluding one's own debt, and at one's own new rate on an empty type (finding 2). Same-id reopen, top-up inside a charged cooldown, operator `setRate` and NFT transfer all pay or carry the old rate. A flash mint only removes the capital need.\n4. **Risk versions.** No escape: `liqRisk` scans every newer version with a passed `bindAt`. Early binding happens only through the position's own risk action (finding 3).\n5. **Positions and rights: holds.** `execute` is NFT-only and checks the signer owns the id; nonces are sequential and `bumpNonce` is bounded; per-id grants are keyed by owner and epoch; `_rightsFor` maps every change to a right and a reopen needs SET_RATE; holder-bound deposits come only from the owner and withdrawals go only to the owner, including through `execute` and `addFor`; `onTransfer` is nonReentrant so ownership cannot change mid-call.\n6. **Sorted list: holds.** `_relist` runs on every path with `listed = recorded debt before != 0` and `has = debt after != 0`; `_valid` requires adjacency and strict ordering, otherwise ","treeHash":null,"usage":{"cachedInputTokens":2963503,"cost":{"source":"runtime","usdMicros":7077361},"inputTokens":674,"model":"claude-fable-5-1","outputTokens":60180,"runtime":"claude","turns":35,"wallClockMs":1262877}}],"verification":[]}