Skip to content

fix(#159): gate senior deposits while an insurance loss has spilled past junior (complete #150 senior side) - #160

Closed
0x-SquidSol wants to merge 2 commits into
dcccrypto:mainfrom
0x-SquidSol:fix/v17-senior-deposit-loss-gate
Closed

fix(#159): gate senior deposits while an insurance loss has spilled past junior (complete #150 senior side)#160
0x-SquidSol wants to merge 2 commits into
dcccrypto:mainfrom
0x-SquidSol:fix/v17-senior-deposit-loss-gate

Conversation

@0x-SquidSol

@0x-SquidSol 0x-SquidSol commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #159. Completes the senior side of the insurance recovery-snipe (#145). PR #150 paused junior deposits while a loss is outstanding, but #145 noted "senior is the same once a loss spills past junior" — and the senior deposit path was never gated.

When a flushed loss Φ exceeds the junior balance J, junior is wiped and the senior sub-pool is marked down by Φ − J (distribute_loss: senior_loss > 0 iff net_loss > junior_balance). senior_balance() is depressed for the open-loss window, so a late senior depositor mints cheap LP and redeems at the restored price after ReturnInsurance, capturing a pro-rata slice of the recovery from the incumbent seniors who actually bore the loss — unprivileged, conservation-exact.

Reproduced (incumbent senior 900k + junior 100k, flush 600k): attacker deposits 400k senior → mints 900k LP; after a 600k return, withdraws 650k on a 400k deposit (+250k) — exactly the incumbent senior's −250k.

Fix

In process_deposit, before pricing, pause the senior (tranche) path while the loss has spilled past junior:

if pool.tranche_enabled() {
    let net_loss = pool.total_flushed.saturating_sub(pool.total_returned);
    if net_loss > pool.junior_balance() {
        return Err(StakeError::InsuranceLossOutstanding.into());
    }
}

Design choices

  • Precise (net_loss > junior_balance), not the symmetric flushed > returned. senior_balance() is a pure function of current state, so senior is depressed iff net_loss > junior_balance(now). A junior-absorbed loss (net_loss ≤ junior_balance) leaves senior_balance() unchanged with nothing to snipe — so senior deposits stay open. The symmetric gate would DoS senior deposits for the entire (possibly never-returned) life of a loss junior fully covers, even though senior faces no snipe there.
  • Dynamic-junior safe. Because the junior-withdraw path decrements raw junior_balance by the loss-adjusted withdrawal amount (not a proportional raw share), a junior partial exit during a junior-absorbed loss keeps junior_balance ≥ net_loss, so the precise gate does not false-trigger. (Verified — no persisted accumulator needed.)
  • Tranche-only. Scoped to tranche_enabled(); the non-tranche/global path (Returned insurance can be sniped when the market is resolved permissionlessly (stake's local market_resolved flag goes stale) #139) is untouched.
  • Withdraw path unchanged. The senior/junior withdraw side (a junior fully exiting during a loss stranding recovery to senior) is a separate finding, not addressed here.
  • Self-lifting. Reuses StakeError::InsuranceLossOutstanding; the gate clears automatically as ReturnInsurance raises total_returned.

Tests

tests/poc_senior_deposit_loss_snipe.rs:

  • senior_deposit_during_loss_snipes_recovery — reproduces the +250k / −250k transfer.
  • gate_blocks_snipe_only_while_senior_marked_down — gate fires when net_loss > junior, stays open for a junior-absorbed loss, lifts after return.
  • precise_gate_no_false_fire_on_junior_partial_exit — the dynamic-junior crux: a junior partial exit during a junior-absorbed loss does not false-trigger the gate; senior stays whole.

All verified against the real calc_senior_lp_for_deposit / calc_senior_collateral_for_withdraw + StakePool::senior_balance / effective_junior_balance / total_pool_value.

Verification

  • cargo build --lib
  • cargo build-sbf ✔ (on-chain target)
  • PoC scenarios pass against the production v17 functions.

Note

Builds on #150 — it reuses the InsuranceLossOutstanding gate that PR introduced, so this branch includes #150's commit. Merge after (or together with) #150.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Deposits are now paused when an insurance loss claim is outstanding, preventing unauthorized withdrawals during loss recovery.
    • Both senior and junior deposit flows now enforce insurance loss checks to protect against edge cases.
  • Tests

    • Added regression tests for insurance loss scenarios and deposit gating behavior during claim processing.

0x-SquidSol and others added 2 commits June 18, 2026 13:58
…tanding (dcccrypto#149)

effective_junior_balance() applies the pool's CURRENT net_loss to the junior tranche
with no baseline for when the cohort began. So a junior depositing into a pool that
already has an outstanding loss (total_flushed > total_returned) — borne by the
global/senior cohort — instantly inherits that pre-existing loss, making the incumbents
whole at the new junior's expense (PoC: Greg deposits 3M, admin flushes 600k; Jane
deposits 1M as first junior and is immediately worth 400k; Greg recovers his full 3M).

Gate process_deposit_junior to reject (StakeError::InsuranceLossOutstanding) while
total_flushed > total_returned. Both this loss-inheritance AND the mirror recovery-snipe
(dcccrypto#145) require a junior deposit during an outstanding loss, so this one gate closes both.
Junior-only: a senior deposit prices against the current marked-down senior_balance and
never perturbs effective_junior_balance, so it neither inherits a loss nor needs gating.
Mode-1 is unaffected (flush is mode-0 only, so the condition is never true there).
Deposits resume once insurance is returned (total_flushed/returned move only via admin
Flush/Return, so no unprivileged DoS).

Considered but rejected for THIS fix: a per-cohort loss-baseline snapshot (keeps the
junior tranche open + fairly priced during a loss). More flexible, but it rewrites the
audited effective_junior_balance loss math and adds StakePool state + cohort bookkeeping
— wrong risk profile for a security fix. Recommended as a follow-up feature if continuous
junior subscriptions during a claim are wanted.

Adds tests/poc_junior_preexisting_loss.rs: documents the loss-reassignment math and pins
the gate condition (fires while outstanding, lifts on full return). The handler revert is
covered by the v17 LiteSVM e2e. cargo build --lib + cargo build-sbf clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t junior (complete dcccrypto#150 senior side)

dcccrypto#150 paused JUNIOR deposits while an insurance loss is outstanding, closing the
junior side of the recovery-snipe (dcccrypto#145). But dcccrypto#145 noted "senior is the same once
a loss spills past junior" and the SENIOR deposit path was never gated.

When a flushed loss Φ exceeds the junior balance J, junior is wiped and the senior
sub-pool is marked down by Φ−J (distribute_loss: senior_loss > 0 IFF
net_loss > junior_balance). senior_balance() is depressed for the open-loss window,
so a late senior depositor can mint cheap LP and redeem at the restored price after
ReturnInsurance, capturing a pro-rata slice of the recovery from the incumbent
seniors who bore the loss (conservation-exact, unprivileged). Reproduced: senior
900k + junior 100k, flush 600k → senior_balance 400k; attacker deposits 400k (mints
900k LP); return 600k → senior_balance 1.3M; attacker withdraws 650k (+250k), the
incumbent senior loses exactly 250k.

Fix: in process_deposit, before pricing, pause the senior (tranche) path while
net_loss > junior_balance. The condition is PRECISE rather than the junior gate's
bare flushed > returned: senior_balance() is a pure function of current state, so
senior is depressed IFF net_loss > junior_balance(now). A junior-ABSORBED loss
(net_loss <= junior_balance) leaves senior_balance() unchanged with nothing to
snipe, so senior deposits stay open — important because the symmetric gate would
DoS senior deposits for the entire (possibly never-returned) life of a loss junior
fully covers. Reuses StakeError::InsuranceLossOutstanding; self-lifts as
ReturnInsurance raises total_returned. Scoped to tranche_enabled() so the
non-tranche/global path (dcccrypto#139) is untouched; the senior WITHDRAW path is left
unchanged (separate finding).

Adds tests/poc_senior_deposit_loss_snipe.rs: reproduces the +250k/-250k transfer;
shows the gate fires only when senior is marked down and lifts on return; and locks
in the dynamic-junior case (a junior partial exit during a junior-absorbed loss does
NOT false-trigger the precise gate, because the raw junior_balance decrement is
loss-adjusted, not proportional).

Builds on dcccrypto#150 (uses the InsuranceLossOutstanding gate it introduced).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new StakeError::InsuranceLossOutstanding (code 24) error variant and extends error_hint to handle it. Two deposit guards are added to process_deposit (senior path, gates when net_loss > junior_balance()) and process_deposit_junior (gates when total_flushed > total_returned). Two PoC test files verify the gating and the pre-existing loss assignment scenarios.

Changes

Insurance Loss Deposit Gates

Layer / File(s) Summary
InsuranceLossOutstanding error variant and hint
src/error.rs
Adds StakeError::InsuranceLossOutstanding = 24 with doc comments and registers its user-facing hint string in error_hint for code 24.
Senior and junior deposit guards
src/processor.rs
Adds a tranche-only guard in process_deposit that rejects when total_flushed - total_returned > junior_balance(), and an early guard in process_deposit_junior that rejects when total_flushed > total_returned; both return InsuranceLossOutstanding.
PoC regression tests
tests/poc_junior_preexisting_loss.rs, tests/poc_senior_deposit_loss_snipe.rs
poc_junior_preexisting_loss.rs documents and tests the pre-existing loss reassignment to the first junior depositor and gate lift/fire behavior. poc_senior_deposit_loss_snipe.rs simulates a late senior deposit during a depressed senior balance, validates the value-transfer math, and tests gate phases including a precise non-false-firing case under a junior-absorbed loss.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

Poem

🐇 Hoppity-hop, the gate swings shut,
While insurance losses fill the rut.
No sneaky sniper gets a cheap seat,
When junior's wiped and seniors retreat.
The loss is returned, the gate swings wide —
All safe again on either side! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: gating senior deposits while an insurance loss has spilled past junior, which directly addresses issue #159 and completes the security fix.
Description check ✅ Passed The description comprehensively covers the bug, the fix with design rationale, and test coverage. All required template sections (Summary, How to test, Checklist) are present and well-detailed.
Linked Issues check ✅ Passed The PR fully addresses issue #159 by gating senior deposits with a precise net_loss > junior_balance condition, implements dynamic-junior safety to avoid false triggers, and includes comprehensive PoC tests validating both the vulnerability and the gate behavior.
Out of Scope Changes check ✅ Passed All changes are tightly scoped to addressing the senior deposit snipe vulnerability: new error variant, deposit-path gates, and PoC tests. No unrelated refactoring or modifications outside the stated objectives.
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
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/v17-senior-deposit-loss-gate

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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/error.rs`:
- Around line 58-62: The documentation and hint text for the
StakeError::InsuranceLossOutstanding error variant currently only describes the
junior deposit gating logic (total_flushed > total_returned), but this error is
now used in both the process_deposit method (for senior deposits with net_loss >
junior_balance) and process_deposit_junior method. Update the enum documentation
and hint text for InsuranceLossOutstanding to cover both deposit scenarios,
clearly explaining when senior deposits are rejected due to outstanding
insurance losses (net_loss exceeding junior_balance) as well as when junior
deposits are paused (total_flushed exceeding total_returned).
🪄 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: bb313d99-ddcb-4782-bf76-47de119ef7c5

📥 Commits

Reviewing files that changed from the base of the PR and between 8c4ceec and 7f4350e.

📒 Files selected for processing (4)
  • src/error.rs
  • src/processor.rs
  • tests/poc_junior_preexisting_loss.rs
  • tests/poc_senior_deposit_loss_snipe.rs

Comment thread src/error.rs
Comment on lines +58 to +62
/// Junior tranche deposits are paused while an insurance loss is outstanding
/// (total_flushed > total_returned). A junior depositing during an open claim
/// would inherit a pre-existing loss it was never exposed to (and the mirror
/// case could snipe the recovery). Deposits resume once insurance is returned.
InsuranceLossOutstanding = 24,

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 | 🟡 Minor | ⚡ Quick win

Update error docs/hint to cover both deposit paths.

StakeError::InsuranceLossOutstanding is now used by both process_deposit (senior) and process_deposit_junior, but the enum docs and hint text still describe only junior gating (total_flushed > total_returned). This can mislead operators and client UX when a senior deposit is rejected for net_loss > junior_balance.

🛠️ Proposed wording update
-    /// Junior tranche deposits are paused while an insurance loss is outstanding
-    /// (total_flushed > total_returned). A junior depositing during an open claim
-    /// would inherit a pre-existing loss it was never exposed to (and the mirror
-    /// case could snipe the recovery). Deposits resume once insurance is returned.
+    /// Deposit is paused while an insurance-loss condition is outstanding for the
+    /// active tranche path (junior: open claim; senior: loss spilled past junior).
+    /// Deposits resume once the relevant loss condition clears.
     InsuranceLossOutstanding = 24,
@@
-        24 => "Insurance loss outstanding — junior tranche deposits are paused until the flushed insurance is returned (total_flushed > total_returned)",
+        24 => "Insurance loss outstanding — deposit is temporarily paused for this tranche until the outstanding loss condition clears",

Also applies to: 99-99

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/error.rs` around lines 58 - 62, The documentation and hint text for the
StakeError::InsuranceLossOutstanding error variant currently only describes the
junior deposit gating logic (total_flushed > total_returned), but this error is
now used in both the process_deposit method (for senior deposits with net_loss >
junior_balance) and process_deposit_junior method. Update the enum documentation
and hint text for InsuranceLossOutstanding to cover both deposit scenarios,
clearly explaining when senior deposits are rejected due to outstanding
insurance losses (net_loss exceeding junior_balance) as well as when junior
deposits are paused (total_flushed exceeding total_returned).

@dcccrypto

Copy link
Copy Markdown
Owner

Superseded by #164 (merged) — kept only the NEW senior-deposit gate + its PoC (the junior gate/error/PoC #160 also carried are already on main via #153). Senior gate placed after #148's pre_accrue_mode1; the old inline accrue #160's branch carried was dropped. Authorship preserved, finding credit for #159 stands. Thanks @0x-SquidSol!

@dcccrypto dcccrypto closed this Jun 18, 2026
dcccrypto added a commit that referenced this pull request Jun 19, 2026
…ept & document for v17) (#174)

The recovery-snipe deposit gates (#150/#160) keep a tranche pool frozen to NEW
deposits while net_loss stays positive. For a PERMANENT (never-returned) loss
this freeze is indefinite (#162). Document at both gates that this is BY DESIGN,
not an oversight:

- The pool stays fully backed; existing LPs withdraw at the marked-down price.
  Only new deposits are blocked — a liveness gap, not a solvency/theft bug.
- #161 already provides an admin-free, permissionless ESCAPE for the common case:
  when the last junior exits a junior-absorbed loss, the forfeited loss is
  realized (total_returned += L) → net_loss → 0 → the junior gate lifts.
- No auto-write-off is added: admin keys are burned (admin write-off uncallable);
  resolved markets already block all deposits (resolution-keyed lift is moot); a
  bare time-box would unfairly realize a still-recoverable loss and kill
  incumbents' legitimate recovery.
- The perfectly-fair admin-free fix is permissionless reconciliation against the
  bound market's real insurance balance — deferred (tight coupling; needs LiteSVM
  verification vs a live market). v17 ships Accept-&-document.

Comment-only. No behavior change.

Co-authored-by: dcccrypto <dcccrypto@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

Senior-deposit recovery-snipe: #145 only half-fixed — senior path never gated (loss spills past junior)

2 participants