Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +667 to 672

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Junior tranche valuation is now asymmetric across deposit vs withdraw.

Line 671 prices junior withdrawals with effective_junior_balance(), but junior deposits still mint against raw junior_balance() (Line 1465). After a loss, this creates inconsistent share pricing between entry and exit.

Proposed fix
-    let junior_bal = pool.junior_balance();
+    let junior_bal = pool.effective_junior_balance()
+        .ok_or(StakeError::Overflow)?;

If you keep effective_junior_balance() -> u64, use it directly; if you apply the fail-closed change in src/state.rs, propagate Option here as above.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/processor.rs` around lines 667 - 672, The withdraw pricing uses
effective_junior_balance() while deposits use junior_balance(), causing
asymmetric share pricing; update the deposit path to use
effective_junior_balance() (or propagate Option if effective_junior_balance()
becomes Option in src/state.rs) so both minting and withdrawal use the same
valuation; specifically replace usages of junior_balance() in deposit/mint logic
with effective_junior_balance() (or handle the Option and fail-closed per the
state change) and ensure calc_junior_collateral_for_withdraw continues to
receive the same junior balance value type.

.ok_or(StakeError::Overflow)?
} else {
Expand Down
25 changes: 23 additions & 2 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +266 to +277

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail closed when total_pool_value() is invalid.

Line 274 falls back with unwrap_or(jb). If pool accounting is already invalid (None), this silently prices junior state with a synthetic value instead of rejecting the read path.

Proposed fix
-    pub fn effective_junior_balance(&self) -> u64 {
+    pub fn effective_junior_balance(&self) -> Option<u64> {
         let jb = self.junior_balance();
         let loss = self.total_flushed.saturating_sub(self.total_returned);
         if loss == 0 {
-            return jb;
+            return Some(jb);
         }
@@
-        let pv = self.total_pool_value().unwrap_or(jb);
+        let pv = self.total_pool_value()?;
         let sb = pv.saturating_sub(jb);
         let (junior_loss, _) = crate::math::distribute_loss(jb, sb, loss);
-        jb.saturating_sub(junior_loss)
+        Some(jb.saturating_sub(junior_loss))
     }
-    pub fn senior_balance(&self) -> Option<u64> {
-        let pv = self.total_pool_value()?;
-        Some(pv.saturating_sub(self.effective_junior_balance()))
-    }
+    pub fn senior_balance(&self) -> Option<u64> {
+        let pv = self.total_pool_value()?;
+        let ejb = self.effective_junior_balance()?;
+        Some(pv.saturating_sub(ejb))
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
pub fn effective_junior_balance(&self) -> Option<u64> {
let jb = self.junior_balance();
let loss = self.total_flushed.saturating_sub(self.total_returned);
if loss == 0 {
return Some(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()?;
let sb = pv.saturating_sub(jb);
let (junior_loss, _) = crate::math::distribute_loss(jb, sb, loss);
Some(jb.saturating_sub(junior_loss))
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/state.rs` around lines 266 - 277, The method effective_junior_balance
currently masks an invalid pool accounting by using unwrap_or(jb) on
total_pool_value(), which silently computes a synthetic pool value; change it to
fail closed by not falling back—if self.total_pool_value() is None, return an
explicit failure (e.g. panic via expect with a clear message or otherwise
propagate an error) so callers immediately see invalid accounting; update the
code in effective_junior_balance to call self.total_pool_value().expect("invalid
pool accounting: total_pool_value missing") (or propagate a Result) before
computing sb and calling crate::math::distribute_loss.

}

/// Derived: senior balance = total_pool_value - effective_junior_balance.
pub fn senior_balance(&self) -> Option<u64> {
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.
Expand Down