fix(lending_pool): base share accounting on tracked managed assets, n…#36
fix(lending_pool): base share accounting on tracked managed assets, n…#36xeladev4 wants to merge 2 commits into
Conversation
d41a17c to
720bef2
Compare
|
This was a really tough one boss |
ogazboiz
left a comment
There was a problem hiding this comment.
this is genuinely strong work and ci is green (fmt, clippy -D warnings, tests and the wasm build all pass). the donation/inflation fix is mathematically sound: share value is derived from a tracked TotalManagedAssets figure rather than the raw token balance, deposits price off managed-assets-before and redeem off managed-assets, and direct token transfers never touch it, so the first-depositor inflation attack is neutralized (test_inflation_attack_via_donation_is_prevented confirms it). it also correctly preserves #31 (the redeem_shares core, emergency_withdraw gate and NotPaused are all intact, not reverted), and tracking TotalDeposits by principal-only on redeem with a liquidity gate is a nice touch. checked math throughout, and record_yield is admin + pause gated.
one blocking issue before this can land:
- automatic yield distribution is silently broken by this change. today repaid loan interest reaches depositors automatically because share value reads the pool balance, which grows when interest is repaid. once share value tracks only TotalManagedAssets, that figure moves only via deposit and the new admin-gated record_yield. but loan_manager repays with a raw token transfer (loan_manager/src/lib.rs:1304) and never calls record_yield, and there are zero non-test callers of record_yield in the repo. so after this merge, repaid interest sits as uncounted surplus and depositors stop earning until an admin manually calls record_yield. the tests pass only because they call it by hand. please wire record_yield into the repayment path (loan_manager should call pool.record_yield(token, interest_portion) after transferring the interest), or add an automated keeper and document it.
two smaller things:
2. calc_shares_to_mint mints 1:1 when total_assets_before == 0 even if cur_total_shares > 0. probably unreachable since redeem decrements both together, but worth a guard or an assert documenting the invariant.
3. redeem_shares transfers before the state writes (inherited from #31, recipient is the provider so no re-entry risk here), not strictly checks-effects-interactions. optional to tighten while you're in there.
heads up on coordination: #33 is also fixing the donation attack on this same file (with a MINIMUM_INITIAL_DEPOSIT guard, bundled with a loan-term accrual change). your managed-assets approach here is the more complete and cleaner fix for the donation vector, so i'd like to canonicalize the share-accounting fix on this pr and have #33 keep just its accrual-by-term work, otherwise the two collide on the share math. once record_yield is wired in, this is the one i want for the donation fix.
if you want to keep contributing, join us on Telegram: https://t.me/+DOylgFv1jyJlNzM0
…ot raw balance Share value for both minting and redemption was derived from the raw TokenClient::balance of the pool. That let the price swing while loan principal was out (a receivable) and let anyone inflate it by transferring tokens directly to the pool address (donation / inflation attack), while redeem_shares subtracted the full yield-inclusive payout from TotalDeposits, causing principal drift. Introduce TotalManagedAssets (principal + realized yield, net of withdrawals) as the single source of truth for the share<->asset exchange rate, moved only by deposit, redeem, and an admin-gated record_yield. The raw balance is now a liquidity gate only (InsufficientLiquidity on under-funded redemptions). redeem_shares reduces TotalDeposits by the principal portion only. Adds record_yield (emitting YieldDistributed), bumps version 3->4, documents the model, and adds tests for outstanding loans, direct transfers, the inflation attack, and principal reconciliation. Closes LabsCrypt#2
…t guard Once share value tracks TotalManagedAssets instead of the raw balance, loan interest transferred into the pool no longer reaches LPs automatically. Wire it up: - lending_pool: add a per-token authorized yield reporter (set_loan_manager/get_loan_manager, admin-gated, emits LoanManagerUpdated) and gate record_yield to that reporter, falling back to admin when unset. - loan_manager: report the interest + late-fee portion of a repayment and the extension fee as yield via record_yield. Best-effort and non-fatal (only when configured as the pool's reporter; pool-side errors are swallowed) so it can never block a repayment. liquidate is intentionally left unwired and documented, since recovered interest needs a paired principal write-off (record_loss) to avoid inflating share value during a loss event. Review nits: - calc_shares_to_mint documents the managed-assets-positive invariant with a debug_assert instead of silently minting 1:1 when total_assets_before == 0. - redeem_shares now commits all accounting before the token transfer (CEI). Adds pool tests for reporter gating and end-to-end loan_manager tests proving repaid interest reaches LP share value and that repayment still succeeds when no reporter is configured.
e6dd8b2 to
f1dabd5
Compare
|
What I changed in response to the review Pool (lending_pool/src/lib.rs): added a per-token authorized yield reporter — set_loan_manager(token, manager) (admin-gated) + get_loan_manager. record_yield is now gated to that reporter, falling back to admin when unset (manual/keeper). Emits LoanManagerUpdated. The two nits: calc_shares_to_mint no longer silently mints 1:1 when total_assets_before == 0 with shares outstanding — documents the invariant via debug_assert! and would surface a violation as a panic. I noted your coordination point with #33 — this PR keeps the share-accounting/donation fix; #33 can drop its share-math and keep accrual-by-term. |
Fix LendingPool share accounting: track managed assets internally instead of raw balance
Closes #2
Problem
lending_poolderivedtotal_assetsfor both share minting (deposit) andredemption (
redeem_shares/get_deposit/get_depositor_yield/get_share_price) fromread_pool_balance— the rawTokenClient::balanceofthe contract. That made the share price:
loan_manager::approve_loantransfersprincipal out of the pool and repayments transfer it back, so a share's value
dropped while a loan was live even though the lent principal is still a pool
asset (a receivable); and
inflates the balance, decoupling shares from the tracked
TotalDepositsandenabling a classic donation / first-depositor inflation attack.
redeem_sharesalso subtracted the full payout (assets_to_return, whichincludes yield) from
TotalDeposits, so the principal counter drifted away fromactual net principal.
Decision (acceptance criterion 1)
Share value tracks an internally-tracked figure, never the raw balance.
A new per-token key
TotalManagedAssetsis the single source of truth for theshare↔asset exchange rate. It equals deposited principal + realized yield, net of
withdrawals, and only ever moves through three deliberate paths:
deposit,redeem_shares, and the admin-gatedrecord_yield. It is not affected byunsolicited transfers or by principal temporarily out on loan.
The raw balance is now used only as a liquidity gate: a redemption that
cannot be serviced from on-hand tokens fails with the (previously unused)
InsufficientLiquidityerror rather than being mis-priced.Changes (
lending_pool/src/lib.rs)DataKey::TotalManagedAssets(Address)plustotal_managed_assets/set_total_managed_assetsaccessors and a publicget_total_managed_assetsgetter.
depositsnapshots and incrementsTotalManagedAssets(instead of reading thebalance) when computing shares to mint.
redeem_shares:TotalManagedAssets;InsufficientLiquiditywhen on-hand balance can't cover it;TotalDepositsby the principal portion only(
shares * total_deposits / total_shares), fixing the drift(acceptance criterion 4);
TotalManagedAssetsby the full payout.get_deposit,get_depositor_yield, andget_share_pricereadTotalManagedAssets.record_yield(token, amount)— the only way realized yield(e.g. loan interest repaid into the pool) raises share value. Because a
contract can't tell a legitimate repayment from a donation by inspecting its
balance, yield is recognised through this explicit call; this is precisely what
stops direct transfers from changing holders' redeemable value
(acceptance criterion 2). Emits the existing
YieldDistributedevent.CURRENT_VERSION3 → 4 and document the accounting model in module/fielddoc comments.
Tests (
lending_pool/src/test.rs) (acceptance criterion 3)Existing yield tests now recognise interest via
record_yield(mirroring arepayment followed by the manager recording yield). New tests:
test_direct_transfer_does_not_change_redeemable_value— a 10k unsolicitedtransfer leaves share price and redeemable value untouched.
test_inflation_attack_via_donation_is_prevented— first-depositor inflationattack no longer rounds a victim's deposit to zero shares.
test_deposit_and_redeem_while_loan_outstanding— deposits/redeems price offthe stable base while a loan is live; over-liquidity redemptions are gated.
test_total_deposits_tracks_principal_after_yield_then_redeem—TotalDepositsdrops by principal only after a yield-bearing redemption.
test_record_yield_requires_admin,test_record_yield_rejected_when_no_shares_outstanding.test_withdrawal_with_utilizationrewritten: share value is no longerdiscounted by utilisation; over-balance redemptions return
InsufficientLiquidity.Automatic yield distribution (review follow-up)
Because share value now tracks
TotalManagedAssetsrather than the raw balance,interest merely transferred into the pool no longer reaches LPs on its own —
it must be reported. This is now wired end-to-end:
set_loan_manager(token, manager)(admin-gated) +get_loan_manager(token).record_yieldis gated tothat reporter, falling back to the admin when none is configured (manual /
keeper use). Emits
LoanManagerUpdated.repay→ reports the interest + late-fee portion of the repayment(principal just returns borrowed principal, so it is excluded).
extend_loan→ reports the extension fee (pure income).when it is the configured reporter, and swallows any pool-side error, so
yield accounting can never block a repayment.
liquidateis intentionally NOT wired (documented in code): a liquidationcan also leave a principal shortfall, and crediting recovered interest as yield
without a paired principal write-off would inflate share value during a loss
event. That needs a
record_losscompanion and is called out as a follow-up;recovered funds remain as conservative, uncounted surplus until then.
Review nits addressed
calc_shares_to_mintno longer silently mints 1:1 whentotal_assets_before == 0while shares exist; it documents the (unreachable) invariant with adebug_assert!and would surface a violation as a panic viachecked_div.redeem_sharesnow follows checks-effects-interactions: all accounting iscommitted before the token transfer out.
Verification
cargo fmt --check— cleancargo clippy --all-targets -- -D warnings— cleancargo test— all workspace tests pass (58 inlending_pool, 90 inloan_manager)cargo build --target wasm32-unknown-unknown— buildsNotes / follow-ups (out of scope here)
record_loss) for default/liquidation principalshortfalls is the natural companion to
record_yieldand is left as afollow-up (see the in-code note in
loan_manager::liquidate).would need a one-time migration seeding
TotalManagedAssetsfrom existingbalance/principal. No migration entry point exists in this contract today, so
none is added here.