# Audit report

> 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.
>
> imdUSD 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.
>
> BUILD 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).
>
> This panel covers two areas whose money flows meet: give both the same depth, and answer every question.
>
> Answer each numbered question, including the ones where nothing is wrong:
> 1. 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.
> 2. 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.
> 3. 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.
> 4. 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?
> 5. 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.
> 6. 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.
> 7. THE SPLIT (LedgerSettlement by delegatecall). Storage layout agreement with Ledger, the shared reentrancy slot, and every entry point reachable on the settlement module directly.
> 8. 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.
> 9. 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.
> 10. 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?
> 11. 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.
> 12. 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.
> 13. PULSE (sequencer grace, on chains that have one). Can a liquidation or a redemption run inside a grace it should wait out?
>
> Not 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.
>
> For 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.

| | |
|---|---|
| Repository | https://github.com/fa11up/imdusd-v2-audit.git |
| Commit | `2875ffb22a726f041bd6891f32f35ddc7cdb983d` |
| Job | `59d97f98-3694-4e00-9c77-f2195bd39818` |
| Judged | 2026-11-10 21:24 UTC |
| Findings | 1 medium · 3 low · 5 info |

Four agents audited the code as it is at `2875ffb`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: 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

`src/LedgerSettlement.sol:120`

```
        uint256 debt = _debtOf(p);
```

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.

**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).

### 2. Low: 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

`src/Ledger.sol:172`

```
        if (newRate != oldRate) p.premiumRate = inCooldown ? uint64(oldRate) : 0;
```

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.

**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.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ImdUSD} from "src/ImdUSD.sol";
import {ParametersBase} from "src/ParametersBase.sol";
import {Parameters} from "src/Parameters.sol";
import {Pricer} from "src/Pricer.sol";
import {Treasury, ICoverable} from "src/Treasury.sol";
import {PositionNFT} from "src/PositionNFT.sol";
import {Ledger, IPulse} from "src/Ledger.sol";
import {LedgerBase} from "src/LedgerBase.sol";
import {Redeemer} from "src/Redeemer.sol";
import {Liquidator} from "src/Liquidator.sol";
import {Migrator} from "src/Migrator.sol";
import {Join} from "src/Join.sol";
import {IFeed} from "src/interfaces/IFeed.sol";
import {Adjust} from "src/interfaces/IPositions.sol";

contract PLToken is ERC20 {
    constructor() ERC20("WETH", "WETH") {}
    function mint(address to, uint256 a) external { _mint(to, a); }
}

contract PLFeed is IFeed {
    uint256 public value;
    uint64 public updatedAt;
    constructor(uint256 v) { value = v; updatedAt = uint64(block.timestamp); }
    function set(uint256 v) external { value = v; updatedAt = uint64(block.timestamp); }
    function latestValue() external view returns (uint256, uint64) { return (value, updatedAt); }
    function isStale() external view returns (bool) { return block.timestamp - updatedAt > 365 days; }
    function maxAge() external pure returns (uint256) { return 365 days; }
}

/// @dev Q3: the C2 rule says debt added inside the cooldown of a charged rate change pays RATE_COOLDOWN of interest at the
/// rate BEFORE that change ("a dust reopen followed by a top-up costs what one reopen does"). Two in-cooldown setRate
/// calls on a dust-sized debt overwrite `premiumRate` with the freely chosen intermediate rate, so the top-up pays the
/// premium at that rate (here 2%) instead of the original 10%.
contract ProofPremiumLaunder is Test {
    bytes32 constant ETH = "ETH";
    address governor = makeAddr("governor");
    address alice = makeAddr("alice");

    ImdUSD usd;
    Parameters params;
    Pricer pricer;
    Treasury treasury;
    Ledger ledger;
    PLToken weth;
    PLFeed feed;

    function setUp() public {
        vm.warp(1_800_000_000);
        weth = new PLToken();
        feed = new PLFeed(2000e18);
        PLToken v1 = new PLToken();

        usd = new ImdUSD(address(this), 10_000_000e18);
        params = new Parameters(governor, address(this));
        pricer = new Pricer(params);
        treasury = new Treasury(governor, IERC20(address(usd)), address(this));
        PositionNFT nft_ = new PositionNFT(address(this));
        ledger = new Ledger(usd, params, pricer, address(treasury), address(this), IPulse(address(0)), nft_);
        nft_.bind(address(ledger));
        Redeemer redeemer = new Redeemer(ledger);
        Liquidator liquidator = new Liquidator(ledger, pricer);
        ledger.wire(address(redeemer), address(liquidator));
        Migrator migrator = new Migrator(IERC20(address(v1)), usd, params);
        usd.wire(address(ledger), address(migrator), address(params));
        params.wire(address(ledger), address(usd));
        treasury.wire(ICoverable(address(ledger)));

        Join join = new Join(address(weth), address(this));
        join.wire(address(ledger));
        params.addGenesisIlk(
            ETH,
            ParametersBase.Config({
                token: address(weth), join: address(join), median: feed, spot: feed, nhi: IFeed(address(0)),
                yieldRate: address(0), pegAggregator: address(0), liveSpot: IFeed(address(0)), redeemable: true, auction: false
            }),
            ParametersBase.Risk({
                mat: 1.4e18, chop: 0.1e18, cut: 0.5e18, chip: 0.1e18, rateFloor: 0.01e18, yieldFee: 0, skew: 0.05e18,
                pegFloor: 0, line: 1_000_000e18, dust: 100e18, redeemFloor: 0.005e18, redeemDivisor: 2, redeemCap: 0
            })
        );
        params.seal();

        weth.mint(alice, 100e18);
        vm.startPrank(alice);
        weth.approve(address(ledger), type(uint256).max);
        vm.stopPrank();
    }

    function _premium(uint256 amount, uint256 rate) internal pure returns (uint256) {
        return (amount * rate * 7 days + 365 days * 1e18 - 1) / (365 days * 1e18);
    }

    function test_twoInCooldownRateChangesLaunderThePremiumRate() public {
        uint256 id = ledger.positionId(alice, ETH, 0);
        // Alice borrows 10,000 at 10%: lastRateChange = now, so a change inside 7 days is charged at 10%.
        vm.prank(alice);
        ledger.open(ETH, 0, Adjust(int256(100e18), int256(10_000e18), 0.10e18, 0, 0, false, type(uint256).max, address(0)));
        vm.warp(block.timestamp + 1 days);

        // The honest price of dropping 10,000 of debt from 10% to 1% inside the cooldown: 7 days at 10%.
        uint256 honest = _premium(10_000e18, 0.10e18);

        // 1. Repay down to dust (free, no price needed).
        uint256 debt = ledger.debtOf(id);
        vm.prank(alice);
        ledger.adjust(id, Adjust(0, -int256(debt - 101e18), 0, 0, 0, false, type(uint256).max, address(0)));
        assertApproxEqAbs(ledger.debtOf(id), 101e18, 1);

        // 2. Two rate changes on the dust: 10% -> 2% (charged at 10% on 101) then 2% -> 1% (charged at 2% on ~101).
        vm.startPrank(alice);
        ledger.setRate(id, 0.02e18, 0, 0);
        ledger.setRate(id, 0.01e18, 0, 0);
        vm.stopPrank();
        LedgerBase.Position memory p = ledger.position(id);
        assertEq(p.rate, 0.01e18);
        uint256 dust = p.debt;
        uint256 paidSoFar = dust - 101e18; // the two tiny premiums (about 0.19 + 0.04 imdUSD)

        // 3. Top up the 9,900 back inside the cooldown.
        ledger.accrue(ETH);
        uint256 upfront = ledger.upfrontFeeFor(ETH, 9_899e18, 0.01e18);
        vm.prank(alice);
        ledger.adjust(id, Adjust(0, int256(9_899e18), 0, 0, 0, false, type(uint256).max, address(0)));
        uint256 topUpPremium = ledger.debtOf(id) - dust - 9_899e18 - upfront;

        emit log_named_uint("honest premium (7d at 10% on 10,000)", honest);
        emit log_named_uint("premiumRate after the two changes   ", p.premiumRate);
        emit log_named_uint("premium charged on the 9,899 top-up ", topUpPremium);
        emit log_named_uint("total premium paid over the sequence", topUpPremium + paidSoFar);

        // Documented rule (LedgerBase 125-127, Ledger 281-283): the top-up pays at the rate BEFORE the charged change (10%).
        // Actual: `premiumRate` is the freely chosen intermediate rate (2%), and the top-up pays about a fifth.
        assertEq(p.premiumRate, 0.10e18, "premiumRate should still be the rate the cooldown was entered at");
        assertGe(topUpPremium + paidSoFar, honest, "the sequence must cost at least one honest rate change");
    }
}
```

### 3. Low: 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

`src/Ledger.sol:159`

```
            premium = _premium(debt, oldRate);
            debt += premium;
            s.debt += premium.toUint128();
            if (uint256(s.debt) + s.badDebt > params.risk(ilk).line) revert CeilingExceeded(); // `r` is the stored risk: the depth rule binds here
            _checkLines(ilk);
            _checkEdge(ilk);
            _mintInterest(ilk, premium);
            if (!q.borrowable || !q.agree) revert NotPriced(); // a pair above the pool is not a price to add debt at
            // A rate change is not a borrow, so the cap does not measure its premium.
            if (!_healthy(p.collateral, debt, q.low, q.mat)) revert Unhealthy();
```

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).

**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).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ImdUSD} from "src/ImdUSD.sol";
import {ParametersBase} from "src/ParametersBase.sol";
import {Parameters} from "src/Parameters.sol";
import {Pricer} from "src/Pricer.sol";
import {Treasury, ICoverable} from "src/Treasury.sol";
import {PositionNFT} from "src/PositionNFT.sol";
import {Ledger, IPulse} from "src/Ledger.sol";
import {LedgerBase} from "src/LedgerBase.sol";
import {Redeemer} from "src/Redeemer.sol";
import {Liquidator} from "src/Liquidator.sol";
import {Migrator} from "src/Migrator.sol";
import {Join} from "src/Join.sol";
import {IFeed} from "src/interfaces/IFeed.sol";
import {Adjust} from "src/interfaces/IPositions.sol";

contract SRToken is ERC20 {
    constructor() ERC20("WETH", "WETH") {}
    function mint(address to, uint256 a) external { _mint(to, a); }
}

contract SRFeed is IFeed {
    uint256 public value;
    uint64 public updatedAt;
    constructor(uint256 v) { value = v; updatedAt = uint64(block.timestamp); }
    function set(uint256 v) external { value = v; updatedAt = uint64(block.timestamp); }
    function latestValue() external view returns (uint256, uint64) { return (value, updatedAt); }
    function isStale() external view returns (bool) { return block.timestamp - updatedAt > 365 days; }
    function maxAge() external pure returns (uint256) { return 365 days; }
}

/// @dev setRate's charged premium is new debt (minted, added to s.debt and p.debt) but skips two checks every borrow runs in
/// Ledger._adjust: the borrow-price cap (`q.borrow`, Pricer NatSpec: "a charged rate change" is measured at min(low, cap))
/// and the unbacked-supply brake (`_checkBrake`, "no new debt on any type").
contract ProofSetRateChecks is Test {
    bytes32 constant ETH = "ETH";
    address governor = makeAddr("governor");
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    ImdUSD usd;
    Parameters params;
    Pricer pricer;
    Treasury treasury;
    Ledger ledger;
    Liquidator liquidator;
    SRToken weth;
    SRFeed feed;

    function setUp() public {
        vm.warp(1_800_000_000);
        weth = new SRToken();
        feed = new SRFeed(2000e18);
        usd = new ImdUSD(address(this), 10_000_000e18);
        params = new Parameters(governor, address(this));
        pricer = new Pricer(params);
        treasury = new Treasury(governor, IERC20(address(usd)), address(this));
        PositionNFT nft_ = new PositionNFT(address(this));
        ledger = new Ledger(usd, params, pricer, address(treasury), address(this), IPulse(address(0)), nft_);
        nft_.bind(address(ledger));
        Redeemer redeemer = new Redeemer(ledger);
        liquidator = new Liquidator(ledger, pricer);
        ledger.wire(address(redeemer), address(liquidator));
        Migrator migrator = new Migrator(IERC20(address(new SRToken())), usd, params);
        usd.wire(address(ledger), address(migrator), address(params));
        params.wire(address(ledger), address(usd));
        treasury.wire(ICoverable(address(ledger)));

        Join join = new Join(address(weth), address(this));
        join.wire(address(ledger));
        params.addGenesisIlk(
            ETH,
            ParametersBase.Config({
                token: address(weth), join: address(join), median: feed, spot: feed, nhi: IFeed(address(0)),
                yieldRate: address(0), pegAggregator: address(0), liveSpot: IFeed(address(0)), redeemable: true, auction: false
            }),
            ParametersBase.Risk({
                mat: 1.4e18, chop: 0.1e18, cut: 0.5e18, chip: 0.1e18, rateFloor: 0.01e18, yieldFee: 0, skew: 0.05e18,
                pegFloor: 0, line: 1_000_000e18, dust: 100e18, redeemFloor: 0.005e18, redeemDivisor: 2, redeemCap: 0
            })
        );
        params.setGenesisCap(ETH, 1500e18); // a borrow-price ceiling below the market (Sky's capped OSM)
        params.seal();

        address[2] memory us = [alice, bob];
        for (uint256 i; i < 2; ++i) {
            weth.mint(us[i], 1000e18);
            vm.startPrank(us[i]);
            weth.approve(address(ledger), type(uint256).max);
            usd.approve(address(ledger), type(uint256).max);
            vm.stopPrank();
        }
    }

    function _open(address u, uint256 salt, uint256 coll, uint256 debt, uint64 rate) internal returns (uint256 id) {
        vm.prank(u);
        id = ledger.open(ETH, salt, Adjust(int256(coll), int256(debt), rate, 0, 0, false, type(uint256).max, address(0)));
    }

    /// Pricer.sol:28-29 / ParametersBase.sol:196-197: a charged rate change is measured at `borrow = min(low, cap)`.
    /// Ledger.setRate measures it at the uncapped `low` (Ledger.sol:168).
    function test_chargedRateChangeIgnoresBorrowCap() public {
        uint256 id = _open(alice, 0, 10e18, 10_000e18, 0.05e18); // CR at the cap (1500): 150%
        vm.prank(governor);
        params.lowerCap(ETH, 1300e18); // CR at the cap: 130% < mat 140%; at `low` (2000): 200%
        vm.prank(alice);
        vm.expectRevert(LedgerBase.Unhealthy.selector);
        ledger.adjust(id, Adjust(0, 1, 0, 0, 0, false, type(uint256).max, address(0)));
        uint256 before = ledger.debtOf(id);
        vm.prank(alice);
        vm.expectRevert(LedgerBase.Unhealthy.selector); // what the Pricer / Parameters NatSpec promises
        ledger.setRate(id, 0.06e18, 0, 0);
        assertEq(ledger.debtOf(id), before, "no premium debt added below the capped health line");
    }

    /// LedgerBase.sol:164-166 / :320-321: while `unbacked()` is past the brake, "no new debt on any type". `_adjust` runs
    /// `_checkBrake()` on a borrow (Ledger.sol:301); setRate's charged premium is minted without it (Ledger.sol:159-166).
    function test_chargedRateChangeBypassesUnbackedBrake() public {
        uint256 a = _open(alice, 0, 10e18, 10_000e18, 0.05e18);
        uint256 b = _open(bob, 0, 100e18, 20_000e18, 0.05e18);
        feed.set(500e18); // alice: 10 ETH = 5,000 against ~10,010 of debt; bob stays at CR 250%
        vm.prank(bob);
        liquidator.liquidate(a); // bob pays 5,000; ~5,010 is booked bad debt; the Treasury covers only its fees
        assertGt(ledger.unbacked(), usd.circulating() / 100, "braked");
        vm.prank(bob);
        vm.expectPartialRevert(LedgerBase.Braked.selector);
        ledger.adjust(b, Adjust(0, 1, 0, 0, 0, false, type(uint256).max, address(0)));
        uint256 before = ledger.debtOf(b);
        uint256 supply = usd.totalSupply();
        vm.prank(bob);
        vm.expectPartialRevert(LedgerBase.Braked.selector); // expected: Braked, as for any other new debt
        ledger.setRate(b, 0.06e18, 0, 0);
        assertEq(ledger.debtOf(b), before, "no new debt while braked");
        assertEq(usd.totalSupply(), supply, "nothing minted while braked");
    }
}
```

### 4. Low: 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

`src/Ledger.sol:329`

```
            p.version = uint32(params.riskVersion(ilk)); // a risk-increasing action rebinds to the newest version
```

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.

**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.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ImdUSD} from "src/ImdUSD.sol";
import {ParametersBase} from "src/ParametersBase.sol";
import {Parameters} from "src/Parameters.sol";
import {Pricer} from "src/Pricer.sol";
import {Treasury, ICoverable} from "src/Treasury.sol";
import {PositionNFT} from "src/PositionNFT.sol";
import {Ledger, IPulse} from "src/Ledger.sol";
import {Redeemer} from "src/Redeemer.sol";
import {Liquidator} from "src/Liquidator.sol";
import {Migrator} from "src/Migrator.sol";
import {Join} from "src/Join.sol";
import {IFeed} from "src/interfaces/IFeed.sol";
import {Adjust} from "src/interfaces/IPositions.sol";

contract RVToken is ERC20 {
    constructor() ERC20("WETH", "WETH") {}
    function mint(address to, uint256 a) external { _mint(to, a); }
}

contract RVFeed is IFeed {
    uint256 public value;
    uint64 public updatedAt;
    constructor(uint256 v) { value = v; updatedAt = uint64(block.timestamp); }
    function set(uint256 v) external { value = v; updatedAt = uint64(block.timestamp); }
    function latestValue() external view returns (uint256, uint64) { return (value, updatedAt); }
    function isStale() external view returns (bool) { return block.timestamp - updatedAt > 365 days; }
    function maxAge() external pure returns (uint256) { return 365 days; }
}

/// @dev Q4: `Parameters.riskVersion` is the NEWEST version, pending or not, and `liqRisk(ilk, newest)` returns it with no
/// bindAt check. Ledger._adjust / setRate write it into `p.version` on any borrow or withdrawal, so a position touched
/// during the 7-day grace is liquidated at the pending (harsher) mat and chop at once, while an untouched twin keeps the
/// old version until `bindAt`.
contract ProofRiskVersionRebind is Test {
    bytes32 constant ETH = "ETH";
    address governor = makeAddr("governor");
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");
    address keeper = makeAddr("keeper");

    ImdUSD usd;
    Parameters params;
    Pricer pricer;
    Treasury treasury;
    Ledger ledger;
    Liquidator liquidator;
    RVToken weth;
    RVFeed feed;

    function _risk(uint64 mat, uint64 chop) internal pure returns (ParametersBase.Risk memory) {
        return ParametersBase.Risk({
            mat: mat, chop: chop, cut: 0.5e18, chip: 0.1e18, rateFloor: 0.01e18, yieldFee: 0, skew: 0.05e18,
            pegFloor: 0, line: 1_000_000e18, dust: 100e18, redeemFloor: 0.005e18, redeemDivisor: 2, redeemCap: 0
        });
    }

    function setUp() public {
        vm.warp(1_800_000_000);
        weth = new RVToken();
        feed = new RVFeed(2000e18);
        usd = new ImdUSD(address(this), 10_000_000e18);
        params = new Parameters(governor, address(this));
        pricer = new Pricer(params);
        treasury = new Treasury(governor, IERC20(address(usd)), address(this));
        PositionNFT nft_ = new PositionNFT(address(this));
        ledger = new Ledger(usd, params, pricer, address(treasury), address(this), IPulse(address(0)), nft_);
        nft_.bind(address(ledger));
        Redeemer redeemer = new Redeemer(ledger);
        liquidator = new Liquidator(ledger, pricer);
        ledger.wire(address(redeemer), address(liquidator));
        Migrator migrator = new Migrator(IERC20(address(new RVToken())), usd, params);
        usd.wire(address(ledger), address(migrator), address(params));
        params.wire(address(ledger), address(usd));
        treasury.wire(ICoverable(address(ledger)));
        Join join = new Join(address(weth), address(this));
        join.wire(address(ledger));
        params.addGenesisIlk(
            ETH,
            ParametersBase.Config({
                token: address(weth), join: address(join), median: feed, spot: feed, nhi: IFeed(address(0)),
                yieldRate: address(0), pegAggregator: address(0), liveSpot: IFeed(address(0)), redeemable: true, auction: false
            }),
            _risk(1.4e18, 0.1e18)
        );
        params.seal();
        address[2] memory us = [alice, bob];
        for (uint256 i; i < 2; ++i) {
            weth.mint(us[i], 1000e18);
            vm.startPrank(us[i]);
            weth.approve(address(ledger), type(uint256).max);
            usd.approve(address(ledger), type(uint256).max);
            vm.stopPrank();
        }
        deal(address(usd), keeper, 100_000e18);
        vm.prank(keeper);
        usd.approve(address(ledger), type(uint256).max);
    }

    function _open(address u, uint256 coll, uint256 debt) internal returns (uint256 id) {
        vm.prank(u);
        id = ledger.open(ETH, 0, Adjust(int256(coll), int256(debt), 0.05e18, 0, 0, false, type(uint256).max, address(0)));
    }

    function test_deRiskingAdjustBindsToPendingVersionBeforeItsGrace() public {
        uint256 a = _open(alice, 10e18, 13_000e18); // CR 153.8% under version 0 (mat 140%)
        uint256 b = _open(bob, 10e18, 13_000e18); // identical twin, never touched

        // Governance raises mat 140% -> 160%: version 1 pushed with bindAt = now + 7 days.
        uint8 kind = params.SET_RISK();
        vm.prank(governor);
        params.propose(abi.encode(kind, ETH, _risk(1.6e18, 0.1e18)));
        vm.warp(block.timestamp + 48 hours);
        params.applyPending();
        feed.set(2000e18);
        assertEq(params.riskVersion(ETH), 1);

        // Alice repays half and withdraws 40% of her collateral: ratio UP (153.8% -> 184.6%), passes the health check.
        vm.prank(alice);
        ledger.adjust(a, Adjust(-4e18, -6_500e18, 0, 0, 0, false, 0, address(0)));

        // Price falls 15%. Alice: 6 x 1700 = 10,200 / ~6,501 = 156.9%: healthy at 140%, unhealthy at 160%.
        feed.set(1700e18);
        (bool okB,) = ledger.healthy(b); // Bob: 17,000 / 13,000 = 130.8%: unhealthy either way (control: version 0 works)
        assertFalse(okB);
        (bool okA,) = ledger.healthy(a);
        (uint256 matA,) = params.liqRisk(ETH, ledger.position(a).version);
        emit log_named_uint("alice.version", ledger.position(a).version);
        emit log_named_uint("mat binding alice now", matA);
        // EXPECTED (ParametersBase.sol:300-303, README): version 1 binds nobody before its bindAt, so Alice is healthy
        // at 140% for the rest of the week. ACTUAL: she is on version 1 already and reads unhealthy at 160%.
        assertEq(ledger.position(a).version, 0, "bound to a version whose bindAt has not passed");
        assertTrue(okA, "healthy at the version in force (140%)");
        vm.prank(keeper);
        vm.expectRevert(Liquidator.Healthy.selector);
        liquidator.liquidate(a);
    }
}
```

### 5. Info: 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

`src/LedgerBase.sol:126`

```
    /// costs what one reopen does. A position's rate and `lastRateChange` survive a repayment (or redemption) to
    /// zero, so reopening at another rate inside the cooldown pays the premium (a flash mint cannot launder a rate drop).
```

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).

**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.

### 6. Info: 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)

`src/Redeemer.sol:114`

```
    function redeem(bytes32 ilk, uint256 amount, uint256 maxFee, uint256 maxPositions, uint256 minCollateralOut)
```

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; }.

**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.

### 7. Info: 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

`src/Liquidator.sol:161`

```
        // the sleeve's. Otherwise the caller pays, as before. The caller earns nothing here: the marker's chip is the kick.
```

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.

**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.

### 8. Info: borrowFor NatSpec says the position 'must already have debt'; nothing enforces it (fails closed in the beta only because every rateFloor > 0)

`src/PositionNFT.sol:141`

```
    /// @notice Borrow `amount` on position `id` (which must already have debt: the rate is the position's) to `to` (zero:
    /// its owner), by its owner's `BorrowFor` signature.
```

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.

**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.

### 9. Info: Garbled NatSpec in Pricer.Quote.agree (also omits that a charged setRate needs `borrowable`) and a stray '(-10-30)' in the Redeemer header

`src/Pricer.sol:43`

```
        /// not below `low` by more than `skew`. The standing bid reads it, and a charged rate change
        /// : failing it sends a liquidation to the caller path, so pushing the pool away can only make the sleeve stand
```

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.

**Reproduction**

Read src/Pricer.sol:43-44 against src/Ledger.sol:166 (`if (!q.borrowable || !q.agree) revert NotPriced();`) and src/Redeemer.sol:19.

---

Judge's submission `886c79112335f9fc22490595bac189cbe1c090d9a0b15d5ad4b1eec76bcee0ec`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
