fix(#159): gate senior deposits while an insurance loss has spilled past junior (complete #150 senior side) - #160
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>
…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>
📝 WalkthroughWalkthroughAdds a new ChangesInsurance Loss Deposit Gates
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts
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 |
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
src/error.rssrc/processor.rstests/poc_junior_preexisting_loss.rstests/poc_senior_deposit_loss_snipe.rs
| /// 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, |
There was a problem hiding this comment.
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).
|
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! |
…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>
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 > 0iffnet_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 afterReturnInsurance, 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:Design choices
net_loss > junior_balance), not the symmetricflushed > returned.senior_balance()is a pure function of current state, so senior is depressed iffnet_loss > junior_balance(now). A junior-absorbed loss (net_loss ≤ junior_balance) leavessenior_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.junior_balanceby the loss-adjusted withdrawal amount (not a proportional raw share), a junior partial exit during a junior-absorbed loss keepsjunior_balance ≥ net_loss, so the precise gate does not false-trigger. (Verified — no persisted accumulator needed.)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.StakeError::InsuranceLossOutstanding; the gate clears automatically asReturnInsuranceraisestotal_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 whennet_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)Note
Builds on #150 — it reuses the
InsuranceLossOutstandinggate 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
Tests