From 6fb1529584717d813f3c5df2f79f78938e7c3ee8 Mon Sep 17 00:00:00 2001 From: 0x-SquidSol <0xSquidSol@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:35:17 -0400 Subject: [PATCH] fix: junior tranche absorbs insurance losses first via effective_junior_balance() distribute_loss() in math.rs was defined but never called, meaning the junior-first-loss guarantee of the tranche system was unimplemented. When total_flushed > total_returned (insurance was tapped), junior_balance was not adjusted, so losses fell on the senior tranche instead of junior. Fix: - Add StakePool::effective_junior_balance() which deducts realized insurance losses from junior_balance using distribute_loss(), matching the intended junior-absorbs-first semantics. - Update senior_balance() to use effective_junior_balance() so senior LP holders are protected from losses that junior can absorb. - Use effective_junior_balance() in process_withdraw for junior tranche withdrawals so payouts correctly reflect the loss-adjusted sub-pool. Co-Authored-By: Claude Sonnet 4.6 --- src/processor.rs | 6 ++++-- src/state.rs | 25 +++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/processor.rs b/src/processor.rs index 7f5c03c..ccf9a57 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -664,9 +664,11 @@ fn process_withdraw( // PERC-303: Determine withdrawal amount based on tranche let withdrawal_amount = if pool.tranche_enabled() && is_junior { - // Junior withdrawal: valued against junior sub-pool only + // Junior withdrawal: valued against junior sub-pool after loss absorption. + // effective_junior_balance() deducts insurance losses that junior absorbs first, + // so junior LP holders correctly receive a reduced payout when the pool lost funds. let junior_lp = pool.junior_total_lp(); - let junior_bal = pool.junior_balance(); + let junior_bal = pool.effective_junior_balance(); crate::math::calc_junior_collateral_for_withdraw(junior_lp, junior_bal, lp_amount) .ok_or(StakeError::Overflow)? } else { diff --git a/src/state.rs b/src/state.rs index 165c136..e89ac9d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -257,9 +257,30 @@ impl StakePool { self.total_lp_supply.saturating_sub(self.junior_total_lp()) } - /// Derived: senior balance = total_pool_value - junior_balance. + /// Junior balance after absorbing insurance losses. + /// + /// When `total_flushed > total_returned` there is an outstanding loss. + /// Junior tranche absorbs that loss first (up to its full balance). + /// `distribute_loss` is the canonical implementation: junior absorbs first, + /// senior only loses once junior is wiped out. + pub fn effective_junior_balance(&self) -> u64 { + let jb = self.junior_balance(); + let loss = self.total_flushed.saturating_sub(self.total_returned); + if loss == 0 { + return jb; + } + // senior_balance is the pool value not attributed to junior — used by + // distribute_loss to cap the allocation of loss to the junior tranche. + let pv = self.total_pool_value().unwrap_or(jb); + let sb = pv.saturating_sub(jb); + let (junior_loss, _) = crate::math::distribute_loss(jb, sb, loss); + jb.saturating_sub(junior_loss) + } + + /// Derived: senior balance = total_pool_value - effective_junior_balance. pub fn senior_balance(&self) -> Option { - self.total_pool_value()?.checked_sub(self.junior_balance()) + let pv = self.total_pool_value()?; + Some(pv.saturating_sub(self.effective_junior_balance())) } /// Current struct version. Increment when layout changes.