Fix: LendingPool MaxPoolSize cap and DepositorCount drifting#38
Conversation
ogazboiz
left a comment
There was a problem hiding this comment.
the core fix is correct, on main redeem_shares subtracted the yield-inflated payout from the principal-only TotalDeposits tracker so the MaxPoolSize cap drifted, and you fix it by removing only the pro-rata principal (checked mul/div), with good tests for the cap math and DepositorCount. but it now conflicts with #36, which just merged.
#36 reworked the same redeem_shares accounting (it switched share value to total_managed_assets and also computes principal_redeemed pro-rata), and it's the more complete fix since it also closes the donation vector and the out-on-loan mispricing that this pr leaves untouched. so:
- please rebase on current main. your TotalDeposits rewrite is now largely subsumed by #36, so keep just the parts #36 doesn't cover, mainly the DepositorCount saturating_sub -> checked_sub underflow hardening and any of its tests not already covered by #36's suite.
- github shows this DIRTY/conflicting now, so the rebase is required to proceed.
once it's rebased down to the DepositorCount hardening on top of #36, i'll merge.
if you want to keep contributing, join us on Telegram: https://t.me/+DOylgFv1jyJlNzM0
dd7fd7d to
0d62000
Compare
|
@ogazboiz I've made the necessary changes, kindly review. |
ogazboiz
left a comment
There was a problem hiding this comment.
this is exactly what i asked for last round, you rebased onto #36 and trimmed it down to just the DepositorCount underflow hardening. the net change now is the utilization_bps doc comment plus lending_pool/src/lib.rs:382 going from saturating_sub(1) to checked_sub(1).expect("depositor_count underflow"). the MaxPoolSize cap is enforced on deposit (PoolSizeExceeded, checked against principal total_deposits with a checked_add overflow guard), and DepositorCount only increments when existing_shares == 0 and only decrements on full redemption, so no drift on repeated deposit/withdraw. good test coverage too (cap-exceeded panic, cap after yield-bearing withdrawal, and the count walk across deposit/partial/full/redeposit). ci is green and it's up to date with main. merging.
if you want to keep contributing, join us on Telegram: https://t.me/+DOylgFv1jyJlNzM0
Closes #9
Fix: TotalDeposits Tracks Net Principal; Harden DepositorCount
Problem
Two accounting bugs in
lending_pool/src/lib.rscausedTotalDepositsandDepositorCountto drift from real state whenever yield had accrued in the pool.Bug 1 —
TotalDepositsSaturates Under Yield (critical)redeem_shares()decrementedTotalDepositsbyassets_to_return:TotalDepositsis supposed to track net deposited principal — the sum of alldeposit()amount arguments minus what has been withdrawn. Butassets_to_returnincludes accrued yield, so each yield-bearing withdrawal over-subtracted, erodingTotalDepositsfaster than actual principal left the pool.Concrete example: Provider A and Provider B each deposit
1 000(TotalDeposits = 2 000).1 000tokens of interest arrive (pool_balance = 3 000,total_shares = 2 000). A redeems all1 000shares and receives1 500assets (principal + yield). The buggy code then sets:But B's original principal is still entirely in the pool —
TotalDepositsshould be1 000. With three or more providers, the compounding error cascades: each successive yield-bearing withdrawal shaves more fromTotalDepositsthan the corresponding principal, eventually saturating it to0viasaturating_subwhile shares and depositors remain.Bug 2 —
DepositorCountDecrement Swallows Invariant Violations SilentlyOn full withdrawal, the count was decremented via
saturating_sub(1):If
countis somehow0when this branch is reached (indicating upstream state corruption),saturating_subwrites0back to storage — committing the corrupted value without any signal. The transaction succeeds, the bad state persists, and every future operation builds on it.Fix
Bug 1 — Subtract Principal Fraction, Not Asset Value
Replace the
assets_to_returnsubtraction with the pro-rata principal fraction:This keeps
TotalDepositsstrictly in the "net principal" space regardless of how much yield has accumulated. When the last depositor fully exits (shares == total_shares), the formula reduces tototal_deposits × total_shares / total_shares = total_deposits, soTotalDepositsreaches exactly0. For partial withdrawals it removes only the proportional principal slice, leaving yield invisible to the cap.cur_total_sharesis already captured earlier in the function (pre-withdrawal) and reused here — no extra reads required.Bug 2 — Fail Loudly on Count Underflow
A
panic!in Soroban aborts and reverts the entire transaction. Ifcountis0when a full-withdrawal branch is reached, the withdrawal fails cleanly rather than persisting a0that future operations silently build on. In normal operation this branch is only reachable when a depositor just burned their last shares, socount ≥ 1is a hard invariant — making violations visible is the right call.Documentation
DataKey::TotalDeposits— expanded comment clarifying it holds net deposited principal only, that yield is excluded (it is implicit in the share price), and distinguishing it frompool_token_balance(the live on-chain balance that includes yield but excludes outstanding loans)PoolStats::utilization_bps— added note that accrued yield inflatespool_token_balance, which partially offsets outstanding loans in the utilisation formula, so utilisation may understate the true loan fraction when significant yield has accumulatedTests Added
Four regression tests in a dedicated Issue #9 section at the bottom of
test.rs. Each test is written to fail on the old code and pass on the fix.test_total_deposits_tracks_principal_not_yield1 000principal,300tokens of yield arrive. Redeeming500shares (half) must reduceTotalDepositsby500(principal half), not by650(the yield-inflated asset value). Minimal reproduction of Bug 1.test_cap_enforced_after_yield_bearing_withdrawal1 000against a cap of2 000.1 000yield arrives. A fully withdraws. AssertsTotalDeposits == 1 000(B's principal, not500); a deposit of1 001is rejected withPoolSizeExceeded; a deposit of exactly1 000succeeds and fills the cap.test_depositor_count_across_deposit_partial_full_withdraw_redepositdepositor_countat every state transition: after both deposit →2; after A partial-withdraws → still2; after A fully withdraws →1; after A re-deposits →2; after B fully withdraws →1; after A fully withdraws →0.test_total_deposits_zero_after_all_shares_redeemed_with_yield1 000tokens of yield arrive. AssertsTotalDeposits == 0,total_shares == 0, anddepositor_count == 0— confirming the pool returns to a clean empty state even when the share price has risen above 1:1.Files Changed
lending_pool/src/lib.rsredeem_shares(): principal-fraction formula,checked_subhardening; doc updates forDataKey::TotalDepositsandPoolStats::utilization_bpslending_pool/src/test.rs