fix(lending_pool): add slippage bounds and virtual-share offset to deposit/redeem - #1573
Merged
ogazboiz merged 2 commits intoJul 30, 2026
Merged
Conversation
…posit/redeem lending_pool priced shares from the pool's live token balance (total_assets = token::Client::balance(pool) + outstanding), with no client-supplied bound on deposit/withdraw. Both were exploitable within a single ledger: an attacker could mint an initial share, transfer tokens directly to the pool's address to inflate the live balance without minting shares, round a victim's next deposit down to a degenerate share count, then redeem to extract the victim's principal plus the donation. Fix: - Replace balance-derived pricing with an internally tracked TotalManagedAssets accounting value, mutated only by deposit, redeem, and the new distribute_yield accrual path -- never derived from token::Client::balance, so an unsolicited transfer to the pool's address cannot move the share price. - Apply a virtual shares/assets offset (ERC4626-style decimals offset, 10^3) in calc_shares_to_mint/calc_assets_to_redeem, which also unifies the previously special-cased first-depositor branch. - Add min_shares_out to deposit and min_assets_out to withdraw/emergency_withdraw; settlement reverts with PoolError::MinSharesNotMet/MinAssetsNotMet rather than executing at a worse price than the caller expected. Add PoolError::ZeroShares for rounding-to-zero settlements. - Add read-only preview_deposit/preview_redeem for off-chain quoting, and a PriceUpdated event on every pricing mutation. - Add distribute_yield(from, token, amount): the sole legitimate path for realized yield to raise the share price, requiring the caller's authorization and performing the real transfer itself. Also removes dead code appended to lib.rs/test.rs by a previous commit (duplicate top-level `deposit` colliding with the events module import, and a test referencing undefined helpers) that left the crate not compiling. All 66 lending_pool tests pass, including new coverage: donation independence (replaying the exact extraction walkthrough from the report), the virtual-offset math in isolation, min_shares_out/ min_assets_out rejection and success paths, distribute_yield authorization, preview_* parity with actual settlement, and a randomized-sequence round-trip non-profitability check. cargo clippy -D warnings and cargo fmt --check are clean. loan_manager only calls lending_pool's is_paused/pool_balance, so it is unaffected by the signature changes. The workspace-wide `cargo build --manifest-path contracts/Cargo.toml` was already broken on main before this change (loan_manager and multisig_governance have the same kind of pre-existing dead/orphaned code outside their impl blocks); that is unrelated to lending_pool and out of scope here. Backend (poolQuoter/quotes route/eventIndexer), frontend (useRedeemQuote/RedeemForm), migrations, and the cross-layer e2e/CI wiring described in the report are out of scope for this PR, which addresses the contract-layer vulnerability and its acceptance criteria. Closes LabsCrypt#1380
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Vulnerability
lending_poolpriced shares from the pool's live token balance(
total_assets = token::Client::balance(pool) + outstanding), and neitherdepositnorwithdrawaccepted a caller-supplied price bound.Both were exploitable within a single ledger:
deposit(attacker, 1)→ 1 share (first-depositor path).address via the token contract. This is not
deposit, so no sharesare minted, but the live balance the pool reads for pricing jumps.
depositgets rounded down (in the worst case to zeroshares) because the pool's "total assets" figure was inflated by the
donation.
plus the donation.
Fix (contract layer)
DataKey::TotalManagedAssets, mutated only insidedeposit,redeem/withdraw, and the newdistribute_yieldaccrual path — neverderived from
token::Client::balance. An unsolicited transfer to thepool's address is now a complete no-op for pricing.
10^3)in
calc_shares_to_mint/calc_assets_to_redeem, which also unifies whatwas previously a special-cased first-depositor branch.
deposittakesmin_shares_out;withdraw/emergency_withdrawtakemin_assets_out. Settlement reverts withPoolError::MinSharesNotMet/MinAssetsNotMetinstead of executing at aworse price than the caller expected. Added
PoolError::ZeroSharesforsettlements that round to zero.
preview_deposit/preview_redeemread-only views for off-chainquoting, matching settlement math exactly (rounding included).
distribute_yield(from, token, amount)— the sole legitimate path forrealized yield to raise the share price. It performs the real token
transfer itself and requires
from's authorization, so it cannot be usedto move the price at someone else's expense.
PriceUpdatedevent emitted on every pricing mutation(
ledger_seq, total_managed_assets, total_shares).Also removed dead code that had been appended to
lib.rs/test.rsby aprevious commit (a duplicate top-level
depositfunction colliding withthe
eventsmodule'sdeposit, and a test referencing undefined helpers)which left the crate not compiling at all before this change.
Test plan
All in
contracts/lending_pool:cargo build -p lending_pool(and withoverflow-checks=on)cargo test -p lending_pool— 66 passed, 0 failedcargo clippy -p lending_pool --all-targets -- -D warnings— cleancargo fmt -p lending_pool -- --check— cleantest_donation_to_pool_address_does_not_move_share_pricereplays the exact extraction walkthrough from this issue end-to-end
against the fixed contract and asserts: the donation doesn't move
total_managed_assetsorget_share_price, the victim is mintedfair (undiluted) shares, and the attacker cannot redeem more than
their own principal.
test_virtual_offset_prevents_first_depositor_inflation_attackchecksthe offset math in isolation (a victim's shares can no longer round to
zero from a manipulated pricing input).
test_deposit_rejects_when_below_min_shares_out/test_withdraw_rejects_when_below_min_assets_out/test_deposit_succeeds_when_min_shares_out_is_metcover the newbound-rejection and success paths.
test_distribute_yield_requires_source_authorizationandtest_preview_deposit_and_preview_redeem_match_settlement.test_round_trip_deposit_then_redeem_is_never_profitable— afixed-seed pseudo-random sequence of 25 rounds, each preceded by a
donation to the pool's address, asserting
deposit(d)immediatelyfollowed by redeeming all resulting shares never returns more than
d(round-trip non-profitability).distribute_yieldaccrual path in place of a raw donation-stylemint to the pool's address, with expected values recomputed for the
virtual-offset formula.
Out of scope
poolQuoter,quotesroute,eventIndexerprice ingestion,migration) and frontend (
useRedeemQuote,RedeemForm, slippage envvars) layers, and the cross-layer e2e/CI wiring, are not part of this PR.
This PR addresses the contract-layer vulnerability and its acceptance
criteria; the other layers need their own follow-up work against the
deposit/withdraw/preview_*/PriceUpdatedsurface added here.cargo build --manifest-path contracts/Cargo.tomlcurrently fails on
mainforloan_managerandmultisig_governancedue to the same kind of pre-existing dead/orphaned code (unrelated
functions referencing
Selfoutside animplblock) that this PRremoves from
lending_pool. That is unrelated to this issue and out ofscope here;
loan_manageronly callslending_pool'sis_paused/pool_balance, so it is unaffected by the signature changes in this PR.contracts/fuzz(excluded from the workspace) still calls the old3-argument
deposit/withdraw; updating the fuzz harness to the newsignatures is a follow-up.
Closes #1380