Skip to content

fix: price junior deposits against effective_junior_balance after losses - #86

Closed
0x-SquidSol wants to merge 1 commit into
dcccrypto:masterfrom
0x-SquidSol:fix/deposit-junior-stale-balance
Closed

fix: price junior deposits against effective_junior_balance after losses#86
0x-SquidSol wants to merge 1 commit into
dcccrypto:masterfrom
0x-SquidSol:fix/deposit-junior-stale-balance

Conversation

@0x-SquidSol

@0x-SquidSol 0x-SquidSol commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Summary

process_deposit_junior priced new LP tokens using pool.junior_balance(), which is a raw storage field that grows with deposits/withdrawals but is never reduced when insurance losses occur (via FlushToInsurance).

When the junior tranche has absorbed losses:

  • junior_balance() remains at the pre-loss value (inflated)
  • Actual backing collateral is lower than stored
  • New depositors receive fewer LP tokens than their proportional ownership warrants — effectively paying a premium that transfers value to existing junior holders

Fix

  • Add StakePool::effective_junior_balance() in state.rs that applies distribute_loss() to compute the true post-loss backing of the junior sub-pool
  • Update senior_balance() to use effective_junior_balance() for consistency
  • Use effective_junior_balance() in process_deposit_junior for fair LP pricing

Attack Vector

  1. Pool has junior_balance = 1000, junior_total_lp = 1000, loss of 500 is flushed
  2. total_pool_value drops to 500 but junior_balance stays at 1000
  3. New depositor adds 100 tokens: calc_junior_lp(1000, 1000, 100) = 100 LP (as if no loss)
  4. Real effective balance is 500, so fair LP should be: 100 * 1000 / 500 = 200 LP
  5. New depositor received only 100 LP — 50% of fair value — with the shortfall benefiting existing holders

Test plan

  • cargo test passes (57/57)
  • effective_junior_balance() returns junior_balance when no net losses
  • effective_junior_balance() returns reduced value when total_flushed > total_returned

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Corrected junior LP token minting calculations to account for outstanding insurance losses, ensuring LP shares accurately reflect loss-adjusted collateral values.
    • Updated senior tranche balance calculations to reflect current loss-adjusted backing rather than nominal amounts.

process_deposit_junior used pool.junior_balance() (raw, monotonically
increasing storage field) to price LP tokens for new junior depositors.
When insurance losses have been absorbed by the junior tranche, this value
is inflated relative to the actual backing collateral, causing incoming
depositors to receive fewer LP tokens than their proportional share.

Fix:
- Add StakePool::effective_junior_balance() in state.rs which applies
  distribute_loss() to deduct insurance losses from junior_balance,
  returning the true post-loss backing of the junior sub-pool.
- Update senior_balance() to use effective_junior_balance() consistently.
- Use effective_junior_balance() in process_deposit_junior so new
  depositors are priced against the loss-adjusted sub-pool.

Without this fix an attacker controlling a large junior LP position can
trigger a flush (which reduces pool value but not junior_balance), then
deposit fresh capital at inflated LP terms that dilute the loss across
all future depositors rather than the current cohort of junior holders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 1, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Updated deposit and balance calculation logic in the stake pool to account for insurance losses. Added effective_junior_balance() method that computes loss-adjusted junior collateral and modified LP minting and senior balance calculations to use this new balance metric instead of raw stored values.

Changes

Cohort / File(s) Summary
Loss-Adjusted Balance Calculations
src/state.rs
Introduced effective_junior_balance() method that deducts insurance losses from the junior tranche collateral using loss distribution logic. Updated senior_balance() to compute available senior collateral against this loss-adjusted junior balance rather than the raw stored value.
LP Minting Logic
src/processor.rs
Modified process_deposit_junior to calculate junior LP token mints using the new effective_junior_balance() instead of raw junior_balance(), aligning LP pricing with loss-adjusted collateral.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A tranche made wise with loss so clear,
Junior's balance, adjusted near,
LP mints by truth profound,
Where collateral and loss are found!
Balance flows like morning dew, 🌿

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The description covers the problem, fix approach, attack vector example, and test plan, but omits required sections: How to test (specific steps) and verification checklist items. Add a 'How to test' section with specific reproduction/verification steps and complete the required testing checklist (cargo test, clippy, fmt, Kani if applicable).
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: pricing junior deposits against a loss-adjusted balance metric instead of the raw stored balance.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/processor.rs (1)

1462-1473: ⚠️ Potential issue | 🟡 Minor

Confirm: junior deposits use loss-adjusted balance; junior withdrawals use raw balance—verify this asymmetry is intentional.

The fix correctly prices new junior deposits against effective_junior_balance() so incoming depositors aren't overcharged after losses. However, junior withdrawals at line 669 still use the raw pool.junior_balance():

let junior_bal = pool.junior_balance();
crate::math::calc_junior_collateral_for_withdraw(junior_lp, junior_bal, lp_amount)

This asymmetry appears intentional: deposits are priced against effective (loss-adjusted) balance but add the raw amount to stored balance, while withdrawals reduce the stored balance proportionally. This design separates loss tracking (via effective_junior_balance()) from the actual stored balance. Confirm this intent aligns with how losses are realized and whether the timing of loss recognition could create edge cases where withdrawals receive more than available collateral.

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

In `@src/processor.rs` around lines 1462 - 1473, The deposit code uses
effective_junior_balance() when calling calc_junior_lp_for_deposit (with
junior_total_lp()) while withdrawal uses raw pool.junior_balance() with
calc_junior_collateral_for_withdraw; confirm whether this asymmetry is
intentional—if yes, add a clear code comment near the deposit/withdraw logic
documenting that deposits are priced against loss-adjusted
effective_junior_balance() while stored junior_balance() is the raw ledger used
for withdrawals and explain why this prevents overcharging and how/when losses
are realized; if it is NOT intentional, change the withdrawal path to use
effective_junior_balance() (and adjust any bookkeeping that updates stored
balance) so both calc_junior_lp_for_deposit and
calc_junior_collateral_for_withdraw operate on the same loss-adjusted metric.
🧹 Nitpick comments (2)
src/state.rs (2)

273-276: Consider adding an invariant check or logging for total_returned > total_flushed.

The saturating_sub on line 275-276 handles the case where total_returned > total_flushed by returning 0 for net_loss. While this is defensive, this condition should never occur in normal operation—it would indicate that more was returned from insurance than was ever flushed.

Per context snippet 3, process_admin_withdraw_insurance doesn't validate that total_returned stays ≤ total_flushed. If this invariant is violated (due to a bug or compromised admin), pricing would silently use the wrong balance.

Consider adding defensive logging or an assertion in a debug build to catch this anomaly:

🛡️ Optional: Add defensive check for invariant violation
     pub fn effective_junior_balance(&self) -> u64 {
         let jb = self.junior_balance();
         // net_loss = total_flushed - total_returned (tokens sent to insurance but not yet returned)
+        #[cfg(debug_assertions)]
+        if self.total_returned > self.total_flushed {
+            solana_program::msg!(
+                "WARNING: total_returned ({}) > total_flushed ({})",
+                self.total_returned,
+                self.total_flushed
+            );
+        }
         let net_loss = self
             .total_flushed
             .saturating_sub(self.total_returned);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/state.rs` around lines 273 - 276, Add a defensive invariant check around
the net_loss calculation: detect when self.total_returned > self.total_flushed
(which should never happen) and either log an error with both values or assert
in debug builds so the condition is caught early; update the net_loss
computation that uses total_flushed, total_returned (and any callers like
process_admin_withdraw_insurance) to preserve behavior but ensure the check
records the anomalous state (include the numeric values in the log/Assertion
message for debugging).

271-284: Review the edge case when total_pool_value() returns None.

When total_pool_value() returns None (due to arithmetic overflow/underflow), line 281 uses unwrap_or(0), which results in senior_bal = 0 - jb = 0 (saturating). This means the entire loss would be allocated to junior, which may be correct behavior but could mask underlying accounting bugs.

Consider whether returning 0 (full junior absorption) is the safest behavior when pool accounting is in an invalid state, or if the function should propagate the error/return a sentinel value. The current approach is defensive but could hide corruption.

Also, a minor observation: the docstring is excellent and clearly explains the bug being fixed.

🔍 Optional: Consider returning Option to propagate accounting errors

If you want to surface accounting anomalies rather than silently handle them:

-    pub fn effective_junior_balance(&self) -> u64 {
+    pub fn effective_junior_balance(&self) -> Option<u64> {
         let jb = self.junior_balance();
         let net_loss = self
             .total_flushed
             .saturating_sub(self.total_returned);
         if net_loss == 0 {
-            return jb;
+            return Some(jb);
         }
-        let senior_bal = self.total_pool_value().unwrap_or(0).saturating_sub(jb);
+        let senior_bal = self.total_pool_value()?.saturating_sub(jb);
         let (junior_loss, _) = crate::math::distribute_loss(jb, senior_bal, net_loss);
-        jb.saturating_sub(junior_loss)
+        Some(jb.saturating_sub(junior_loss))
     }

This would require updating callers to handle None, but would make pool state corruption visible rather than silently defaulting.

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

In `@src/state.rs` around lines 271 - 284, effective_junior_balance currently
treats total_pool_value().unwrap_or(0) as 0 on overflow/underflow, which
silently forces full junior loss; change effective_junior_balance to propagate
the accounting failure by returning Option<u64> (i.e., return None if
total_pool_value() is None) instead of defaulting to 0 so callers can detect
corruption. Specifically, update the function signature effective_junior_balance
-> Option<u64>, early-return None when self.total_pool_value() is None,
otherwise compute jb via junior_balance(), net_loss from
total_flushed/total_returned, compute senior_bal = total_pool_value().unwrap() -
jb (saturating as before), call crate::math::distribute_loss(jb, senior_bal,
net_loss) and return Some(adjusted_jb); then update all callers to handle the
Option and adjust docs/tests accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/state.rs`:
- Around line 260-284: Add unit tests covering effective_junior_balance()
behavior: verify it returns junior_balance() when net_loss == 0 by setting
total_flushed == total_returned; verify it returns a reduced value when
total_flushed > total_returned by asserting effective_junior_balance() ==
junior_balance() - distributed_loss (use distribute_loss logic expectations);
add an edge test where net_loss exceeds junior_balance to ensure result
saturates at 0; and add a test that after losses senior_balance() (or
total_pool_value()/junior_balance interaction) reflects the distributed loss
correctly (create states with known junior_balance, total_pool_value,
total_flushed/total_returned and assert both effective_junior_balance() and
senior-related values match expected distribute_loss outputs). Ensure tests
construct State with values for junior_balance(), total_flushed, total_returned,
and total_pool_value() and compare against crate::math::distribute_loss results.

---

Outside diff comments:
In `@src/processor.rs`:
- Around line 1462-1473: The deposit code uses effective_junior_balance() when
calling calc_junior_lp_for_deposit (with junior_total_lp()) while withdrawal
uses raw pool.junior_balance() with calc_junior_collateral_for_withdraw; confirm
whether this asymmetry is intentional—if yes, add a clear code comment near the
deposit/withdraw logic documenting that deposits are priced against
loss-adjusted effective_junior_balance() while stored junior_balance() is the
raw ledger used for withdrawals and explain why this prevents overcharging and
how/when losses are realized; if it is NOT intentional, change the withdrawal
path to use effective_junior_balance() (and adjust any bookkeeping that updates
stored balance) so both calc_junior_lp_for_deposit and
calc_junior_collateral_for_withdraw operate on the same loss-adjusted metric.

---

Nitpick comments:
In `@src/state.rs`:
- Around line 273-276: Add a defensive invariant check around the net_loss
calculation: detect when self.total_returned > self.total_flushed (which should
never happen) and either log an error with both values or assert in debug builds
so the condition is caught early; update the net_loss computation that uses
total_flushed, total_returned (and any callers like
process_admin_withdraw_insurance) to preserve behavior but ensure the check
records the anomalous state (include the numeric values in the log/Assertion
message for debugging).
- Around line 271-284: effective_junior_balance currently treats
total_pool_value().unwrap_or(0) as 0 on overflow/underflow, which silently
forces full junior loss; change effective_junior_balance to propagate the
accounting failure by returning Option<u64> (i.e., return None if
total_pool_value() is None) instead of defaulting to 0 so callers can detect
corruption. Specifically, update the function signature effective_junior_balance
-> Option<u64>, early-return None when self.total_pool_value() is None,
otherwise compute jb via junior_balance(), net_loss from
total_flushed/total_returned, compute senior_bal = total_pool_value().unwrap() -
jb (saturating as before), call crate::math::distribute_loss(jb, senior_bal,
net_loss) and return Some(adjusted_jb); then update all callers to handle the
Option and adjust docs/tests accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0fe4262b-7501-4b31-83e5-213e21306511

📥 Commits

Reviewing files that changed from the base of the PR and between 1881738 and 8c292cb.

📒 Files selected for processing (2)
  • src/processor.rs
  • src/state.rs

Comment thread src/state.rs
Comment on lines +260 to +284
/// Loss-adjusted junior tranche balance.
///
/// `junior_balance()` (stored) grows monotonically with deposits and withdrawals
/// but is NOT reduced when `total_flushed` increases (insurance loss). Using the
/// raw value prices new deposits against a stale, inflated sub-pool, causing new
/// junior depositors to receive fewer LP tokens than their proportional share —
/// effectively overcharging them when prior losses have already reduced pool value.
///
/// This method applies `distribute_loss` to compute the amount of outstanding
/// insurance losses that the junior tranche must absorb first, returning the
/// true collateral backing junior LP tokens.
pub fn effective_junior_balance(&self) -> u64 {
let jb = self.junior_balance();
// net_loss = total_flushed - total_returned (tokens sent to insurance but not yet returned)
let net_loss = self
.total_flushed
.saturating_sub(self.total_returned);
if net_loss == 0 {
return jb;
}
// Compute how much of the net loss the junior tranche absorbs.
let senior_bal = self.total_pool_value().unwrap_or(0).saturating_sub(jb);
let (junior_loss, _) = crate::math::distribute_loss(jb, senior_bal, net_loss);
jb.saturating_sub(junior_loss)
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for tests covering effective_junior_balance
rg -n 'effective_junior_balance' --type rust

Repository: dcccrypto/percolator-stake

Length of output: 507


🏁 Script executed:

# First, check the structure of src/state.rs and locate test modules
wc -l src/state.rs

Repository: dcccrypto/percolator-stake

Length of output: 86


🏁 Script executed:

# Search for test files related to state
fd -type f -name "*.rs" | xargs rg -l "effective_junior_balance|mod.*test|#\[cfg\(test\)\]" | head -20

Repository: dcccrypto/percolator-stake

Length of output: 336


🏁 Script executed:

# Look for test modules in state.rs and check lines around 448-733 if they exist
sed -n '440,500p' src/state.rs | head -80

Repository: dcccrypto/percolator-stake

Length of output: 2001


🏁 Script executed:

# Search more broadly for test patterns that might indirectly test effective_junior_balance
rg -n "effective_junior_balance|junior_balance.*test|distribute_loss.*test" --type rust -A 3 -B 3

Repository: dcccrypto/percolator-stake

Length of output: 2067


🏁 Script executed:

# Read the test module section (lines 440-734)
sed -n '440,734p' src/state.rs

Repository: dcccrypto/percolator-stake

Length of output: 9416


Add tests for effective_junior_balance() method.

The test module lacks coverage for this method, which is used in production code (processor.rs:1468) for LP token pricing. Add tests for:

  1. Returns junior_balance() when net_loss == 0
  2. Returns reduced value when total_flushed > total_returned
  3. Edge case: junior tranche fully wiped (loss exceeds junior balance)
  4. Interaction with senior_balance() after losses
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/state.rs` around lines 260 - 284, Add unit tests covering
effective_junior_balance() behavior: verify it returns junior_balance() when
net_loss == 0 by setting total_flushed == total_returned; verify it returns a
reduced value when total_flushed > total_returned by asserting
effective_junior_balance() == junior_balance() - distributed_loss (use
distribute_loss logic expectations); add an edge test where net_loss exceeds
junior_balance to ensure result saturates at 0; and add a test that after losses
senior_balance() (or total_pool_value()/junior_balance interaction) reflects the
distributed loss correctly (create states with known junior_balance,
total_pool_value, total_flushed/total_returned and assert both
effective_junior_balance() and senior-related values match expected
distribute_loss outputs). Ensure tests construct State with values for
junior_balance(), total_flushed, total_returned, and total_pool_value() and
compare against crate::math::distribute_loss results.

@dcccrypto dcccrypto left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Security review — Price junior deposits against effective_junior_balance after losses. Prevents new depositors from getting LP at pre-loss prices (diluting existing holders). Security APPROVED ✅

@dcccrypto dcccrypto left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

🛡️ Security APPROVED. Uses effective_junior_balance() for junior deposit pricing instead of raw junior_balance(). Prevents overcharging new junior depositors when insurance losses (total_flushed - total_returned) have already reduced real pool backing. distribute_loss applied correctly.

@dcccrypto

Copy link
Copy Markdown
Owner

Superseded by PR#95 (merged) and PR#96 (omnibus test coverage). All changes in this PR are already in master. Closing as part of PERC-8433 cleanup.

@dcccrypto

Copy link
Copy Markdown
Owner

Implemented in PR #98 and merged to master. Thank you for identifying this vulnerability — the fix was applied based on your diff after two rounds of security review.

@dcccrypto dcccrypto closed this Apr 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants