From 978ad58d3beea515c091ce7fe4f878a5af29934b Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 23 Sep 2026 01:10:40 +0000 Subject: [PATCH] lending: lock a minimum number of reserve shares Shares were priced against tracked total_liquidity rather than the vault balance, which stops a vault donation. But total_liquidity also counts interest owed on borrows, and a supplier can borrow from their own reserve. A lone supplier holding one share borrows a single base unit; debt rounds up, so a second later it reads as two and the share is worth two units without anything minted. Deposits and redemptions both round in the pool's favor, so depositing the most that still mints one share and redeeming it leaves the remainder with the attacker's own share, and the price climbs about half again per round. inflating_shares_through_own_borrow_does_not_pay runs that on the default rate curve, with no bad debt and the loan repaid. Against the old program (Anchor v2 and v1 alike) 49 rounds make one share worth about 690 USDC, a 1,000 USDC deposit mints one share, and the attacker starts with 4,000,000,000 minor units and ends with 4,155,201,816. The first deposit now mints deposit - MINIMUM_SHARES (1,000) shares, and deposit, redeem, collateral valuation, collateral withdrawal and liquidation all divide by the share supply plus that minimum, as the token swap and perpetual futures do. The attacker's share is then 1 of 1,001: they end 1,065 minor units down and the victim gets back their whole deposit. A reserve with no shares and no liquidity bootstraps; any other deposit is priced against the locked slice. The test harnesses open each reserve with a deposit from the market owner, so existing tests keep their exact numbers; the first-deposit tests use an empty reserve. Applied to all three ports, with READMEs and changelogs. Claude-Session: https://claude.ai/code/session_013vsKb7K4wGqggas2vtABoF --- finance/lending/anchor-v1/CHANGELOG.md | 27 ++++ finance/lending/anchor-v1/README.md | 24 ++- .../programs/lending/src/constants.rs | 16 ++ .../instructions/deposit_reserve_liquidity.rs | 36 ++++- .../src/instructions/liquidate_obligation.rs | 2 +- .../instructions/redeem_reserve_collateral.rs | 11 +- .../src/instructions/refresh_obligation.rs | 2 +- .../withdraw_obligation_collateral.rs | 2 +- .../programs/lending/src/state/reserve.rs | 17 +- .../programs/lending/tests/common/mod.rs | 43 +++-- .../lending/tests/test_deposit_redeem.rs | 151 ++++++++++++++++- .../programs/lending/tests/test_reserve.rs | 2 +- finance/lending/anchor/CHANGELOG.md | 27 ++++ finance/lending/anchor/README.md | 24 ++- .../anchor/programs/lending/src/constants.rs | 16 ++ .../instructions/deposit_reserve_liquidity.rs | 34 +++- .../src/instructions/liquidate_obligation.rs | 2 +- .../instructions/redeem_reserve_collateral.rs | 14 +- .../src/instructions/refresh_obligation.rs | 2 +- .../withdraw_obligation_collateral.rs | 2 +- .../programs/lending/src/state/reserve.rs | 17 +- .../programs/lending/tests/common/mod.rs | 43 +++-- .../lending/tests/test_deposit_redeem.rs | 152 +++++++++++++++++- .../programs/lending/tests/test_reserve.rs | 2 +- finance/lending/quasar/CHANGELOG.md | 20 +++ finance/lending/quasar/README.md | 13 +- finance/lending/quasar/src/constants.rs | 16 ++ .../quasar/src/instructions/position.rs | 10 +- .../lending/quasar/src/instructions/supply.rs | 38 ++++- finance/lending/quasar/src/math.rs | 14 +- finance/lending/quasar/src/tests.rs | 121 +++++++++++++- 31 files changed, 806 insertions(+), 94 deletions(-) diff --git a/finance/lending/anchor-v1/CHANGELOG.md b/finance/lending/anchor-v1/CHANGELOG.md index 481fe877f..c1fc61662 100644 --- a/finance/lending/anchor-v1/CHANGELOG.md +++ b/finance/lending/anchor-v1/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 2026-09-23 + +Lock a minimum number of reserve shares. The first deposit now mints +`deposit - MINIMUM_SHARES` (1,000) shares, and every conversion between shares +and liquidity (deposit, redeem, collateral valuation in `refresh_obligation`, +`withdraw_obligation_collateral` and liquidation) divides by the share supply +plus that minimum, through the new `Reserve::total_shares`. The withheld shares +belong to nobody, so their slice of the pool stays locked. A first deposit of +`MINIMUM_SHARES` or less fails with `DepositTooSmall`, and a reserve whose +suppliers have all left prices the next deposit against the locked slice +rather than bootstrapping it. + +Pricing shares against tracked `total_liquidity` stopped a vault donation, but +`total_liquidity` includes interest owed on borrows. A lone supplier with one +share could borrow a single base unit from their own reserve, let one second of +interest round the debt up to two, and then ratchet the share price up about +half again per round by depositing the most that still minted one share and +redeeming it, leaving the rounding with their own share. On the default rate +curve, with no bad debt and the loan repaid, 49 rounds made one share worth +about 690 USDC, and a 1,000 USDC deposit made after that minted one share, so +the attacker redeemed half the pool and kept 155 USDC of it. Tested by +`inflating_shares_through_own_borrow_does_not_pay`, which fails against the +previous program, and by `first_deposit_must_exceed_the_minimum` and +`sole_supplier_leaves_the_minimum_behind`. The test harness's `add_reserve` now +opens each reserve with a deposit from the market owner; `add_empty_reserve` +leaves it empty. + ## 2026-09-22 Accrue interest by the wall clock instead of by slots. The reserve's diff --git a/finance/lending/anchor-v1/README.md b/finance/lending/anchor-v1/README.md index cefc669a6..0063d4f01 100644 --- a/finance/lending/anchor-v1/README.md +++ b/finance/lending/anchor-v1/README.md @@ -55,10 +55,26 @@ crosses the liquidation threshold and a liquidator can close part of the positio ### Share tokens (the deposit claim) Supplying liquidity mints share tokens; redeeming burns them. The exchange rate -is `total_liquidity / share_supply`, where `total_liquidity = available_liquidity -+ current_debt`. `available_liquidity` (not the vault's raw token balance) is the -source of truth, so a token donated directly to the vault cannot inflate the rate, -closing the classic empty-pool inflation attack. The first deposit mints 1:1. +is `total_liquidity / total_shares`, where `total_liquidity = available_liquidity ++ current_debt` and `total_shares` is the share supply plus `MINIMUM_SHARES`. +`available_liquidity` (not the vault's raw token balance) is the source of truth, +so a token donated directly to the vault cannot inflate the rate. + +That alone does not close the empty-pool inflation attack, because +`total_liquidity` also counts interest owed on borrows, and a supplier can +borrow. A lone supplier holding one share borrows a single base unit from their +own reserve; debt rounds up, so a second later it reads as two, and the share is +worth two units without anything having been minted. Deposits and redemptions +round in the pool's favor, so depositing the most that still mints one share +and redeeming it leaves the remainder with the only other share, the attacker's +own, and the price climbs about half again each round. After 49 rounds one share +is worth about 690 USDC, a 1,000 USDC deposit mints one share, and the attacker's +share redeems half the pool: 155 USDC of profit on a debt they repay. So the +first deposit mints 1:1 less `MINIMUM_SHARES` (1,000), which are never minted and +count as shares nobody holds in every conversion between shares and liquidity: +deposit, redeem, collateral valuation, collateral withdrawal and liquidation. +The attacker's one share is then 1 of 1,001, and what the rounding leaves behind +goes mostly to shares nobody can redeem. ### Interest: a kinked curve and a cumulative index diff --git a/finance/lending/anchor-v1/programs/lending/src/constants.rs b/finance/lending/anchor-v1/programs/lending/src/constants.rs index a56c893d5..125e5356f 100644 --- a/finance/lending/anchor-v1/programs/lending/src/constants.rs +++ b/finance/lending/anchor-v1/programs/lending/src/constants.rs @@ -39,6 +39,22 @@ pub const SECONDS_PER_YEAR: u128 = 31_536_000; /// time, so the window tightens on its own and never loosens. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25; +/// Reserve shares withheld from a reserve's first deposit. The first supplier +/// receives `deposit - MINIMUM_SHARES` shares rather than the full amount, the +/// convention Uniswap V2 uses for LP tokens, and every conversion between +/// shares and liquidity counts these as shares that nobody holds +/// (`Reserve::total_shares`), so their slice of the pool never leaves. +/// +/// Shares are priced off tracked `total_liquidity`, not the vault balance, so a +/// direct donation cannot move them. But `total_liquidity` counts interest owed +/// on borrows, and a supplier can also borrow: a lone supplier holding one +/// share can make it worth more than one unit through their own debt, then +/// ratchet the price up with deposits and redemptions whose rounding stays in +/// the pool, until a later deposit rounds down and they take part of it. With +/// the minimum counted, their one share is 1 of 1_001, and whatever the +/// rounding leaves behind is spread mostly across shares they cannot redeem. +pub const MINIMUM_SHARES: u64 = 1_000; + // PDA seeds. pub const LENDING_MARKET_SEED: &[u8] = b"lending_market"; pub const RESERVE_SEED: &[u8] = b"reserve"; diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/deposit_reserve_liquidity.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/deposit_reserve_liquidity.rs index 3425497ca..5082960a9 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/deposit_reserve_liquidity.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/deposit_reserve_liquidity.rs @@ -3,14 +3,16 @@ use anchor_spl::token_interface::{ mint_to, transfer_checked, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked, }; +use crate::constants::MINIMUM_SHARES; use crate::errors::LendingError; use crate::math::mul_div_floor; use crate::state::{reserve_signer_seeds, Reserve}; /// Supply liquidity to a reserve and receive share tokens. The first deposit -/// mints share tokens 1:1; later deposits mint -/// `liquidity_amount * share_supply / total_liquidity`, floored so the protocol -/// keeps any rounding dust. +/// mints share tokens 1:1, less the `MINIMUM_SHARES` withheld; later deposits +/// mint `liquidity_amount * total_shares / total_liquidity`, where +/// `total_shares` counts the withheld minimum, floored so the protocol keeps +/// any rounding dust. pub fn handle_deposit_reserve_liquidity( context: Context, liquidity_amount: u64, @@ -19,11 +21,31 @@ pub fn handle_deposit_reserve_liquidity( let reserve = &mut context.accounts.reserve; reserve.require_refreshed()?; - let share_supply = reserve.share_mint_supply as u128; - let share_amount = if share_supply == 0 { - liquidity_amount as u128 + let total_liquidity = reserve.total_liquidity()?; + let share_amount = if reserve.share_mint_supply == 0 && total_liquidity == 0 { + // Bootstrap: shares track liquidity one-for-one, less the withheld + // minimum, so the share supply can never start at a dust amount. + liquidity_amount + .checked_sub(MINIMUM_SHARES) + .ok_or(LendingError::DepositTooSmall)? as u128 } else { - mul_div_floor(liquidity_amount as u128, share_supply, reserve.total_liquidity()?)? + // The withheld minimum counts as shares nobody holds, here and in every + // other conversion, so its slice of the pool is locked for good. That + // is what stops share inflation: a lone supplier who borrows from their + // own reserve can lift `total_liquidity` with the interest they owe, + // and deposits and redemptions that round in the pool's favour lift it + // further, until one share is worth enough to round a later deposit + // down. With the minimum counted, their one share is 1 of 1_001 and + // whatever they leave in the pool goes mostly to shares nobody redeems. + // + // A reserve whose suppliers have all left takes this branch too: the + // minimum's slice is still in `total_liquidity`, so the next deposit + // is priced against it rather than bootstrapped. + mul_div_floor( + liquidity_amount as u128, + reserve.total_shares()?, + total_liquidity, + )? }; require!(share_amount > 0, LendingError::DepositTooSmall); let share_amount = u64::try_from(share_amount).map_err(|_| LendingError::MathOverflow)?; diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs index bb24f1369..4fddd3bcf 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs @@ -89,7 +89,7 @@ pub fn handle_liquidate_obligation( )?; let seize_shares = mul_div_floor( seize_liquidity as u128, - collateral_reserve.share_mint_supply as u128, + collateral_reserve.total_shares()?, collateral_reserve.total_liquidity()?.max(1), )?; let seize_shares = u64::try_from(seize_shares).map_err(|_| LendingError::MathOverflow)?; diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/redeem_reserve_collateral.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/redeem_reserve_collateral.rs index 459b56cf8..7ece3a5da 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/redeem_reserve_collateral.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/redeem_reserve_collateral.rs @@ -8,8 +8,10 @@ use crate::math::mul_div_floor; use crate::state::{reserve_signer_seeds, Reserve}; /// Burn share tokens and withdraw the underlying liquidity they represent: -/// `share_amount * total_liquidity / share_supply`, floored so the protocol -/// keeps any rounding dust. Capped by the reserve's available (un-borrowed) +/// `share_amount * total_liquidity / total_shares`, floored so the protocol +/// keeps any rounding dust. `total_shares` counts the `MINIMUM_SHARES` withheld +/// from the first deposit, as `deposit_reserve_liquidity` does, so their slice +/// of the pool never leaves. Capped by the reserve's available (un-borrowed) /// liquidity. pub fn handle_redeem_reserve_collateral( context: Context, @@ -19,12 +21,11 @@ pub fn handle_redeem_reserve_collateral( let reserve = &mut context.accounts.reserve; reserve.require_refreshed()?; - let share_supply = reserve.share_mint_supply as u128; - require!(share_supply > 0, LendingError::InsufficientReserveLiquidity); + require!(reserve.share_mint_supply > 0, LendingError::InsufficientReserveLiquidity); let liquidity_amount = mul_div_floor( share_amount as u128, reserve.total_liquidity()?, - share_supply, + reserve.total_shares()?, )?; let liquidity_amount = u64::try_from(liquidity_amount).map_err(|_| LendingError::MathOverflow)?; require!( diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_obligation.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_obligation.rs index 21460be55..758113cad 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_obligation.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_obligation.rs @@ -33,7 +33,7 @@ pub fn handle_refresh_obligation(context: Context) -> Result< let liquidity = mul_div_floor( collateral.deposited_shares as u128, reserve.total_liquidity()?, - (reserve.share_mint_supply as u128).max(1), + reserve.total_shares()?, )?; let liquidity = u64::try_from(liquidity).map_err(|_| LendingError::MathOverflow)?; let value = market_value(liquidity, reserve.liquidity_decimals, price_scaled, Rounding::Down)?; diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/withdraw_obligation_collateral.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/withdraw_obligation_collateral.rs index a38860b85..ac603c5fc 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/withdraw_obligation_collateral.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/withdraw_obligation_collateral.rs @@ -39,7 +39,7 @@ pub fn handle_withdraw_obligation_collateral( let removed_liquidity = mul_div_ceil( share_amount as u128, reserve.total_liquidity()?, - (reserve.share_mint_supply as u128).max(1), + reserve.total_shares()?, )?; let removed_liquidity = u64::try_from(removed_liquidity).map_err(|_| LendingError::MathOverflow)?; let removed_value = market_value( diff --git a/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs b/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs index ccc0067da..34bd82635 100644 --- a/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs @@ -1,6 +1,8 @@ use anchor_lang::prelude::*; -use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED, SECONDS_PER_YEAR}; +use crate::constants::{ + BPS_DENOMINATOR, FIXED_POINT_SCALE, MINIMUM_SHARES, RESERVE_SEED, SECONDS_PER_YEAR, +}; use crate::errors::LendingError; use crate::math::{mul_div_ceil, mul_div_floor}; @@ -52,7 +54,9 @@ pub struct Reserve { /// burning share tokens directly via the token program (outside this /// program) makes the real mint supply drift below this mirror; that drift /// only lowers what the burner could have redeemed, so the pool never pays - /// out more than it holds. + /// out more than it holds. The `MINIMUM_SHARES` withheld from the first + /// deposit are never minted and are not counted here; `total_shares` adds + /// them. pub share_mint_supply: u64, /// Total borrowed principal. The live debt is @@ -170,6 +174,15 @@ impl Reserve { .ok_or(LendingError::MathOverflow.into()) } + /// The share count every conversion between shares and liquidity divides + /// by: the outstanding supply plus the `MINIMUM_SHARES` withheld from the + /// first deposit, which belong to nobody and so never redeem. + pub fn total_shares(&self) -> Result { + (self.share_mint_supply as u128) + .checked_add(MINIMUM_SHARES as u128) + .ok_or(LendingError::MathOverflow.into()) + } + /// Borrowed fraction of the pool, in basis points (0..=10_000). pub fn utilization_bps(&self) -> Result { let gross = self.gross_liquidity()?; diff --git a/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs b/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs index 39756bd95..ec4bb9962 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs @@ -23,8 +23,8 @@ use solana_kite::{ use solana_signer::Signer; use lending::constants::{ - LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED, - PRICE_FEED_SEED, RESERVE_SEED, SHARE_MINT_SEED, + LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, MINIMUM_SHARES, OBLIGATION_SEED, + OBLIGATION_SHARE_VAULT_SEED, PRICE_FEED_SEED, RESERVE_SEED, SHARE_MINT_SEED, }; use lending::state::{Obligation, Reserve, ReserveConfig}; @@ -274,11 +274,31 @@ impl Env { send(&mut self.svm, vec![instruction], &[owner], &owner.pubkey()).unwrap(); } + /// Add a reserve to the default market and open it: the market owner makes + /// the first deposit, `MINIMUM_SHARES + 1`, so the withheld minimum is in + /// place and every later deposit mints shares one-for-one until interest + /// accrues. The owner keeps the one share it is minted. pub fn add_reserve( &mut self, decimals: u8, price_mantissa: i128, config: ReserveConfig, + ) -> ReserveHandle { + let handle = self.add_empty_reserve(decimals, price_mantissa, config); + let owner = self.owner.insecure_clone(); + let opening_deposit = MINIMUM_SHARES + 1; + self.fund(&owner, handle.mint, opening_deposit); + self.supply(&owner, &handle, opening_deposit); + handle + } + + /// Add a reserve to the default market with no deposits, for tests of the + /// first deposit itself. + pub fn add_empty_reserve( + &mut self, + decimals: u8, + price_mantissa: i128, + config: ReserveConfig, ) -> ReserveHandle { let owner = self.owner.insecure_clone(); let market = self.market; @@ -332,7 +352,7 @@ impl Env { } /// Supply liquidity to a reserve, receiving share tokens. Returns the user's - /// share-token account. + /// share-token account, which the first supply creates. pub fn try_supply( &mut self, user: &Keypair, @@ -340,13 +360,16 @@ impl Env { amount: u64, ) -> Result { let user_liquidity = ata(&user.pubkey(), &handle.mint); - let user_share = create_associated_token_account( - &mut self.svm, - &user.pubkey(), - &handle.share_mint, - user, - ) - .unwrap(); + let user_share = ata(&user.pubkey(), &handle.share_mint); + if self.svm.get_account(&user_share).is_none() { + create_associated_token_account( + &mut self.svm, + &user.pubkey(), + &handle.share_mint, + user, + ) + .unwrap(); + } let deposit = Instruction { program_id: lending::id(), diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_deposit_redeem.rs b/finance/lending/anchor-v1/programs/lending/tests/test_deposit_redeem.rs index f52f3cf88..c96d65c51 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_deposit_redeem.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_deposit_redeem.rs @@ -1,22 +1,42 @@ mod common; use common::{default_config, Env}; +use lending::constants::MINIMUM_SHARES; use solana_kite::mint_tokens_to_token_account; +/// The first deposit mints shares one-for-one, less the `MINIMUM_SHARES` +/// withheld. The withheld shares are never minted, so the tracked supply is +/// what the depositor holds, while the pool holds the whole deposit. #[test] -fn first_deposit_mints_shares_one_to_one() { +fn first_deposit_mints_shares_one_to_one_less_the_minimum() { let mut env = Env::new(); - let usdc = env.add_reserve(6, common::dollars(1), default_config()); + let usdc = env.add_empty_reserve(6, common::dollars(1), default_config()); let supplier = env.create_user(); let deposit = 1_000_000_000; // 1000 USDC env.fund(&supplier, usdc.mint, deposit); let share_account = env.supply(&supplier, &usdc, deposit); - assert_eq!(env.token_balance(share_account), deposit); + assert_eq!(env.token_balance(share_account), deposit - MINIMUM_SHARES); let reserve = env.reserve(&usdc); assert_eq!(reserve.available_liquidity, deposit); - assert_eq!(reserve.share_mint_supply, deposit); + assert_eq!(reserve.share_mint_supply, deposit - MINIMUM_SHARES); +} + +/// A first deposit no larger than the minimum would mint nothing, so it is +/// refused. +#[test] +fn first_deposit_must_exceed_the_minimum() { + let mut env = Env::new(); + let usdc = env.add_empty_reserve(6, common::dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, usdc.mint, MINIMUM_SHARES); + let result = env.try_supply(&supplier, &usdc, MINIMUM_SHARES); + assert!( + result.unwrap_err().contains("DepositTooSmall"), + "a first deposit of only the minimum mints nothing and must be rejected" + ); } #[test] @@ -57,5 +77,126 @@ fn redeem_returns_underlying_liquidity() { env.try_redeem(&supplier, &usdc, amount).unwrap(); assert_eq!(env.token_balance(liquidity_account), amount); assert_eq!(env.token_balance(share_account), 0); - assert_eq!(env.reserve(&usdc).share_mint_supply, 0); +} + +/// Even as the reserve's only supplier, the first depositor reclaims their +/// deposit less the withheld minimum: those shares belong to nobody, and their +/// liquidity stays in the pool. The next supplier is priced against that slice +/// rather than bootstrapped, at the same one share per unit. +#[test] +fn sole_supplier_leaves_the_minimum_behind() { + let mut env = Env::new(); + let usdc = env.add_empty_reserve(6, common::dollars(1), default_config()); + + let supplier = env.create_user(); + let amount = 1_000_000_000; + let liquidity_account = env.fund(&supplier, usdc.mint, amount); + let share_account = env.supply(&supplier, &usdc, amount); + let shares = env.token_balance(share_account); + env.try_redeem(&supplier, &usdc, shares).unwrap(); + + assert_eq!(env.token_balance(liquidity_account), amount - MINIMUM_SHARES); + let reserve = env.reserve(&usdc); + assert_eq!(reserve.share_mint_supply, 0); + assert_eq!(reserve.available_liquidity, MINIMUM_SHARES); + + let next = env.create_user(); + env.fund(&next, usdc.mint, 5_000); + let next_shares = env.supply(&next, &usdc, 5_000); + assert_eq!(env.token_balance(next_shares), 5_000); +} + +/// First-depositor share inflation without a donation. Shares are priced +/// against tracked `total_liquidity`, not the vault balance, so tokens sent +/// straight to the vault move nothing. But `total_liquidity` also counts +/// interest owed on borrows, and a supplier can borrow from their own reserve. +/// +/// The attacker opens the reserve holding a single share, borrows one base +/// unit of it against collateral in another reserve, and lets one second pass. +/// Debt is rounded up, so the one unit now reads as two and the lone share is +/// worth two units without having minted anything. From there, deposits and +/// redemptions that round down in the pool's favour ratchet the price up: each +/// deposit is the largest that still mints one share, and redeeming that share +/// leaves the rounding behind for the only other share, the attacker's own. A +/// deposit that would mint zero shares is refused (`DepositTooSmall`), so the +/// victim is not robbed outright; instead a deposit just under two shares' +/// worth mints one, and the attacker's share redeems half the pool. +/// +/// The rate curve is the default one. Nothing about the attack needs the +/// market owner's cooperation or bad debt: the attacker repays what they owe. +/// +/// `MINIMUM_SHARES` counts as shares nobody holds in every share conversion, +/// so the attacker's one share is 1 of 1_001 and whatever the rounding leaves +/// behind is spread mostly across shares they cannot redeem. +#[test] +fn inflating_shares_through_own_borrow_does_not_pay() { + let mut env = Env::new(); + let usdc = env.add_empty_reserve(6, common::dollars(1), default_config()); + let collateral = env.add_reserve(6, common::dollars(1), default_config()); + + let attacker = env.create_user(); + let budget: u64 = 4_000_000_000; + let attacker_liquidity = env.fund(&attacker, usdc.mint, budget); + + // Open the reserve and keep exactly one share. + let attacker_share = env.supply(&attacker, &usdc, MINIMUM_SHARES + 1); + let surplus = env.token_balance(attacker_share) - 1; + if surplus > 0 { + env.try_redeem(&attacker, &usdc, surplus).unwrap(); + } + assert_eq!(env.token_balance(attacker_share), 1); + + // Borrow one base unit against collateral in another reserve, and let a + // second of interest round that debt up to two. + env.fund(&attacker, collateral.mint, 1_000_000_000); + let collateral_share = env.supply(&attacker, &collateral, 1_000_000_000); + let obligation = env.initialize_obligation(&attacker); + let collateral_shares = env.token_balance(collateral_share); + env.post_collateral(&attacker, obligation, &collateral, collateral_shares); + env.try_borrow(&attacker, obligation, &[&collateral], &[], &usdc, 1) + .unwrap(); + env.warp_seconds(1); + env.refresh_reserve_only(&attacker, &usdc); + assert_eq!(env.reserve(&usdc).current_borrowed_amount().unwrap(), 2); + + // Ratchet until one share is worth more than half the victim's deposit. + let victim_deposit: u64 = 1_000_000_000; + for _ in 0..64 { + let reserve = env.reserve(&usdc); + let total = reserve.total_liquidity().unwrap(); + let shares = reserve.share_mint_supply as u128 + MINIMUM_SHARES as u128; + if total * 2 > victim_deposit as u128 * shares { + break; + } + // The largest deposit that mints exactly one share. + let deposit = (2 * total).div_ceil(shares) - 1; + env.svm.expire_blockhash(); + env.supply(&attacker, &usdc, deposit as u64); + env.try_redeem(&attacker, &usdc, 1).unwrap(); + } + + let victim = env.create_user(); + let victim_liquidity = env.fund(&victim, usdc.mint, victim_deposit); + let victim_share = env.supply(&victim, &usdc, victim_deposit); + + // The attacker exits: redeems their share and repays the debt. + env.svm.expire_blockhash(); + let attacker_shares = env.token_balance(attacker_share); + env.try_redeem(&attacker, &usdc, attacker_shares).unwrap(); + env.repay(&attacker, obligation, &usdc, 10); + assert_eq!(env.reserve(&usdc).borrowed_principal, 0); + let attacker_end = env.token_balance(attacker_liquidity); + assert!( + attacker_end <= budget, + "attacker started with {budget} and ended with {attacker_end}" + ); + + // The victim exits with everything and gets back all but a sliver. + let victim_shares = env.token_balance(victim_share); + env.try_redeem(&victim, &usdc, victim_shares).unwrap(); + let victim_back = env.token_balance(victim_liquidity); + assert!( + victim_back * 1_000 >= victim_deposit * 999, + "victim deposited {victim_deposit} and got back {victim_back}" + ); } diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs b/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs index fd75ebbc5..4a7eabe4c 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs @@ -7,7 +7,7 @@ use lending::constants::FIXED_POINT_SCALE; #[test] fn init_market_and_reserve() { let mut env = Env::new(); - let usdc = env.add_reserve(6, common::dollars(1), default_config()); + let usdc = env.add_empty_reserve(6, common::dollars(1), default_config()); let reserve = env.reserve(&usdc); assert_eq!(reserve.lending_market, env.market); diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index 481fe877f..c1fc61662 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 2026-09-23 + +Lock a minimum number of reserve shares. The first deposit now mints +`deposit - MINIMUM_SHARES` (1,000) shares, and every conversion between shares +and liquidity (deposit, redeem, collateral valuation in `refresh_obligation`, +`withdraw_obligation_collateral` and liquidation) divides by the share supply +plus that minimum, through the new `Reserve::total_shares`. The withheld shares +belong to nobody, so their slice of the pool stays locked. A first deposit of +`MINIMUM_SHARES` or less fails with `DepositTooSmall`, and a reserve whose +suppliers have all left prices the next deposit against the locked slice +rather than bootstrapping it. + +Pricing shares against tracked `total_liquidity` stopped a vault donation, but +`total_liquidity` includes interest owed on borrows. A lone supplier with one +share could borrow a single base unit from their own reserve, let one second of +interest round the debt up to two, and then ratchet the share price up about +half again per round by depositing the most that still minted one share and +redeeming it, leaving the rounding with their own share. On the default rate +curve, with no bad debt and the loan repaid, 49 rounds made one share worth +about 690 USDC, and a 1,000 USDC deposit made after that minted one share, so +the attacker redeemed half the pool and kept 155 USDC of it. Tested by +`inflating_shares_through_own_borrow_does_not_pay`, which fails against the +previous program, and by `first_deposit_must_exceed_the_minimum` and +`sole_supplier_leaves_the_minimum_behind`. The test harness's `add_reserve` now +opens each reserve with a deposit from the market owner; `add_empty_reserve` +leaves it empty. + ## 2026-09-22 Accrue interest by the wall clock instead of by slots. The reserve's diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 40713099f..9d43d292f 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -55,10 +55,26 @@ crosses the liquidation threshold and a liquidator can close part of the positio ### Share tokens (the deposit claim) Supplying liquidity mints share tokens; redeeming burns them. The exchange rate -is `total_liquidity / share_supply`, where `total_liquidity = available_liquidity -+ current_debt`. `available_liquidity` (not the vault's raw token balance) is the -source of truth, so a token donated directly to the vault cannot inflate the rate, -closing the classic empty-pool inflation attack. The first deposit mints 1:1. +is `total_liquidity / total_shares`, where `total_liquidity = available_liquidity ++ current_debt` and `total_shares` is the share supply plus `MINIMUM_SHARES`. +`available_liquidity` (not the vault's raw token balance) is the source of truth, +so a token donated directly to the vault cannot inflate the rate. + +That alone does not close the empty-pool inflation attack, because +`total_liquidity` also counts interest owed on borrows, and a supplier can +borrow. A lone supplier holding one share borrows a single base unit from their +own reserve; debt rounds up, so a second later it reads as two, and the share is +worth two units without anything having been minted. Deposits and redemptions +round in the pool's favor, so depositing the most that still mints one share +and redeeming it leaves the remainder with the only other share, the attacker's +own, and the price climbs about half again each round. After 49 rounds one share +is worth about 690 USDC, a 1,000 USDC deposit mints one share, and the attacker's +share redeems half the pool: 155 USDC of profit on a debt they repay. So the +first deposit mints 1:1 less `MINIMUM_SHARES` (1,000), which are never minted and +count as shares nobody holds in every conversion between shares and liquidity: +deposit, redeem, collateral valuation, collateral withdrawal and liquidation. +The attacker's one share is then 1 of 1,001, and what the rounding leaves behind +goes mostly to shares nobody can redeem. ### Interest: a kinked curve and a cumulative index diff --git a/finance/lending/anchor/programs/lending/src/constants.rs b/finance/lending/anchor/programs/lending/src/constants.rs index a56c893d5..125e5356f 100644 --- a/finance/lending/anchor/programs/lending/src/constants.rs +++ b/finance/lending/anchor/programs/lending/src/constants.rs @@ -39,6 +39,22 @@ pub const SECONDS_PER_YEAR: u128 = 31_536_000; /// time, so the window tightens on its own and never loosens. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25; +/// Reserve shares withheld from a reserve's first deposit. The first supplier +/// receives `deposit - MINIMUM_SHARES` shares rather than the full amount, the +/// convention Uniswap V2 uses for LP tokens, and every conversion between +/// shares and liquidity counts these as shares that nobody holds +/// (`Reserve::total_shares`), so their slice of the pool never leaves. +/// +/// Shares are priced off tracked `total_liquidity`, not the vault balance, so a +/// direct donation cannot move them. But `total_liquidity` counts interest owed +/// on borrows, and a supplier can also borrow: a lone supplier holding one +/// share can make it worth more than one unit through their own debt, then +/// ratchet the price up with deposits and redemptions whose rounding stays in +/// the pool, until a later deposit rounds down and they take part of it. With +/// the minimum counted, their one share is 1 of 1_001, and whatever the +/// rounding leaves behind is spread mostly across shares they cannot redeem. +pub const MINIMUM_SHARES: u64 = 1_000; + // PDA seeds. pub const LENDING_MARKET_SEED: &[u8] = b"lending_market"; pub const RESERVE_SEED: &[u8] = b"reserve"; diff --git a/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs b/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs index e932fc157..84d17d7d9 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs @@ -3,14 +3,16 @@ use anchor_spl::token_interface::{ mint_to, transfer_checked, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked, }; +use crate::constants::MINIMUM_SHARES; use crate::errors::LendingError; use crate::math::mul_div_floor; use crate::state::{reserve_signer_seeds, Reserve}; /// Supply liquidity to a reserve and receive share tokens. The first deposit -/// mints share tokens 1:1; later deposits mint -/// `liquidity_amount * share_supply / total_liquidity`, floored so the protocol -/// keeps any rounding dust. +/// mints share tokens 1:1, less the `MINIMUM_SHARES` withheld; later deposits +/// mint `liquidity_amount * total_shares / total_liquidity`, where +/// `total_shares` counts the withheld minimum, floored so the protocol keeps +/// any rounding dust. pub fn handle_deposit_reserve_liquidity( context: &mut Context, liquidity_amount: u64, @@ -19,14 +21,30 @@ pub fn handle_deposit_reserve_liquidity( let reserve = &mut context.accounts.reserve; reserve.require_refreshed()?; - let share_supply = reserve.share_mint_supply as u128; - let share_amount = if share_supply == 0 { - liquidity_amount as u128 + let total_liquidity = reserve.total_liquidity()?; + let share_amount = if reserve.share_mint_supply == 0 && total_liquidity == 0 { + // Bootstrap: shares track liquidity one-for-one, less the withheld + // minimum, so the share supply can never start at a dust amount. + liquidity_amount + .checked_sub(MINIMUM_SHARES) + .ok_or(LendingError::DepositTooSmall)? as u128 } else { + // The withheld minimum counts as shares nobody holds, here and in every + // other conversion, so its slice of the pool is locked for good. That + // is what stops share inflation: a lone supplier who borrows from their + // own reserve can lift `total_liquidity` with the interest they owe, + // and deposits and redemptions that round in the pool's favour lift it + // further, until one share is worth enough to round a later deposit + // down. With the minimum counted, their one share is 1 of 1_001 and + // whatever they leave in the pool goes mostly to shares nobody redeems. + // + // A reserve whose suppliers have all left takes this branch too: the + // minimum's slice is still in `total_liquidity`, so the next deposit + // is priced against it rather than bootstrapped. mul_div_floor( liquidity_amount as u128, - share_supply, - reserve.total_liquidity()?, + reserve.total_shares()?, + total_liquidity, )? }; require!(share_amount > 0, LendingError::DepositTooSmall); diff --git a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs index da1ab81d3..8806e2aaa 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs @@ -91,7 +91,7 @@ pub fn handle_liquidate_obligation( )?; let seize_shares = mul_div_floor( seize_liquidity as u128, - collateral_reserve.share_mint_supply as u128, + collateral_reserve.total_shares()?, collateral_reserve.total_liquidity()?.max(1), )?; let seize_shares = u64::try_from(seize_shares).map_err(|_| LendingError::MathOverflow)?; diff --git a/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs b/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs index 2416fc2ed..d5676f1f4 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs @@ -8,8 +8,10 @@ use crate::math::mul_div_floor; use crate::state::{reserve_signer_seeds, Reserve}; /// Burn share tokens and withdraw the underlying liquidity they represent: -/// `share_amount * total_liquidity / share_supply`, floored so the protocol -/// keeps any rounding dust. Capped by the reserve's available (un-borrowed) +/// `share_amount * total_liquidity / total_shares`, floored so the protocol +/// keeps any rounding dust. `total_shares` counts the `MINIMUM_SHARES` withheld +/// from the first deposit, as `deposit_reserve_liquidity` does, so their slice +/// of the pool never leaves. Capped by the reserve's available (un-borrowed) /// liquidity. pub fn handle_redeem_reserve_collateral( context: &mut Context, @@ -19,12 +21,14 @@ pub fn handle_redeem_reserve_collateral( let reserve = &mut context.accounts.reserve; reserve.require_refreshed()?; - let share_supply = reserve.share_mint_supply as u128; - require!(share_supply > 0, LendingError::InsufficientReserveLiquidity); + require!( + reserve.share_mint_supply > 0, + LendingError::InsufficientReserveLiquidity + ); let liquidity_amount = mul_div_floor( share_amount as u128, reserve.total_liquidity()?, - share_supply, + reserve.total_shares()?, )?; let liquidity_amount = u64::try_from(liquidity_amount).map_err(|_| LendingError::MathOverflow)?; diff --git a/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs index 3777a7a7d..d146adf2e 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs @@ -40,7 +40,7 @@ pub fn handle_refresh_obligation(context: &mut Context) -> Re let liquidity = mul_div_floor( collateral.deposited_shares as u128, reserve.total_liquidity()?, - (reserve.share_mint_supply as u128).max(1), + reserve.total_shares()?, )?; let liquidity = u64::try_from(liquidity).map_err(|_| LendingError::MathOverflow)?; let value = market_value( diff --git a/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs b/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs index ec810d751..ac85e77c5 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs @@ -40,7 +40,7 @@ pub fn handle_withdraw_obligation_collateral( let removed_liquidity = mul_div_ceil( share_amount as u128, reserve.total_liquidity()?, - (reserve.share_mint_supply as u128).max(1), + reserve.total_shares()?, )?; let removed_liquidity = u64::try_from(removed_liquidity).map_err(|_| LendingError::MathOverflow)?; diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs index 53ebfdf25..9a99d0890 100644 --- a/finance/lending/anchor/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor/programs/lending/src/state/reserve.rs @@ -1,6 +1,8 @@ use anchor_lang::prelude::*; -use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED, SECONDS_PER_YEAR}; +use crate::constants::{ + BPS_DENOMINATOR, FIXED_POINT_SCALE, MINIMUM_SHARES, RESERVE_SEED, SECONDS_PER_YEAR, +}; use crate::errors::LendingError; use crate::math::{mul_div_ceil, mul_div_floor}; @@ -52,7 +54,9 @@ pub struct Reserve { /// burning share tokens directly via the token program (outside this /// program) makes the real mint supply drift below this mirror; that drift /// only lowers what the burner could have redeemed, so the pool never pays - /// out more than it holds. + /// out more than it holds. The `MINIMUM_SHARES` withheld from the first + /// deposit are never minted and are not counted here; `total_shares` adds + /// them. pub share_mint_supply: u64, /// Total borrowed principal. The live debt is @@ -172,6 +176,15 @@ impl Reserve { .ok_or(LendingError::MathOverflow.into()) } + /// The share count every conversion between shares and liquidity divides + /// by: the outstanding supply plus the `MINIMUM_SHARES` withheld from the + /// first deposit, which belong to nobody and so never redeem. + pub fn total_shares(&self) -> Result { + (self.share_mint_supply as u128) + .checked_add(MINIMUM_SHARES as u128) + .ok_or(LendingError::MathOverflow.into()) + } + /// Borrowed fraction of the pool, in basis points (0..=10_000). pub fn utilization_bps(&self) -> Result { let gross = self.gross_liquidity()?; diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index d369b8df1..4f456692a 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -18,8 +18,8 @@ use solana_kite::{ }; use lending::constants::{ - LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED, - PRICE_FEED_SEED, RESERVE_SEED, SHARE_MINT_SEED, + LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, MINIMUM_SHARES, OBLIGATION_SEED, + OBLIGATION_SHARE_VAULT_SEED, PRICE_FEED_SEED, RESERVE_SEED, SHARE_MINT_SEED, }; use lending::state::{Obligation, Reserve, ReserveConfig}; @@ -301,11 +301,31 @@ impl Env { send(&mut self.svm, vec![instruction], &[owner], &owner.pubkey()).unwrap(); } + /// Add a reserve to the default market and open it: the market owner makes + /// the first deposit, `MINIMUM_SHARES + 1`, so the withheld minimum is in + /// place and every later deposit mints shares one-for-one until interest + /// accrues. The owner keeps the one share it is minted. pub fn add_reserve( &mut self, decimals: u8, price_mantissa: i128, config: ReserveConfig, + ) -> ReserveHandle { + let handle = self.add_empty_reserve(decimals, price_mantissa, config); + let owner = self.owner.insecure_clone(); + let opening_deposit = MINIMUM_SHARES + 1; + self.fund(&owner, handle.mint, opening_deposit); + self.supply(&owner, &handle, opening_deposit); + handle + } + + /// Add a reserve to the default market with no deposits, for tests of the + /// first deposit itself. + pub fn add_empty_reserve( + &mut self, + decimals: u8, + price_mantissa: i128, + config: ReserveConfig, ) -> ReserveHandle { let owner = self.owner.insecure_clone(); let market = self.market; @@ -359,7 +379,7 @@ impl Env { } /// Supply liquidity to a reserve, receiving share tokens. Returns the user's - /// share-token account. + /// share-token account, which the first supply creates. pub fn try_supply( &mut self, user: &Keypair, @@ -367,13 +387,16 @@ impl Env { amount: u64, ) -> Result { let user_liquidity = ata(&user.pubkey(), &handle.mint); - let user_share = create_associated_token_account( - &mut self.svm, - &user.pubkey(), - &handle.share_mint, - user, - ) - .unwrap(); + let user_share = ata(&user.pubkey(), &handle.share_mint); + if self.svm.get_account(&user_share).is_none() { + create_associated_token_account( + &mut self.svm, + &user.pubkey(), + &handle.share_mint, + user, + ) + .unwrap(); + } let deposit = Instruction { program_id: lending::id(), diff --git a/finance/lending/anchor/programs/lending/tests/test_deposit_redeem.rs b/finance/lending/anchor/programs/lending/tests/test_deposit_redeem.rs index 33c04ccef..92a11d783 100644 --- a/finance/lending/anchor/programs/lending/tests/test_deposit_redeem.rs +++ b/finance/lending/anchor/programs/lending/tests/test_deposit_redeem.rs @@ -1,22 +1,40 @@ mod common; use common::{default_config, Env}; +use lending::constants::MINIMUM_SHARES; +use lending::errors::LendingError; use solana_kite::mint_tokens_to_token_account; +/// The first deposit mints shares one-for-one, less the `MINIMUM_SHARES` +/// withheld. The withheld shares are never minted, so the tracked supply is +/// what the depositor holds, while the pool holds the whole deposit. #[test] -fn first_deposit_mints_shares_one_to_one() { +fn first_deposit_mints_shares_one_to_one_less_the_minimum() { let mut env = Env::new(); - let usdc = env.add_reserve(6, common::dollars(1), default_config()); + let usdc = env.add_empty_reserve(6, common::dollars(1), default_config()); let supplier = env.create_user(); let deposit = 1_000_000_000; // 1000 USDC env.fund(&supplier, usdc.mint, deposit); let share_account = env.supply(&supplier, &usdc, deposit); - assert_eq!(env.token_balance(share_account), deposit); + assert_eq!(env.token_balance(share_account), deposit - MINIMUM_SHARES); let reserve = env.reserve(&usdc); assert_eq!(reserve.available_liquidity, deposit); - assert_eq!(reserve.share_mint_supply, deposit); + assert_eq!(reserve.share_mint_supply, deposit - MINIMUM_SHARES); +} + +/// A first deposit no larger than the minimum would mint nothing, so it is +/// refused. +#[test] +fn first_deposit_must_exceed_the_minimum() { + let mut env = Env::new(); + let usdc = env.add_empty_reserve(6, common::dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, usdc.mint, MINIMUM_SHARES); + let result = env.try_supply(&supplier, &usdc, MINIMUM_SHARES); + common::assert_program_error!(result, LendingError::DepositTooSmall); } #[test] @@ -63,5 +81,129 @@ fn redeem_returns_underlying_liquidity() { env.try_redeem(&supplier, &usdc, amount).unwrap(); assert_eq!(env.token_balance(liquidity_account), amount); assert_eq!(env.token_balance(share_account), 0); - assert_eq!(env.reserve(&usdc).share_mint_supply, 0); +} + +/// Even as the reserve's only supplier, the first depositor reclaims their +/// deposit less the withheld minimum: those shares belong to nobody, and their +/// liquidity stays in the pool. The next supplier is priced against that slice +/// rather than bootstrapped, at the same one share per unit. +#[test] +fn sole_supplier_leaves_the_minimum_behind() { + let mut env = Env::new(); + let usdc = env.add_empty_reserve(6, common::dollars(1), default_config()); + + let supplier = env.create_user(); + let amount = 1_000_000_000; + let liquidity_account = env.fund(&supplier, usdc.mint, amount); + let share_account = env.supply(&supplier, &usdc, amount); + let shares = env.token_balance(share_account); + env.try_redeem(&supplier, &usdc, shares).unwrap(); + + assert_eq!( + env.token_balance(liquidity_account), + amount - MINIMUM_SHARES + ); + let reserve = env.reserve(&usdc); + assert_eq!(reserve.share_mint_supply, 0); + assert_eq!(reserve.available_liquidity, MINIMUM_SHARES); + + let next = env.create_user(); + env.fund(&next, usdc.mint, 5_000); + let next_shares = env.supply(&next, &usdc, 5_000); + assert_eq!(env.token_balance(next_shares), 5_000); +} + +/// First-depositor share inflation without a donation. Shares are priced +/// against tracked `total_liquidity`, not the vault balance, so tokens sent +/// straight to the vault move nothing. But `total_liquidity` also counts +/// interest owed on borrows, and a supplier can borrow from their own reserve. +/// +/// The attacker opens the reserve holding a single share, borrows one base +/// unit of it against collateral in another reserve, and lets one second pass. +/// Debt is rounded up, so the one unit now reads as two and the lone share is +/// worth two units without having minted anything. From there, deposits and +/// redemptions that round down in the pool's favour ratchet the price up: each +/// deposit is the largest that still mints one share, and redeeming that share +/// leaves the rounding behind for the only other share, the attacker's own. A +/// deposit that would mint zero shares is refused (`DepositTooSmall`), so the +/// victim is not robbed outright; instead a deposit just under two shares' +/// worth mints one, and the attacker's share redeems half the pool. +/// +/// The rate curve is the default one. Nothing about the attack needs the +/// market owner's cooperation or bad debt: the attacker repays what they owe. +/// +/// `MINIMUM_SHARES` counts as shares nobody holds in every share conversion, +/// so the attacker's one share is 1 of 1_001 and whatever the rounding leaves +/// behind is spread mostly across shares they cannot redeem. +#[test] +fn inflating_shares_through_own_borrow_does_not_pay() { + let mut env = Env::new(); + let usdc = env.add_empty_reserve(6, common::dollars(1), default_config()); + let collateral = env.add_reserve(6, common::dollars(1), default_config()); + + let attacker = env.create_user(); + let budget: u64 = 4_000_000_000; + let attacker_liquidity = env.fund(&attacker, usdc.mint, budget); + + // Open the reserve and keep exactly one share. + let attacker_share = env.supply(&attacker, &usdc, MINIMUM_SHARES + 1); + let surplus = env.token_balance(attacker_share) - 1; + if surplus > 0 { + env.try_redeem(&attacker, &usdc, surplus).unwrap(); + } + assert_eq!(env.token_balance(attacker_share), 1); + + // Borrow one base unit against collateral in another reserve, and let a + // second of interest round that debt up to two. + env.fund(&attacker, collateral.mint, 1_000_000_000); + let collateral_share = env.supply(&attacker, &collateral, 1_000_000_000); + let obligation = env.initialize_obligation(&attacker); + let collateral_shares = env.token_balance(collateral_share); + env.post_collateral(&attacker, obligation, &collateral, collateral_shares); + env.try_borrow(&attacker, obligation, &[&collateral], &[], &usdc, 1) + .unwrap(); + env.warp_seconds(1); + env.refresh_reserve_only(&attacker, &usdc); + assert_eq!(env.reserve(&usdc).current_borrowed_amount().unwrap(), 2); + + // Ratchet until one share is worth more than half the victim's deposit. + let victim_deposit: u64 = 1_000_000_000; + for _ in 0..64 { + let reserve = env.reserve(&usdc); + let total = reserve.total_liquidity().unwrap(); + let shares = reserve.share_mint_supply as u128 + MINIMUM_SHARES as u128; + if total * 2 > victim_deposit as u128 * shares { + break; + } + // The largest deposit that mints exactly one share. + let deposit = (2 * total).div_ceil(shares) - 1; + env.svm.expire_blockhash(); + env.supply(&attacker, &usdc, deposit as u64); + env.try_redeem(&attacker, &usdc, 1).unwrap(); + } + + let victim = env.create_user(); + let victim_liquidity = env.fund(&victim, usdc.mint, victim_deposit); + let victim_share = env.supply(&victim, &usdc, victim_deposit); + + // The attacker exits: redeems their share and repays the debt. + env.svm.expire_blockhash(); + let attacker_shares = env.token_balance(attacker_share); + env.try_redeem(&attacker, &usdc, attacker_shares).unwrap(); + env.repay(&attacker, obligation, &usdc, 10); + assert_eq!(env.reserve(&usdc).borrowed_principal, 0); + let attacker_end = env.token_balance(attacker_liquidity); + assert!( + attacker_end <= budget, + "attacker started with {budget} and ended with {attacker_end}" + ); + + // The victim exits with everything and gets back all but a sliver. + let victim_shares = env.token_balance(victim_share); + env.try_redeem(&victim, &usdc, victim_shares).unwrap(); + let victim_back = env.token_balance(victim_liquidity); + assert!( + victim_back * 1_000 >= victim_deposit * 999, + "victim deposited {victim_deposit} and got back {victim_back}" + ); } diff --git a/finance/lending/anchor/programs/lending/tests/test_reserve.rs b/finance/lending/anchor/programs/lending/tests/test_reserve.rs index 63c6c2686..1c0f5a9cf 100644 --- a/finance/lending/anchor/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor/programs/lending/tests/test_reserve.rs @@ -9,7 +9,7 @@ use lending::state::Reserve; #[test] fn init_market_and_reserve() { let mut env = Env::new(); - let usdc = env.add_reserve(6, common::dollars(1), default_config()); + let usdc = env.add_empty_reserve(6, common::dollars(1), default_config()); let reserve = env.reserve(&usdc); assert_eq!(reserve.lending_market, env.market); diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index fa17e700b..717329d4e 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [2026-09-23] + +### Fixed + +- Lock a minimum number of reserve shares. The first deposit now mints + `deposit - MINIMUM_SHARES` (1,000) shares, and deposit, redeem, collateral + valuation, collateral withdrawal and liquidation all divide by the share + supply plus that minimum (`math::total_shares`). The withheld shares belong + to nobody, so their slice of the pool stays locked. A first deposit of + `MINIMUM_SHARES` or less fails with `DepositTooSmall`, and a reserve whose + suppliers have all left prices the next deposit against the locked slice. + Recording `available_liquidity` stopped a vault donation, but total liquidity + also counts interest owed on borrows, and a lone supplier who borrowed from + their own reserve could raise their single share's value with that interest + and then with rounding, until a later deposit rounded down in their favor. + The Anchor ports carry the test that runs it. Tested here by + `first_deposit_withholds_the_minimum` and + `first_deposit_must_exceed_the_minimum`; the test harnesses now open each + reserve with a deposit from the market owner. + ## [2026-09-22] ### Changed diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index 65b29de9a..107c4b478 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -59,9 +59,16 @@ Everything else mirrors the Anchor version. `LiquidationTooLarge` rather than silently seizing less, which would make the liquidator overpay. - **Share tokens**: supplying mints them, redeeming burns them; the exchange rate - `total_liquidity / share_supply` rises as borrowers pay interest. + `total_liquidity / total_shares` rises as borrowers pay interest. `available_liquidity` (not the vault's raw balance) is the source of truth, so a - token donation can't inflate the rate. + token donation can't inflate the rate. That is not enough on its own, because + total liquidity also counts interest owed on borrows and a supplier can borrow + from their own reserve: a lone supplier can raise the value of their one share + that way, then let rounding push it higher, until a later deposit rounds down + in their favor. So the first deposit mints 1:1 less `MINIMUM_SHARES` (1,000), + and `total_shares` (the share supply plus that minimum) is what every + conversion between shares and liquidity divides by. The withheld shares + belong to nobody, so the attacker's one share is 1 of 1,001. - **Protocol fees**: the reserve keeps `reserve_factor_bps` of each interest accrual in `accumulated_protocol_fees` (carved out of total liquidity, so it never lifts the supplier exchange rate); the market owner withdraws it with @@ -104,7 +111,7 @@ cargo test tests:: # runs the quasar-svm integration tests `cargo build-sbf` must run first: the tests load the compiled `target/deploy/quasar_lending.so` into `quasar-svm`. The suite drives the full -lifecycle: supply/redeem (1:1 first deposit), borrow up to the LTV limit (and +lifecycle: supply/redeem (1:1 first deposit, less the withheld minimum), borrow up to the LTV limit (and rejection beyond it), repay, interest accrual lifting the share value after time passes, interest that follows seconds rather than slots (and charges nothing for a timestamp behind the last accrual), and liquidation of an unhealthy position (with diff --git a/finance/lending/quasar/src/constants.rs b/finance/lending/quasar/src/constants.rs index 2d109352d..d2f86bd49 100644 --- a/finance/lending/quasar/src/constants.rs +++ b/finance/lending/quasar/src/constants.rs @@ -33,6 +33,22 @@ pub const TOKEN_ACCOUNT_SPACE: u64 = 165; /// SPL mint size, for the rent-exempt share mint created in `initialize_reserve`. pub const MINT_SPACE: u64 = 82; +/// Reserve shares withheld from a reserve's first deposit. The first supplier +/// receives `deposit - MINIMUM_SHARES` shares rather than the full amount, the +/// convention Uniswap V2 uses for LP tokens, and every conversion between +/// shares and liquidity counts these as shares that nobody holds +/// (`math::total_shares`), so their slice of the pool never leaves. +/// +/// Shares are priced off tracked `total_liquidity`, not the vault balance, so a +/// direct donation cannot move them. But `total_liquidity` counts interest owed +/// on borrows, and a supplier can also borrow: a lone supplier holding one +/// share can make it worth more than one unit through their own debt, then +/// ratchet the price up with deposits and redemptions whose rounding stays in +/// the pool, until a later deposit rounds down and they take part of it. With +/// the minimum counted, their one share is 1 of 1_001, and whatever the +/// rounding leaves behind is spread mostly across shares they cannot redeem. +pub const MINIMUM_SHARES: u64 = 1_000; + // PDA seeds for the `Seed::from(...)` signer arrays in the CPI-signing handlers. // (The `#[seeds(...)]` attributes on the account types carry their own literals.) pub const RESERVE_SEED: &[u8] = b"reserve"; diff --git a/finance/lending/quasar/src/instructions/position.rs b/finance/lending/quasar/src/instructions/position.rs index fae3cfc73..fc1ec491c 100644 --- a/finance/lending/quasar/src/instructions/position.rs +++ b/finance/lending/quasar/src/instructions/position.rs @@ -6,7 +6,7 @@ use { logic::{accrue, now, price_scaled, snapshot_obligation, snapshot_reserve, SCALE}, math::{ current_debt, market_value, mul_div_ceil, mul_div_floor, net_total_liquidity, - value_to_amount, Rounding, + total_shares, value_to_amount, Rounding, }, state::{ LendingMarket, Obligation, ObligationInner, ObligationVaultPda, PriceFeed, Reserve, @@ -198,7 +198,7 @@ impl BorrowObligationLiquidity { let collateral_liquidity = mul_div_floor( obligation.deposited_shares as u128, collateral_total, - (collateral.share_mint_supply as u128).max(1), + total_shares(collateral.share_mint_supply)?, )?; let collateral_value = market_value( u64::try_from(collateral_liquidity).map_err(|_| LendingError::MathOverflow)?, @@ -414,7 +414,7 @@ impl WithdrawObligationCollateral { let remaining_liquidity = mul_div_floor( remaining_shares as u128, collateral_total, - (collateral.share_mint_supply as u128).max(1), + total_shares(collateral.share_mint_supply)?, )?; let remaining_value = market_value( u64::try_from(remaining_liquidity).map_err(|_| LendingError::MathOverflow)?, @@ -561,7 +561,7 @@ impl LiquidateObligation { let collateral_liquidity = mul_div_floor( obligation.deposited_shares as u128, collateral_total, - (collateral.share_mint_supply as u128).max(1), + total_shares(collateral.share_mint_supply)?, )?; let collateral_value = market_value( u64::try_from(collateral_liquidity).map_err(|_| LendingError::MathOverflow)?, @@ -617,7 +617,7 @@ impl LiquidateObligation { )?; let seize_shares = mul_div_floor( seize_liquidity as u128, - collateral.share_mint_supply as u128, + total_shares(collateral.share_mint_supply)?, collateral_total.max(1), )?; let seize_shares = u64::try_from(seize_shares).map_err(|_| LendingError::MathOverflow)?; diff --git a/finance/lending/quasar/src/instructions/supply.rs b/finance/lending/quasar/src/instructions/supply.rs index 121eb9a35..56548504f 100644 --- a/finance/lending/quasar/src/instructions/supply.rs +++ b/finance/lending/quasar/src/instructions/supply.rs @@ -1,8 +1,9 @@ use { crate::{ + constants::MINIMUM_SHARES, error::LendingError, logic::{accrue, now, snapshot_reserve}, - math::{mul_div_floor, net_total_liquidity}, + math::{mul_div_floor, net_total_liquidity, total_shares}, state::Reserve, }, quasar_lang::{cpi::Seed, prelude::*}, @@ -64,10 +65,31 @@ impl DepositReserveLiquidity { reserve.borrow_accumulation_factor, reserve.accumulated_protocol_fees, )?; - let shares = if reserve.share_mint_supply == 0 { - amount as u128 + let shares = if reserve.share_mint_supply == 0 && total == 0 { + // Bootstrap: shares track liquidity one-for-one, less the withheld + // minimum, so the share supply can never start at a dust amount. + amount + .checked_sub(MINIMUM_SHARES) + .ok_or(LendingError::DepositTooSmall)? as u128 } else { - mul_div_floor(amount as u128, reserve.share_mint_supply as u128, total)? + // The withheld minimum counts as shares nobody holds, here and in + // every other conversion, so its slice of the pool is locked for + // good. That is what stops share inflation: a lone supplier who + // borrows from their own reserve can lift the total with the + // interest they owe, and deposits and redemptions that round in the + // pool's favour lift it further, until one share is worth enough to + // round a later deposit down. With the minimum counted, their one + // share is 1 of 1_001 and whatever they leave in the pool goes + // mostly to shares nobody redeems. + // + // A reserve whose suppliers have all left takes this branch too: + // the minimum's slice is still in the total, so the next deposit is + // priced against it rather than bootstrapped. + mul_div_floor( + amount as u128, + total_shares(reserve.share_mint_supply)?, + total, + )? }; require!(shares > 0, LendingError::DepositTooSmall); let shares = u64::try_from(shares).map_err(|_| LendingError::MathOverflow)?; @@ -156,7 +178,13 @@ impl RedeemReserveCollateral { reserve.borrow_accumulation_factor, reserve.accumulated_protocol_fees, )?; - let liquidity = mul_div_floor(shares as u128, total, reserve.share_mint_supply as u128)?; + // The withheld minimum counts as shares nobody holds, as it does in + // deposit_reserve_liquidity, so its slice of the pool never leaves. + let liquidity = mul_div_floor( + shares as u128, + total, + total_shares(reserve.share_mint_supply)?, + )?; let liquidity = u64::try_from(liquidity).map_err(|_| LendingError::MathOverflow)?; require!( liquidity <= reserve.available_liquidity, diff --git a/finance/lending/quasar/src/math.rs b/finance/lending/quasar/src/math.rs index f14c150d2..cd8550dc5 100644 --- a/finance/lending/quasar/src/math.rs +++ b/finance/lending/quasar/src/math.rs @@ -5,7 +5,10 @@ use quasar_lang::prelude::*; use crate::{ - constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, FIXED_POINT_SCALE_DECIMALS, SECONDS_PER_YEAR}, + constants::{ + BPS_DENOMINATOR, FIXED_POINT_SCALE, FIXED_POINT_SCALE_DECIMALS, MINIMUM_SHARES, + SECONDS_PER_YEAR, + }, error::LendingError, }; @@ -132,6 +135,15 @@ pub fn net_total_liquidity( .ok_or(LendingError::MathOverflow.into()) } +/// The share count every conversion between shares and liquidity divides by: +/// the outstanding supply plus the `MINIMUM_SHARES` withheld from the first +/// deposit, which belong to nobody and so never redeem. +pub fn total_shares(share_mint_supply: u64) -> Result { + (share_mint_supply as u128) + .checked_add(MINIMUM_SHARES as u128) + .ok_or(LendingError::MathOverflow.into()) +} + /// Borrowed fraction of the pool in basis points (0..=10_000). pub fn utilization_bps( available: u64, diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index 0c1b729c1..9568ff3e2 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -50,6 +50,12 @@ const BORROWER_BORROW: Pubkey = Pubkey::new_from_array([14; 32]); const LIQUIDATOR_BORROW: Pubkey = Pubkey::new_from_array([15; 32]); const LIQUIDATOR_COLLATERAL_SHARE: Pubkey = Pubkey::new_from_array([16; 32]); const OWNER_BORROW: Pubkey = Pubkey::new_from_array([17; 32]); +const OWNER_COLLATERAL: Pubkey = Pubkey::new_from_array([18; 32]); +const OWNER_COLLATERAL_SHARE: Pubkey = Pubkey::new_from_array([19; 32]); +const OWNER_BORROW_SHARE: Pubkey = Pubkey::new_from_array([20; 32]); +/// What the market owner deposits to open each reserve: the smallest first +/// deposit that clears the withheld minimum, minting the owner one share. +const OPENING_DEPOSIT: u64 = crate::constants::MINIMUM_SHARES + 1; // Per-owner market index this market is seeded from (owner's market 0). const MARKET_ID: u64 = 0; @@ -124,8 +130,20 @@ fn base_world(test: &mut Test) -> Pdas { test.add( TokenAccount::new(w.collateral_share_mint, LIQUIDATOR).at(LIQUIDATOR_COLLATERAL_SHARE), ); - // Where the market owner receives collected protocol fees. - test.add(TokenAccount::new(BORROW_MINT, OWNER).at(OWNER_BORROW)); + // The owner's opening deposits come from these; once they are made, + // `OWNER_BORROW` is empty again and receives collected protocol fees. + test.add( + TokenAccount::new(BORROW_MINT, OWNER) + .at(OWNER_BORROW) + .amount(OPENING_DEPOSIT), + ); + test.add(TokenAccount::new(w.borrow_share_mint, OWNER).at(OWNER_BORROW_SHARE)); + test.add( + TokenAccount::new(COLLATERAL_MINT, OWNER) + .at(OWNER_COLLATERAL) + .amount(OPENING_DEPOSIT), + ); + test.add(TokenAccount::new(w.collateral_share_mint, OWNER).at(OWNER_COLLATERAL_SHARE)); w } @@ -160,7 +178,38 @@ fn initialize_reserve(test: &mut Test, w: &Pdas, the_mint: Pubkey) { .succeeds(); } +/// Create the market and both reserves, then open each reserve with the +/// owner's deposit so the withheld minimum is in place and later deposits mint +/// shares one-for-one until interest accrues. fn setup_markets(test: &mut Test, w: &Pdas) { + setup_empty_markets(test, w); + test.send(DepositReserveLiquidityInstruction { + supplier: OWNER, + reserve: w.collateral_reserve, + liquidity_mint: COLLATERAL_MINT, + liquidity_vault: w.collateral_vault, + share_mint: w.collateral_share_mint, + supplier_liquidity: OWNER_COLLATERAL, + supplier_share: OWNER_COLLATERAL_SHARE, + amount: OPENING_DEPOSIT, + }) + .succeeds(); + test.send(DepositReserveLiquidityInstruction { + supplier: OWNER, + reserve: w.borrow_reserve, + liquidity_mint: BORROW_MINT, + liquidity_vault: w.borrow_vault, + share_mint: w.borrow_share_mint, + supplier_liquidity: OWNER_BORROW, + supplier_share: OWNER_BORROW_SHARE, + amount: OPENING_DEPOSIT, + }) + .succeeds(); +} + +/// Create the market and both reserves with no deposits, for tests of the +/// first deposit itself. +fn setup_empty_markets(test: &mut Test, w: &Pdas) { test.send(InitializeLendingMarketInstruction { owner: OWNER, quote_mint: QUOTE_MINT, @@ -296,6 +345,38 @@ fn supply_mints_shares_one_to_one_and_redeem_returns_liquidity(test: &mut Test) .has_tokens(SUPPLIER_BORROW_SHARE, 0); } +/// The first deposit into a reserve mints one share per unit, less the +/// `MINIMUM_SHARES` withheld. Those shares belong to nobody, so even as the +/// only supplier the depositor gets back their deposit less the minimum, and +/// the minimum's liquidity stays in the pool. +#[quasar_test] +fn first_deposit_withholds_the_minimum(test: &mut Test) { + let w = base_world(test); + setup_empty_markets(test, &w); + let minimum = crate::constants::MINIMUM_SHARES; + + deposit_borrow_side(test, &w, 1_000 * UNIT) + .succeeds() + .has_tokens(SUPPLIER_BORROW_SHARE, 1_000 * UNIT - minimum); + + redeem(test, &w, 1_000 * UNIT - minimum) + .succeeds() + .has_tokens(SUPPLIER_BORROW, 1_000 * UNIT - minimum) + .has_tokens(SUPPLIER_BORROW_SHARE, 0); +} + +/// A first deposit no larger than the minimum would mint nothing, so it is +/// refused. +#[quasar_test] +fn first_deposit_must_exceed_the_minimum(test: &mut Test) { + let w = base_world(test); + setup_empty_markets(test, &w); + assert!( + deposit_borrow_side(test, &w, crate::constants::MINIMUM_SHARES).is_err(), + "a first deposit of only the minimum mints nothing and must be rejected" + ); +} + #[quasar_test] fn borrow_up_to_ltv_succeeds_and_beyond_fails(test: &mut Test) { let w = base_world(test); @@ -362,7 +443,8 @@ mod clock_warp { super::{dollars, EXP, TENTH_OF_A_YEAR}, super::{ BORROWER, BORROWER_BORROW, BORROWER_COLLATERAL, BORROWER_COLLATERAL_SHARE, BORROW_MINT, - COLLATERAL_MINT, DECIMALS, MARKET_ID, OWNER, OWNER_BORROW, QUOTE_MINT, SUPPLIER, + COLLATERAL_MINT, DECIMALS, MARKET_ID, OPENING_DEPOSIT, OWNER, OWNER_BORROW, + OWNER_BORROW_SHARE, OWNER_COLLATERAL, OWNER_COLLATERAL_SHARE, QUOTE_MINT, SUPPLIER, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, UNIT, }, crate::{ @@ -506,8 +588,13 @@ mod clock_warp { 0, ), token(BORROWER_BORROW, BORROW_MINT, BORROWER, 0), - // Where the market owner receives collected protocol fees. - token(OWNER_BORROW, BORROW_MINT, OWNER, 0), + // The owner's opening deposits come from these; once they are + // made, `OWNER_BORROW` is empty again and receives collected + // protocol fees. + token(OWNER_BORROW, BORROW_MINT, OWNER, OPENING_DEPOSIT), + token(OWNER_BORROW_SHARE, borrow_share_mint, OWNER, 0), + token(OWNER_COLLATERAL, COLLATERAL_MINT, OWNER, OPENING_DEPOSIT), + token(OWNER_COLLATERAL_SHARE, collateral_share_mint, OWNER, 0), ] { svm.set_account(account); } @@ -656,6 +743,30 @@ mod clock_warp { self.borrow_share_mint, self.borrow_price, ); + // Open each reserve with the owner's deposit, as the quasar-test + // harness does. + self.deposit( + OWNER, + self.collateral_reserve, + COLLATERAL_MINT, + self.collateral_vault, + self.collateral_share_mint, + OWNER_COLLATERAL, + OWNER_COLLATERAL_SHARE, + OPENING_DEPOSIT, + ) + .assert_success(); + self.deposit( + OWNER, + self.borrow_reserve, + BORROW_MINT, + self.borrow_vault, + self.borrow_share_mint, + OWNER_BORROW, + OWNER_BORROW_SHARE, + OPENING_DEPOSIT, + ) + .assert_success(); } #[allow(clippy::too_many_arguments)]