Skip to content

Math & Hints (the correctness-critical core) ​

This is the heart of the library. The math here is the only client-side computation in musd-kit, and it is held to the highest test bar (07-testing). Everything is verified against 01-ground-truth §6-§7; this document is the implementation-level spec.

The boundary, restated: for a live position, do not use anything here, read the contract getters (read/, 03-core-api §2). The formulas below are for previews of positions that do not exist yet, and for the thin derivations (liquidationPrice, healthFactor) layered on authoritative live values.

All quantities are bigint in 1e18 fixed point unless noted. Rates are basis points (uint16 on-chain).


1. The verified quantities ​

QuantityFormulaNotes
Borrowing feefee = getBorrowingFee(draw)read on-chain; governable (C2)
Net debtnetDebt = draw + feethe fee is added to debt (C6)
Entire (composite) debtentireDebt = netDebt + 200e18+ gas compensation
ICRicr = (collateral × price) / entireDebt1e18 fixed point; = contract computeCR
Nominal ICR (for hints)nicr = (collateral × 1e20) / entireDebt= contract computeNominalCR (1e20 = 100·1e18)
Liquidation priceliqPrice = (MCR × entireDebt) / collateralMCR = 1.1e18; price at which ICR == MCR
Min-debt checknetDebt ≥ minNetDebtread minNetDebt(); floor on draw+fee (C1, C6)
Health factornormalize icr / MCR → distance-to-liquidation1.0 at MCR, higher = safer (UI/keeper)

1e20 for NICR is exact: nominal CR uses collateral × 100e18 / entireDebt, and 100e18 == 1e20. Cross-check the SDK's NICR against HintHelpers.computeNominalCR on the fork; they must be identical.


2. Entire-debt for a PREVIEW (no live position) ​

A live Trove's entire debt is read from getEntireDebtAndColl, never computed. For a preview (e.g. "what will my debt be in 30 days?"), use the verified interest model (01-ground-truth §7):

interest_accrued = principal × rate_bips / 10_000 × elapsedSeconds / SECONDS_PER_YEAR   // linear, simple
entireDebt_preview = principal + interest_accrued + fees + 200e18
  • Linear, non-compounding, time-based (seconds), not blocks (C3).
  • SECONDS_PER_YEAR must match the contract's constant exactly (verify on the fork; do not assume 31_536_000 vs 31_557_600, read/confirm what the contract uses).
  • Subtlety (C4): a Trove's fixed rate is set at open from its 110%-CR maximum borrowing capacity, not the initial draw. previewOpen must compute the rate the way the contract will, then validate that the predicted rate matches the rate the contract assigns when the position is actually opened on the fork.

This preview accrual is the single most error-prone formula in the library; its fork validation (§5) is the M1 correctness gate.


3. Borrowing power ​

The maximum MUSD a user can draw against collateral at price, staying above MCR:

find the largest `draw` such that:
    (collateral × price) / (draw + getBorrowingFee(draw) + 200e18) ≥ MCR
  AND
    (draw + getBorrowingFee(draw)) ≥ minNetDebt      // read minNetDebt()

Because the fee depends on draw (and may be a percentage), solve for draw analytically if the fee is linear, or by monotonic search otherwise, then verify the boundary on the fork by actually opening a Trove at the computed max and confirming its ICR is ≥ MCR (and that one wei more would breach it). In Recovery Mode (TCR < CCR), the effective constraint tightens, surface it (O3) and reflect it in the returned power.

Do not use a "≥ 2,000 net" floor, the floor is minNetDebt (currently 1,800), read on-chain (C1/C6).


4. Live-position derivations (thin, over authoritative values) ​

For getTrove on a live position, icr, entireDebt, interestOwed, nominalICR come from contract getters. Only two fields are derived, and they are derived from those authoritative values, not recomputed from scratch:

liquidationPrice = (MCR × entireDebt_fromContract) / collateral_fromContract
healthFactor     = f(icr_fromContract / MCR)        // monotonic; 1.0 at MCR

liquidationPrice rises over time as interest accrues (entire debt grows) unless the user repays, a subtlety a naive static calculation misses. Because entireDebt here is read live (accrued to now), the derived liquidationPrice is correct at read time.


4b. What the open preview actually models ​

previewOpen mirrors _openTrove (BorrowerOperations.sol:631-665) rather than approximating it. Three rules that the earlier version got wrong, each now enforced:

RuleSourceConsequence
The fee is charged only when !isRecoveryMode && !isAccountFeeExempt(borrower):637-643In Recovery Mode, or for an exempt account, netDebt is the bare draw and the fee is zero (MK-004, MK-018)
The debt floor is checked against that same netDebt:645Adding a phantom fee made the floor look met in the band draw < minNetDebt <= draw + fee, for an open that reverts. Charging the fee correctly closes it
Recovery Mode requires ICR >= CCR; normal mode requires both ICR >= MCR and a resulting system TCR >= CCR:654-665viable covers all three; the old meetsRecoveryRequirement covered none of them in normal mode (MK-005)

Pass account whenever you have it, to previewOpen and to getBorrowingPower alike: exemption is read, not assumed, and the exempt cohort is non empty on mainnet. Until the P13 wave only the first of the two would take one (MK-067).

Borrowing against an existing Trove is a different calculation. getBorrowingPower solves the OPEN constraints only. An existing Trove is additionally gated by maxBorrowingCapacity >= netDebtChange + debt (:1358-1365), where capacity was fixed at the opening price (:1323-1328), is lowered on a collateral decrease (:879-897), and is reset from the current price by every refinance (:1077-1084, MK-101). That is previewBorrow (MK-002). Note the debt in that comparison is read after updateSystemAndTroveInterest (:769), so it includes accrued interest, which is why the SDK compares against the live entire debt rather than the stored getTroveDebt.

getBorrowingPower also enforces the resulting system TCR in normal mode, which the contract requires on every normal mode open and which it previously ignored.

The maximum is the liquidation threshold, and a draw that survives holding needs a margin the caller chooses (MK-100, MK-240). Solving for the largest draw the open gate accepts means solving for ICR == MCR whenever the individual ratio binds, and MCR is also where liquidation begins (ICR < MCR, TroveManager.sol:1146-1148). An open accrues no interest before its ratio check (BorrowerOperations.sol:648-657), so that ceiling is still accepted a block later; the debt then accrues every second (TroveManager.sol:1513-1527), and the position is liquidatable almost at once.

drawForMargin runs the same solver against a stressed price, price * (10000 - priceFallBps) / 10000 / (1 + accrual), where accrual is horizonSeconds of interest at the live global rate, rounded up. Every open gate is a comparison of collateral * price against a multiple of debt, so clearing the gates at the stressed price is clearing them after that fall together with that accrual. Both inputs are the caller's, with no default: until 0.5.0 a recommended figure used 200 bps and 3600 seconds, a margin for the delay before a send presented as the amount to hold (MK-240). How often a fall of a given size followed within a given horizon on mainnet is tabled in 03-core-api, from scripts/oracle-moves.ts --horizons. In Recovery Mode the ceiling lands on CCR, which is not liquidatable. When the system TCR binds instead, the ceiling is refused a block later, because the system debt accrues too (ActivePool.sol:134-144), and a margin draw opens.

It no longer decides any of these rules itself (MK-067, MK-069). Its feasibility predicate is evaluateOpen, the evaluator behind previewOpen, so a maximum and a candidate verdict cannot disagree about where the boundary is. They did: this function charged the borrowing fee in Recovery Mode and for an exempt account, where the contract charges none (BorrowerOperations.sol:637-643), so every Recovery Mode maximum came back short by the fee while previewOpen called the larger draw viable. It also took no account, which made exemption inexpressible; it takes one now.

4c. Insertion hints are computed from PRINCIPAL ​

SortedTroves is ordered by a quantity that excludes interest, everywhere:

  • TroveManager.getNominalICR is _computeNominalCR(coll + pending, principal + pending) (TroveManager.sol:566-577), with no interest term.
  • Every on-chain re-insert passes _computeNominalCR(coll, PRINCIPAL): BorrowerOperations.sol:902-906 (adjust), :1087-1088 (refinance), and TroveManager.sol:1287-1290 (partial redemption).

Feeding a hint the ENTIRE debt therefore names a position that is not in the list. The contract re-validates and traverses from a bad hint, so the cost is gas and latency rather than a wrong number, and in the worst case an out-of-gas insert (MK-006).

A repayment is applied to interest first. InterestRateMath.calculateDebtAdjustment branches on payment >= interestOwed (InterestRateMath.sol:41-47): at or above, principal falls by payment - interestOwed, which is zero at exact equality; below, principal falls by exactly zero. So a repay at or under the interest owed does not move the sort key at all, and modeling debt as falling by the full payment produced a hint for a position that never exists.

Why the open path was accidentally correct, and why the gate could not catch this. At open there is no accrued interest, so the composite debt IS the principal and the two quantities coincide. The dual-validation gate in §5 covered the open path only, so it compared a quantity that happens to be right there and never exercised a path where interest exists. A gate that only covers the case where two quantities coincide cannot tell you which one you meant.


5. The dual-validation method (how preview math earns trust) ​

Every formula in math/ is validated twice. This is the mechanism behind "correctness is the product."

  1. Against forked-Mezo behavior (the primary gate). For a grid of inputs: compute the preview, then actually perform the operation on the fork, then read the contract's getter and assert equality to the wei (or within a documented, justified tolerance for any rounding the contract itself does).

    • previewOpen(x) → openTrove(x) on the fork → getEntireDebtAndColl / getCurrentICR must match previewOpen's entireDebt / icr.
    • getBorrowingPower → open at the boundary → ICR ≥ MCR, and boundary+1 wei breaches (or the open reverts). In Recovery Mode too, against CCR, which is where MK-067 lived: the assertion that stood here was reconstructed from the implementation's own arithmetic and could only ever agree with it (MK-070).
    • preview interest accrual → warp the fork clock → compare to getTroveInterestOwed.
  2. Against the contract's pure helpers (the cheap cross-check). Where the contract exposes the same computation as a pure function, call it and assert the SDK matches:

    • SDK computeICR vs HintHelpers.computeCR(coll, entireDebt, price)
    • SDK NICR vs HintHelpers.computeNominalCR(coll, entireDebt)
    • SDK fee usage vs getBorrowingFee(draw)

If (1) and (2) ever disagree, the contract is right and the SDK is wrong, fix the SDK, add the failing case to the boundary corpus (07-testing).


6. The hint module (hints/), the insertion-hint ritual ​

MUSD is a Liquity-fork CDP: every Trove lives in a list sorted by nominal CR, and opening/adjusting one requires supplying correct insertion hints (upperHint, lowerHint) for gas efficiency. The SDK wraps this once.

6.1 Insertion hints (open / adjust / withdrawColl / refinance) ​

1. Determine entireDebt for the resulting position (draw + fee + 200), see §1.
2. nicr = computeNominalCR(collateral, entireDebt)          // contract pure, or the §1 formula cross-checked
3. (approxHint ) = HintHelpers.getApproxHint(nicr, numTrials, randomSeed)
4. (upperHint, lowerHint) = SortedTroves.findInsertPosition(nicr, approxHint, approxHint)
5. → pass upperHint, lowerHint to the write call
  • Defaults (O2): start numTrials ≈ 15, randomSeed ≈ 42 (Mezo's example values); expose both as overridable options; document the gas/accuracy trade-off. Tune numTrials empirically against the fork (higher = better placement, more gas).
  • Re-run on every position-changing write, adjustTrove, withdrawColl, withdrawMUSD (borrow more), and refinance each change the position's place in the list, so each recomputes hints.
  • Validation: randomized (collateral, draw) pairs must produce a successful openTrove on the fork with the Trove landing in the correct sorted position and gas within tolerance of optimal.

6.2 Redemption hints (redemption/) ​

(firstRedemptionHint, partialRedemptionHintNICR, truncatedAmount)
    = HintHelpers.getRedemptionHints(amount, price, maxIterations)
→ pass these to TroveManager.redeemCollateral(...)
  • truncatedAmount is the amount actually redeemable given the minNetDebt floor on the last touched Trove, surface it to the caller (they may redeem less than requested).
  • Hints go stale if someone redeems before the tx lands → compute immediately before sending; document the stale-hint revert path and a retry.
  • Fee: the current redemptionRate() (read on-chain, C2), applied to ALL redeemers including loan holders. (The "0% for loan holders" rule was disproven on the fork in Phase 6, see 01-ground-truth.md §8.)

7. Invariants the tests assert (non-exhaustive) ​

  • For any opened position: getCurrentICR(addr, price) == computeCR(coll, getEntireDebtAndColl.debt, price).
  • previewOpen(x).entireDebt == getEntireDebtAndColl(addr).debt after opening x.
  • previewOpen(x).meetsMinimum == false ⟺ openTrove(x) reverts BelowMinimumDebt.
  • previewOpen(x).viable == false ⟹ openTrove(x) reverts, with the reason named in reasons (MK-005).
  • The NICR every write path computes its hint from == getNominalICR(addr) after the write (MK-006).
  • previewRefinance(a).fee == the debt increase refinance() actually causes (MK-003).
  • liquidationPrice such that computeCR(coll, entireDebt, liquidationPrice) == MCR.
  • NICR from the SDK == computeNominalCR(coll, entireDebt) exactly.
  • A position with ICR just below MCR is isLiquidatable; just above is not, and a liquidation attempt reverts.

Community tooling for Mezo testnet and evaluation. Not affiliated with or endorsed by Mezo or Thesis. MIT licensed.