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