fix(#169): compute total_pool_value() in i128 — stop mode-1 fee-withdraw underflow that permanently bricks trading pools - #170
Conversation
… fee-withdraw underflow brick total_pool_value() computed `total_deposited.checked_sub(total_withdrawn)` FIRST and added total_fees_earned LAST (mode 1). In a trading-LP pool, withdrawal payouts are fee-inclusive (priced against the fee-inclusive TPV), so total_withdrawn accumulates principal + fees while total_deposited tracks principal only. Once cumulative payouts exceed cumulative principal, that intermediate checked_sub underflows to None, and since every caller does `.ok_or(StakeError::Overflow)?`, the pool permanently bricks — deposits, withdrawals, and AccrueFees all revert, trapping remaining LPs' funds. Reachable in normal operation of any profitable mode-1 pool; no attacker. (mode-0 / insurance pools are immune: no fees, so total_withdrawn never exceeds deposited - flushed + returned.) Fix: accumulate credits − debits in a wide signed intermediate (i128), then range-check the final net to u64. i128 holds the sum/difference of five u64 counters with ~60 bits to spare (|value| < 5*2^64 < 2^67 ≪ 2^127), so no intermediate can spuriously under- or over-flow; the only way to None is now the explicit final check: net < 0 (genuine insolvency / over-flush — the over-withdraw guard callers rely on, e.g. the permanent-loss case) or net > u64::MAX. Chosen i128 over a u64 "credits-first" reorder because the latter has a residual spurious-None: on a long-lived pool the credit sum (deposited + returned + fees, all lifetime-cumulative) can overflow u64 even when the net value is small — relocating the brick rather than eliminating it. All three solution agents independently reached the same conclusion. mode-0 stays byte-identical to `deposited - withdrawn - flushed + returned`; Some(0) at exact zero; the u64::MAX boundary is inclusive; genuine insolvency still surfaces as None. Adds tests/poc_mode1_fee_withdraw_underflow.rs: the fee-inclusive withdraw no longer bricks (Some(250) not None), an emptied-via-fees pool reads Some(0), and mode-0 (incl. genuine insolvency → None) is unchanged. Verified: cargo build --lib + cargo build-sbf pass, and the scratch runner confirms Some(250) (and every other tranche/HWM scenario, which all route through total_pool_value, still passes). 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 (2)
📝 WalkthroughWalkthrough
ChangesMode-1 total_pool_value() underflow fix and regression tests
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)
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 |
|
Thanks @0x-SquidSol — the underlying bug is real (mode-1
Rebase + keep both the RL term and the i128 widening and this is good — happy to re-review. |
…draw brick (#187) In a mode-1 (trading) pool a withdrawer's payout includes their share of accrued fees, so total_withdrawn legitimately exceeds total_deposited once fees have been paid out. total_pool_value()'s left-to-right `total_deposited.checked_sub( total_withdrawn)` then underflowed to None and PERMANENTLY bricked the pool — every op (LP pricing, HWM, AccrueFees, vault check) fails closed, trapping the LP funds still in the pool. principal_tvl() had the same shape and bricked DEPOSITS. Fix: sum both in a wide SIGNED i128 intermediate so evaluation order is irrelevant. - total_pool_value(): fail closed (None) only when the FINAL value is out of range — negative (genuine insolvency: claims > assets) or > u64::MAX. The false-underflow brick is gone; the insolvency guard is preserved. #161 realized_junior_loss term and PERC-272 mode-1 fee inclusion are kept. - principal_tvl(): clamp a net-negative basis to 0 (no live principal → the deposit cap admits new principal) instead of bricking deposits. #161 RL still excluded. This is the idea from #170 (0x-SquidSol), reimplemented on current main so it KEEPS the post-#161 `- realized_junior_loss()` term (the PR's stale base dropped it, which would have reverted the #161 fair-recovery fix) and also fixes the twin principal_tvl(). Verified: - Kani: proof_169_mode1_no_false_underflow_brick — for any ledger the i128 value equals the true signed sum when in range (incl. withdrawn>deposited, the case that bricked) and is None iff truly insolvent. 0/104 checks failed; cover (withdrawn>deposited) satisfied (non-vacuous). - Unit: 5 #169 regression tests (fee-withdrawal not bricked, principal_tvl clamps, insolvency still None, mode-0 unaffected + RL preserved, fees in value not principal). Full suite green (46 integration + 67 unit). build-sbf clean. Closes #169. Co-authored-by: dcccrypto <dcccrypto@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Superseded by the merged fix in #187. Your i128 insight was the right call, @0x-SquidSol — I reimplemented it on current |
Summary
Fixes #169. In a mode-1 (trading-LP) pool,
total_pool_value()underflowed toNoneonce cumulative withdrawals exceeded cumulative deposits, permanently bricking the pool — every caller does.ok_or(StakeError::Overflow)?, so deposits, withdrawals, andAccrueFeesall revert and remaining LPs' funds are trapped. This is reachable in normal operation of any profitable trading pool (no attacker), because withdrawal payouts are fee-inclusive whiletotal_depositedtracks principal only.Root cause
total_pool_value()subtractedtotal_withdrawnfromtotal_depositedfirst and addedtotal_fees_earnedlast:A withdrawal is priced against the fee-inclusive TPV, so
total_withdrawn += withdrawal_amountaccumulates principal plus fees, whiletotal_depositedis principal only. Once payouts exceed principal, the intermediatechecked_subunderflows →None, even though the final value (after adding fees) is non-negative and correct.Fix
Accumulate credits − debits in a wide signed intermediate (
i128), then range-check the final net tou64:i128holds the sum/difference of fiveu64counters with ~60 bits to spare (|value| < 5·2^64 < 2^67 ≪ 2^127), so no intermediate can spuriously under- or over-flow. The only path toNoneis now the explicit final check:net < 0(genuine insolvency / over-flush — the over-withdraw guard callers rely on) ornet > u64::MAX.Why i128 over a u64 "credits-first" reorder
All three solution agents independently reached the same conclusion: a
checked_add-credits-then-checked_sub-debits reorder fixes the reported underflow but introduces a residual spurious-None— on a long-lived pool the credit sum (deposited + returned + fees, all lifetime-cumulative) can overflowu64even when the net value is small, relocating the brick rather than eliminating it. The i128 form removes the entire spurious-Noneclass.Behavior preserved
deposited − withdrawn − flushed + returned(fees ignored unlesspool_mode == 1).None(the only behavioral change is converting the spurious mode-1Noneinto the correctSome).Some(0)at exact zero; theu64::MAXboundary is inclusive.Tests
tests/poc_mode1_fee_withdraw_underflow.rs:mode1_fee_inclusive_withdraw_does_not_brick_pool— the fee-inclusive withdraw yieldsSome(250), not the pre-fixNone.mode1_tpv_reads_zero_not_none_when_emptied_via_fees— an emptied-via-fees pool readsSome(0).mode0_total_pool_value_unchanged_and_insolvency_still_none— mode-0 unchanged, and a genuine over-flush still returnsNone.Verification
cargo build --lib✔cargo build-sbf✔ (on-chain target)total_pool_value()returnsSome(250)(wasNone) after the fee-inclusive withdrawal, and every other tranche/HWM/pricing scenario — all of which route throughtotal_pool_value()— still passes, confirming no regression.Note on scope
This is the mode-1 (trading-LP) path. The fix is correct and harmless regardless, but the real-world severity is CRITICAL if trading pools are deployed and merely latent if only mode-0 (insurance) pools are live — worth confirming on your side which modes are in production.
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests