fix(stake,v17): pause junior deposits while an insurance loss is outstanding (closes #149; also closes the junior side of #145) - #150
Conversation
…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>
|
Warning Review limit reached
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
Superseded by #153 (merged) — your fix, rebased onto current main so the |
#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>
…(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>
…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>
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 currentnet_loss = total_flushed − total_returnedwith 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_juniorto reject (StakeError::InsuranceLossOutstanding) whiletotal_flushed > total_returned.senior_balanceand never perturbseffective_junior_balance, so it neither inherits a loss nor needs gating (verified by all reviewers).FlushToInsuranceis mode-0 only, sototal_flushed > total_returnedis never true in a trading pool — the gate is inert there.total_flushed/total_returnedmove only via admin Flush/Return, so there's no unprivileged way to hold the gate closed.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_balanceloss math, addsStakePoolstate + 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).DepositJunior→InsuranceLossOutstandingwhiletotal_flushed > total_returned.