Skip to content

fix: lock junior_fee_mult_bps once juniors are deposited - #127

Merged
dcccrypto merged 1 commit into
dcccrypto:mainfrom
0x-SquidSol:fix/tranche-config-lock-multiplier
Jun 13, 2026
Merged

fix: lock junior_fee_mult_bps once juniors are deposited#127
dcccrypto merged 1 commit into
dcccrypto:mainfrom
0x-SquidSol:fix/tranche-config-lock-multiplier

Conversation

@0x-SquidSol

Copy link
Copy Markdown
Contributor

Summary

process_admin_set_tranche_config allowed the admin to change junior_fee_mult_bps at any time, even while junior LPs were already deposited. Because process_accrue_fees reads the multiplier live (no per-epoch snapshot, see distribute_fees call at L1696), a mid-life change immediately rewrites the fee split for every junior LP.

Attack

  1. Juniors deposit at multiplier M_1 (economic term they accepted)
  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 the inflated rate
  6. Admin resets multiplier to M_1 to cover tracks

Inverse attack: Admin depresses multiplier to silently reduce junior yield below what was promised at deposit time. Depositors have no recourse — they can't back out without losing cooldown/HWM protections.

Severity

MEDIUM — governance/economic risk. Not immediate theft but a clear avenue for admin value extraction or unilateral repricing of outstanding obligations.

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.

if pool.junior_total_lp() > 0
    && pool.junior_fee_mult_bps() != junior_fee_mult_bps
{
    return Err(StakeError::Unauthorized.into());
}

Design rationale

  • Locking post-deposit enforces the economic contract. Junior LPs take first-loss exposure specifically because of the advertised multiplier. Mutating it is unilateral repricing.
  • Epoch lifecycle works naturally: deposit window → fees accrue → market resolves → juniors exit → reconfigure → next cohort.
  • Idempotent no-op allowed so admin tooling re-applying config doesn't trigger false positives.
  • The "no disable" design limitation (set_tranche_enabled(true) hardcoded) is left as-is. Agents A/C raised a "disable mid-flight" attack vector, but because the current code literally cannot disable tranches, that attack surface doesn't exist in practice. A separate follow-up PR can add a proper enabled: bool parameter (wire-format change) if the product requires it.

Verification

🤖 Generated with Claude Code

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

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@0x-SquidSol has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 46 minutes and 20 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 46 minutes and 20 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6a82bec0-a3f3-4730-bed1-d4fb3af9452d

📥 Commits

Reviewing files that changed from the base of the PR and between c3a617a and 890f5cd.

📒 Files selected for processing (1)
  • src/processor.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
dcccrypto merged commit bc5eec4 into dcccrypto:main Jun 13, 2026
1 check passed
dcccrypto added a commit that referenced this pull request Jun 18, 2026
…Kani (#143) (#148)

* fix(stake,v17): pre-accrue mode-1 fees on the junior deposit path (#146)

process_deposit_junior was the only pricing path that did not crystallize
pending mode-1 trading-fee surplus before pricing — process_deposit (senior/
global) and process_withdraw already do (the #136 fix). A junior depositor
could mint LP at the stale pre-fee price and, after a permissionless AccrueFees,
capture a multiplier-weighted (up to 5x) share of fees earned before they joined
(PoC: deposit 1,000,000 -> withdraw 1,400,000, +400,000). This is a
v17-convergence drop: the original #136 fix covered all three paths.

The pre-accrue block was inline-duplicated across the paths, which is how the
junior copy went missing. Extract it into one shared helper pre_accrue_mode1(
pool, vault) and route all three sites through it so they cannot drift again.
The deposit/withdraw change is a behavior-preserving extraction (verbatim body);
the junior path gets the call before pricing (after the cap/token-program/ATA
checks, matching process_deposit) so it folds only the fee surplus (vault read
pre-transfer) and prices against the post-accrual junior balance.

Adds tests/poc_junior_jit_fee_snipe.rs (mirrors poc_jit_fee_snipe.rs):
documents the +400,000 snipe under current pricing and asserts the pre-accrue
neutralizes it. Verified against the v17 production functions (current 1,400,000;
fixed 999,999). cargo build --lib + cargo build-sbf clean.

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

* fix(stake,v17): restore protections dropped in the v17 convergence (#143)

The v17-convergence line forked before two main PRs landed and didn't
re-incorporate them. Both are absent from v17; restored here.

1. junior_fee_mult_bps governance lock (originally #127):
   process_admin_set_tranche_config validated the multiplier range but no
   longer blocked changing it once junior LPs exist. process_accrue_fees reads
   the multiplier live (no per-epoch snapshot), so a mid-life change re-prices
   the junior/senior fee split for already-committed junior LPs (admin can pump
   before AccrueFees to extract an outsized share, or depress to cut promised
   junior yield). Restore: reject any change when junior_total_lp() > 0;
   idempotent same-value rewrites still allowed; freely configurable once all
   juniors exit.

2. Tranche-math Kani proofs (§15, originally part of the #140/#141 coverage):
   the Kani suite covered only the global path. Restore the 10 tranche harnesses
   + their u32/u64 mirrors: distribute_loss conservation + junior-first;
   distribute_fees conservation + no-senior-strands-to-junior; sub-pool C9 guard
   + first-depositor 1:1; sub-pool round-trip no-profit; senior_balance
   non-underflow; tranche decomposition. (Tranche proptests already survived.)

Verification: cargo build --lib + cargo build-sbf clean; kani crate compiles;
the §15 invariants verified exhaustively (405,121 cases) against the v17
production functions out-of-band; cargo kani + cargo test run them in CI.

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>
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.

2 participants