fix(tranche): price senior deposits against the senior sub-pool - #135
fix(tranche): price senior deposits against the senior sub-pool#1350x-SquidSol wants to merge 6 commits into
Conversation
dcccrypto#130) effective_junior_balance is used in every junior deposit (process_deposit_junior) for LP pricing and every junior withdrawal (process_withdraw) for collateral valuation. It composes the already-Kani-proven distribute_loss with a derivation of gross_senior from raw accounting fields. The wrapper composition itself — the net_loss == 0 short-circuit, the gross_senior derivation, and the final saturating_sub — had no formal verification, despite the historical BUG-6 fix where losses were previously double-applied on the junior side. Added 4 Kani proofs to tests/kani.rs: 1. proof_effective_junior_balance_no_panic Panic-freedom across arbitrary accounting states, including pathological cases (withdrawn > deposited, flushed > returned). 2. proof_effective_junior_balance_bounded_by_raw effective_junior_balance() <= junior_balance() for all inputs. Loss adjustment can only decrease, never inflate. Includes kani::cover! guard proving the loss-application branch is reachable beyond the trivial net_loss == 0 short-circuit. 3. proof_effective_junior_balance_no_loss_identity When total_flushed == total_returned (no outstanding loss), effective == junior_balance exactly. Pins the short-circuit against regression that would silently apply non-zero loss on lossless pools. 4. proof_effective_junior_tranche_conservation effective_junior + senior_balance == total_pool_value when both are well-defined. Regression guard in case senior_balance()'s definition ever changes away from being the tpv residual. All proofs use the established 1e9 bound for CBMC tractability, matching the style of the surrounding distribute_loss and distribute_fees proofs. All are inside the #[cfg(kani)] module — no effect on normal builds. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…to#129) .cargo/audit.toml ignores RUSTSEC-2022-0093 (ed25519-dalek 1.x) and RUSTSEC-2024-0344 (curve25519-dalek 3.x) with the rationale that these crates are dev-only and never reach the BPF binary. The dependency chain (solana-keypair -> ed25519-dalek-bip32 -> dalek 1.x) is reachable only via solana-sdk which lives under [dev-dependencies]. But no CI check mechanically verifies the "dev-only" claim. If a future dep change pulled either crate into the normal dep tree, the BPF binary would ship with known-vulnerable code and CI would say nothing — the ignore rules would silently become unsafe. Added .github/workflows/audit.yml with a bpf-tree-shake job that: 1. Runs cargo tree -e normal --no-default-features (excludes dev-dependencies and build-dependencies, matching the on-chain build's actual dep surface) 2. Greps the result for ed25519-dalek v1.x and curve25519-dalek v3.x 3. Fails if either appears, with a clear error pointing to the offending audit.toml ignore Verified locally: grep patterns are version-specific — curve25519-dalek v4.1.3 is legitimately present in the normal tree via solana-program, but only v3.x has the ignored CVE, so no false positive. Runs on PR, master push, weekly schedule (catches silent regressions). NOTE: the companion `cargo audit` step (tracked separately) is blocked by upstream cargo-audit 0.21.x lacking CVSS 4.0 parser support. Will be added when a fixed cargo-audit is released to crates.io. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
process_admin_set_tranche_config allowed the admin to change junior_fee_mult_bps at any time, even while junior LPs were deposited. Since process_accrue_fees reads the multiplier live (no per-epoch snapshot), a mid-life change immediately rewrites the fee split for every junior LP. Attack path: 1. Juniors deposit at multiplier M_1 2. Vault accumulates trading fees 3. Admin bumps multiplier to M_2 > M_1 4. Anyone calls AccrueFees — junior sub-pool captures outsized share 5. Admin (holding a junior position) withdraws at inflated value 6. Admin resets multiplier to M_1 to cover tracks The inverse also works: admin depresses the multiplier to silently reduce junior yield below what depositors were promised at deposit time. Fix: block any multiplier change when junior_total_lp() > 0. Idempotent re-writes (same value) still succeed so admin tooling can reapply config. Once all juniors withdraw (junior_total_lp back to 0), the multiplier is freely configurable for the next cohort. The "no disable" issue (set_tranche_enabled always hardcoded true) is left as-is — it's a design limitation, but because the current code cannot disable tranches, the related "disable mid-flight" attack surface that Agents A/C raised does not exist in practice. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…cccrypto#126) process_admin_set_insurance_policy forwarded a caller-supplied authority: Pubkey to wrapper Tag 22 without any validation. A malicious admin could set authority = their_wallet_key, causing the wrapper to record their wallet as the policy authority. They could then bypass the stake program entirely by calling the wrapper's WithdrawInsuranceLimited directly (Tag 23), signing as themselves. Funds extract to the attacker while pool.total_returned is NEVER incremented — process_admin_withdraw_insurance (the only path that updates total_returned) is skipped entirely. This silently desyncs total_pool_value() from the vault's actual balance. LP holders see no on-chain anomaly until they try to redeem against a pool whose accounting claims the funds are still in insurance, but the tokens are in the attacker's wallet. Fix: derive the expected vault_auth PDA and require the caller-supplied authority to equal it. This mirrors the exact check already present in process_admin_withdraw_insurance (lines 1418-1422). Non-breaking ABI change — the parameter is retained, just now validated. The vault_auth PDA is the ONLY signer the stake program can produce for WithdrawInsuranceLimited, so any other authority value breaks the program's own withdrawal path while enabling the bypass. There is no legitimate alternate value. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
With tranches enabled, process_deposit minted senior LP at the GLOBAL pool price while senior withdrawals redeem at the SENIOR sub-pool price. After the junior tranche absorbs an insurance loss (total_flushed > total_returned) the global per-LP price falls below the senior per-LP price, so an unprivileged user could mint senior LP cheap (global) and redeem dear (senior), extracting value from existing senior LP holders and the junior first-loss buffer. Price senior deposits via math::calc_senior_lp_for_deposit(senior_total_lp(), senior_balance(), amount) when tranche_enabled() — the same basis the senior withdraw path uses (calc_senior_collateral_for_withdraw) and symmetric with the junior deposit path. A senior_total_lp() == 0 first-depositor 1:1 bootstrap avoids bricking the senior tranche when junior deposited first and a fee/loss left orphaned senior value with zero senior LP. No state-layout change, no ABI change, no new error variants; senior accounting remains fully derived from the global counters. Adds tests/poc_senior_deposit_mispricing.rs (regression: old global pricing is exploitable; sub-pool pricing is not; bootstrap does not brick) and math unit tests for calc_senior_lp_for_deposit. Refs dcccrypto#134 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughFixes a senior-tranche LP deposit mispricing vulnerability by introducing ChangesSenior LP Deposit Mispricing Fix
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 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)
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 |
…conditional bootstrap The senior deposit path special-cased `senior_total_lp() == 0` into an unconditional 1:1 mint, which bypassed the orphaned-value (C9) guard inside `calc_lp_for_deposit`. In the reachable state where all LP has exited (`total_lp_supply == 0`) but the pool still holds value (e.g. insurance was returned post-resolution), a tranche pool has `senior_total_lp() == 0` with `senior_balance() > 0`. The bootstrap minted 1 senior LP for a 1-token deposit against the whole orphaned balance, which the depositor could then withdraw in full at the senior sub-pool price — draining the orphan. Fix: always price senior deposits via `calc_senior_lp_for_deposit` (which delegates to `calc_lp_for_deposit`), with no `senior_total_lp() == 0` special case. It mints 1:1 only for a true first senior (`senior_balance == 0`) and returns `None` for orphaned senior value (`senior_balance > 0`), rejecting the deposit exactly as the non-tranche path does. A legitimate first senior always has `senior_balance == 0` (empty pool, or a junior-first pool where junior captures 100% of fees and absorbs 100% of loss because `gross_senior == 0`), so this does not brick the first senior deposit. Adds tests/poc_senior_bootstrap_orphan.rs: reproduces the orphan-theft under the old bootstrap, asserts the fix rejects it, and proves the first senior still mints 1:1 across empty / junior-only / junior-only-post-loss-and-recovery states. Updates the existing senior-mispricing regression test to the no-bootstrap helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up commit: close a C9-bypass introduced by the first cut of this fixAdversarial re-review of this branch surfaced a defect in the senior deposit code added here, and The defectThis PR's senior deposit path special-cased let senior_lp = pool.senior_total_lp();
if senior_lp == 0 {
amount // <-- bypasses the orphaned-value (C9) guard
} else {
calc_senior_lp_for_deposit(senior_lp, senior_bal, amount)...
}That branch never consults the orphaned-value (C9) guard that lives inside On the currently-merged code this is additionally gated by the The fix (
|
Reachability update: live-exploitable (not merely latent) for markets with permissionless resolution enabledMy note above called this "latent, gated by the
Net: for a stake-administered market with permissionless resolution enabled, the old bootstrap was a live orphan-theft — gated only by an admin returning insurance to a fully-exited pool (a normal recovery action), with the final steal being unprivileged. Only default-config markets ( |
|
Integrated into |
Summary
Fixes the senior LP deposit/withdraw pricing asymmetry reported in #134.
With tranches enabled,
process_depositminted senior LP at the global pool price, while senior withdrawals redeem at the senior sub-pool price. After the junior tranche absorbs an insurance loss (total_flushed > total_returned), the global per-LP price drops below the senior per-LP price, so an unprivileged user could mint senior LP cheap (global) and redeem dear (senior), extracting value from existing senior LP holders and the junior first-loss buffer. The junior deposit path was already sub-pool-priced; the senior deposit path was the missed twin.Change
math::calc_senior_lp_for_deposit(senior_total_lp, senior_balance, amount)— senior-side mirror ofcalc_junior_lp_for_deposit(delegates tocalc_lp_for_deposit, inheriting round-DOWN/pool-favoring semantics and the orphaned-value guards).process_depositbranches ontranche_enabled(): senior deposits now price against the senior sub-pool (senior_total_lp()/senior_balance()) — the same basiscalc_senior_collateral_for_withdrawredeems against — while non-tranche pools keep global pricing. Asenior_total_lp() == 0first-depositor 1:1 bootstrap prevents bricking the senior tranche when junior deposited first and a fee/loss left orphaned senior value with zero senior LP.All four tranche legs now price against their own sub-pool (or global when tranches are off): senior deposit ↔ senior withdraw and junior deposit ↔ junior withdraw — closing the asymmetry in both the loss direction (theft) and the fee direction (overpay).
Safety / blast radius
senior_total_lp = total_lp_supply − junior_total_lp;senior_balance = total_pool_value − effective_junior_balance); the deposit only changes how many LP are minted and still bumps only the global counters — no senior setters needed.Tests
tests/poc_senior_deposit_mispricing.rs:global_pricing_was_exploitable(documents the original bug, ≈ +25%),senior_subpool_pricing_prevents_extraction(fix → 0 profit, incumbent senior left whole),bootstrap_first_senior_deposit_does_not_brick.src/math.rs: unit tests forcalc_senior_lp_for_deposit(first-deposit 1:1, pro-rata, orphaned-value reject, round-trip-no-profit-after-loss).cargo build-sbf(deployment target) green; fix reproduced and then neutralized against the compiled lib.Refs #134
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
Bug Fixes
Tests