Skip to content

fix(stake,v17): pause junior deposits while an insurance loss is outstanding (closes #149; also closes the junior side of #145) - #150

Closed
0x-SquidSol wants to merge 1 commit into
dcccrypto:mainfrom
0x-SquidSol:fix/v17-junior-preexisting-loss
Closed

fix(stake,v17): pause junior deposits while an insurance loss is outstanding (closes #149; also closes the junior side of #145)#150
0x-SquidSol wants to merge 1 commit into
dcccrypto:mainfrom
0x-SquidSol:fix/v17-junior-preexisting-loss

Conversation

@0x-SquidSol

Copy link
Copy Markdown
Contributor

Closes #149. Also closes the junior recovery-snipe of #145 (same root cause + same gate).

What

effective_junior_balance() charges the junior tranche the pool's current net_loss = total_flushed − total_returned with no baseline for when the junior cohort began. So a junior depositing into a pool that already has an outstanding loss (borne by the global/senior cohort) instantly inherits that pre-existing loss.

PoC (verified against the v17 functions): Greg deposits 3,000,000 (non-tranche) → admin flushes 600,000 (Greg's loss is real) → admin enables tranches → Jane deposits 1,000,000 as the first junior, mints 1:1 → her stake is immediately worth 400,000 (−600,000), and Greg withdraws his full 3,000,000 (his loss evaporated onto Jane). Conservation holds globally, so no runtime check catches it.

Fix

Gate process_deposit_junior to reject (StakeError::InsuranceLossOutstanding) while total_flushed > total_returned.

Why a gate, not a re-pricing

Three independent solution proposals were reviewed. The alternative — a per-cohort loss-baseline snapshot (charge junior only for losses after it joined) — keeps the junior tranche open and fairly priced during a loss, which is more flexible. But it rewrites the audited effective_junior_balance loss math, adds StakePool state + cohort-transition bookkeeping, and carries subtle policy edges (re-flush after partial recovery). That's a feature, not a security hotfix. The gate is minimal, touches none of the loss math, adds no state/layout/migration, and provably closes the vulnerability. Recommended follow-up: adopt the loss-baseline if continuous, fairly-priced junior subscriptions during an open claim are desired.

This supersedes #145's "design-call, deferred" classification: the loss-inheritance manifestation is a deterministic loss to honest depositors, not an optional pricing preference.

Verification

  • cargo build --lib + cargo build-sbf: clean.
  • tests/poc_junior_preexisting_loss.rs: documents the loss-reassignment math (verified against production fns) and pins the gate condition (fires while a loss is outstanding, lifts on full return).
  • The handler-level revert is covered by the v17 LiteSVM e2e suite; recommend a dedicated e2e asserting DepositJuniorInsuranceLossOutstanding while total_flushed > total_returned.

…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>
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@0x-SquidSol, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 30 minutes and 35 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 52888b5c-de73-4256-bef5-a84a70761f78

📥 Commits

Reviewing files that changed from the base of the PR and between 9addf4e and 636cc7c.

📒 Files selected for processing (3)
  • src/error.rs
  • src/processor.rs
  • tests/poc_junior_preexisting_loss.rs
✨ 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.

@dcccrypto

Copy link
Copy Markdown
Owner

Superseded by #153 (merged) — your fix, rebased onto current main so the InsuranceLossOutstanding gate sits AFTER #148's pre_accrue_mode1 (keeping that guard intact), plus the error-codes test backfill. Your commit authorship is preserved in #153 and finding credit for #149 stands. Couldn't force-update your fork branch from here, hence the fresh PR. Thanks @0x-SquidSol!

dcccrypto added a commit that referenced this pull request Jun 18, 2026
#149) — rebased from #150 (#153)

* fix(stake,v17): pause junior deposits while an insurance loss is outstanding (#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
(#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>

* test(error-codes): enumerate InsuranceLossOutstanding (+ backfill 22/23) for #150 rebase

Adds StakeError::InsuranceLossOutstanding (24) to both error_codes test arrays.
Also backfills ZeroSharesMinted (22) + NoPendingAdmin (23), which were already
missing from the enumeration on main, and bumps the sequential check to 0..24 so
test_all_error_codes_unique stays complete.

Rebase-completion for #150 (authored by 0x-SquidSol): the gate was re-applied
AFTER pre_accrue_mode1 (PR #148) so the JIT fee-snipe guard stays intact.

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

---------

Co-authored-by: 0X-SquidSol <david.laszczynski@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: dcccrypto <dcccrypto@users.noreply.github.com>
@dcccrypto dcccrypto closed this Jun 18, 2026
dcccrypto added a commit that referenced this pull request Jun 18, 2026
…(complete #150 senior side) (#164)

junior side of the recovery-snipe (#145). 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 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 (#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 #150 (uses the InsuranceLossOutstanding gate it introduced).

Co-authored-by: 0X-SquidSol <david.laszczynski@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

First junior depositor inherits a pre-existing insurance loss (loss-inheritance side of #145; deterministic loss to honest depositors)

2 participants