← back to lessons

Lessons: Mezo mUSD (Cycle 220) - Liquity-fork-with-interest CDP

Target: Mezo mUSD, Cantina $500K, github.com/mezo-org/musd @ 43ef441 (2026-05-19). Lineage: Threshold USD -> Liquity fork. BTC-collateralized CDP stablecoin. Verdict: WALK CLEAN. 5/5 Foundry invariant tests PASS (defended).

Keeper lesson 1: Dual-interest-accounting reconciliation (PRIMARY, recurring class)

When a Liquity-class CDP ADDS ongoing interest (Mezo, Liquity-V2-ish, any "borrow rate" fork), interest is typically tracked TWO ways that must stay in lockstep: - Aggregate: interestNumerator = sum(principal_i * rate_i), minted to a treasury/PCV on a permissionless updateSystemInterest() poke as numerator * dt / (BPS*YEAR). - Per-position: each trove's interestOwed += principal*rate*dt/(BPS*YEAR).

Invariants to test (extract the PURE interest-math lib into standalone Foundry; replay exact ops): 1. aggregate_minted == sum(per_trove_interestOwed) over arbitrary multi-trove / multi-rate / open-close timelines. 2. Rounding drift SIGN must favor the protocol: aggregate floors ONCE over the summed numerator, per-trove floors N times => aggregate >= sum(per-trove). If the sign ever flips, borrowers collectively owe more than was minted (interest-side insolvency) OR borrowers can extract. 3. total_supply == total_recorded_debt at every step (interest minted to treasury must be matched by an equal activePool.increaseDebt(0, interest)).

THE BUG, IF PRESENT, lives in: any addPrincipal/removePrincipal NOT preceded by an aggregate updateSystemInterest() sync (desyncs lastUpdatedTime from the numerator change), or a liquidation-redistribution path that re-rates inherited debt (addPrincipal(pendingPrincipal, receiverRate)) without re-syncing the aggregate. Mezo gets the ordering right in EVERY entry point (_openTrove, _adjustTrove, _refinance, redeemCollateral, batchLiquidateTroves, _closeTrove all call updateSystemInterest/updateSystemAndTroveInterest first). Check each one.

Keeper lesson 2: A fork that SIMPLIFIES canonical danger zones is harder there - pivot effort

Mezo deleted the two classic Liquity bug-honeypots: - Dynamic decaying redemption base-rate -> replaced with FLAT governable redemptionRate (0.75%). Kills base-rate front-running, first-redeemer advantage, redemption-spike griefing. - Recovery-mode mass liquidation -> liquidation only at ICR<MCR regardless of TCR; recovery mode merely GATES new borrowing (_requireNewTCRisAboveCCR). Kills recovery-mode liquidation edge cases. LESSON: when a fork removes canonical complexity, do NOT keep grinding the removed surface. Redirect to the ADDED complexity (here: interest accounting, refinance, signature ops, PCV).

Keeper lesson 3 (anti-pattern, banked from own modeling error)

When modeling a burn that draws from CIRCULATING supply (e.g. a redeemer burning mUSD that was minted partly to a treasury/PCV), the burner ACQUIRES the treasury's tokens from the market before burning. Do NOT track the treasury's balance as separate-and-surviving while also zeroing total supply - that double-counts and produces a phantom "unbacked tokens" failure. Solvency invariant is supply == debt; the treasury's tokens are part of supply, not additive to it.

Reusable PoC harness pattern

Hardhat-only repo + no node deps installed => DON'T fight the JS toolchain for math-core invariants. Copy the PURE library (InterestRateMath.sol, LiquityMath.sol - both pure, self-contained, zero imports) into a fresh forge init project and replay the exact add/removePrincipal + updateSystemInterest call ordering as a plain Solidity model. Fast, deterministic, real forge test stdout. Files: ~/bounty/cycles/cycle-220-mezo/poc/.

Surface verdicts (for fast resume / cross-target transfer)

Generated 2026-07-02 13:15:05 UTC | auto-sync /15min