Skip to content

fix(lending_pool): add slippage bounds and virtual-share offset to deposit/redeem - #1573

Merged
ogazboiz merged 2 commits into
LabsCrypt:mainfrom
Akpolo:fix/issue-1380-slippage-virtual-shares
Jul 30, 2026
Merged

fix(lending_pool): add slippage bounds and virtual-share offset to deposit/redeem#1573
ogazboiz merged 2 commits into
LabsCrypt:mainfrom
Akpolo:fix/issue-1380-slippage-virtual-shares

Conversation

@Akpolo

@Akpolo Akpolo commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Vulnerability

lending_pool priced shares from the pool's live token balance
(total_assets = token::Client::balance(pool) + outstanding), and neither
deposit nor withdraw accepted a caller-supplied price bound.

Both were exploitable within a single ledger:

  1. Pool is empty. Attacker calls deposit(attacker, 1) → 1 share (first-depositor path).
  2. Attacker transfers a large amount of the asset directly to the pool's
    address via the token contract. This is not deposit, so no shares
    are minted, but the live balance the pool reads for pricing jumps.
  3. Victim's next deposit gets rounded down (in the worst case to zero
    shares) because the pool's "total assets" figure was inflated by the
    donation.
  4. Attacker redeems their single share and extracts the victim's principal
    plus the donation.

Fix (contract layer)

  • Internal accounting instead of live-balance pricing. Added
    DataKey::TotalManagedAssets, mutated only inside deposit,
    redeem/withdraw, and the new distribute_yield accrual path — never
    derived from token::Client::balance. An unsolicited transfer to the
    pool's address is now a complete no-op for pricing.
  • Virtual shares/assets offset (ERC4626-style decimals offset, 10^3)
    in calc_shares_to_mint/calc_assets_to_redeem, which also unifies what
    was previously a special-cased first-depositor branch.
  • Slippage bounds. deposit takes min_shares_out; withdraw /
    emergency_withdraw take min_assets_out. Settlement reverts with
    PoolError::MinSharesNotMet / MinAssetsNotMet instead of executing at a
    worse price than the caller expected. Added PoolError::ZeroShares for
    settlements that round to zero.
  • preview_deposit / preview_redeem read-only views for off-chain
    quoting, matching settlement math exactly (rounding included).
  • distribute_yield(from, token, amount) — the sole legitimate path for
    realized yield to raise the share price. It performs the real token
    transfer itself and requires from's authorization, so it cannot be used
    to move the price at someone else's expense.
  • PriceUpdated event emitted on every pricing mutation
    (ledger_seq, total_managed_assets, total_shares).

Also removed dead code that had been appended to lib.rs/test.rs by a
previous commit (a duplicate top-level deposit function colliding with
the events module's deposit, 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 with overflow-checks=on)
  • cargo test -p lending_pool — 66 passed, 0 failed
  • cargo clippy -p lending_pool --all-targets -- -D warnings — clean
  • cargo fmt -p lending_pool -- --check — clean
  • New test test_donation_to_pool_address_does_not_move_share_price
    replays the exact extraction walkthrough from this issue end-to-end
    against the fixed contract and asserts: the donation doesn't move
    total_managed_assets or get_share_price, the victim is minted
    fair (undiluted) shares, and the attacker cannot redeem more than
    their own principal.
  • test_virtual_offset_prevents_first_depositor_inflation_attack checks
    the 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_met cover the new
    bound-rejection and success paths.
  • test_distribute_yield_requires_source_authorization and
    test_preview_deposit_and_preview_redeem_match_settlement.
  • test_round_trip_deposit_then_redeem_is_never_profitable — a
    fixed-seed pseudo-random sequence of 25 rounds, each preceded by a
    donation to the pool's address, asserting deposit(d) immediately
    followed by redeeming all resulting shares never returns more than
    d (round-trip non-profitability).
  • All 9 pre-existing yield/loan-cycle tests updated to use the new
    distribute_yield accrual path in place of a raw donation-style
    mint to the pool's address, with expected values recomputed for the
    virtual-offset formula.

Out of scope

  • Backend (poolQuoter, quotes route, eventIndexer price ingestion,
    migration) and frontend (useRedeemQuote, RedeemForm, slippage env
    vars) 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_*/PriceUpdated surface added here.
  • The workspace-wide cargo build --manifest-path contracts/Cargo.toml
    currently fails on main for loan_manager and multisig_governance
    due to the same kind of pre-existing dead/orphaned code (unrelated
    functions referencing Self outside an impl block) that this PR
    removes from lending_pool. That is unrelated to this issue and out of
    scope here; loan_manager only calls lending_pool's is_paused/
    pool_balance, so it is unaffected by the signature changes in this PR.
  • contracts/fuzz (excluded from the workspace) still calls the old
    3-argument deposit/withdraw; updating the fuzz harness to the new
    signatures is a follow-up.

Closes #1380

Akpolo and others added 2 commits July 29, 2026 18:17
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants