diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index c9653b69338..d86a858857d 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -329,7 +329,8 @@ fn process_init_reserve( validate_pyth_keys(&lending_market, pyth_product_info, pyth_price_info)?; validate_switchboard_keys(&lending_market, switchboard_feed_info)?; - let market_price = get_price(Some(switchboard_feed_info), pyth_price_info, clock)?; + let (market_price, smoothed_market_price) = + get_price(Some(switchboard_feed_info), pyth_price_info, clock)?; let authority_signer_seeds = &[ lending_market_info.key.as_ref(), @@ -360,6 +361,7 @@ fn process_init_reserve( pyth_oracle_pubkey: *pyth_price_info.key, switchboard_oracle_pubkey: *switchboard_feed_info.key, market_price, + smoothed_market_price: smoothed_market_price.unwrap_or(market_price), }), collateral: ReserveCollateral::new(NewReserveCollateralParams { mint_pubkey: *reserve_collateral_mint_info.key, @@ -482,7 +484,21 @@ fn _refresh_reserve<'a>( return Err(LendingError::InvalidOracleConfig.into()); } - reserve.liquidity.market_price = get_price(switchboard_feed_info, pyth_price_info, clock)?; + let (market_price, smoothed_market_price) = + get_price(switchboard_feed_info, pyth_price_info, clock)?; + + reserve.liquidity.market_price = market_price; + + if let Some(smoothed_market_price) = smoothed_market_price { + reserve.liquidity.smoothed_market_price = smoothed_market_price; + } + + // currently there's no way to support two prices without a pyth oracle. So if a reserve + // only supports switchboard, reserve.smoothed_market_price == reserve.market_price + if reserve.liquidity.pyth_oracle_pubkey == solend_program::NULL_PUBKEY { + reserve.liquidity.smoothed_market_price = market_price; + } + Reserve::pack(reserve, &mut reserve_info.data.borrow_mut())?; _refresh_reserve_interest(program_id, reserve_info, clock) @@ -881,6 +897,7 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> let mut deposited_value = Decimal::zero(); let mut borrowed_value = Decimal::zero(); + let mut borrowed_value_upper_bound = Decimal::zero(); let mut allowed_borrow_value = Decimal::zero(); let mut unhealthy_borrow_value = Decimal::zero(); @@ -910,25 +927,22 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> return Err(LendingError::ReserveStale.into()); } - // @TODO: add lookup table https://git.io/JOCYq - let decimals = 10u64 - .checked_pow(deposit_reserve.liquidity.mint_decimals as u32) - .ok_or(LendingError::MathOverflow)?; - - let market_value = deposit_reserve + let liquidity_amount = deposit_reserve .collateral_exchange_rate()? - .decimal_collateral_to_liquidity(collateral.deposited_amount.into())? - .try_mul(deposit_reserve.liquidity.market_price)? - .try_div(decimals)?; - collateral.market_value = market_value; + .decimal_collateral_to_liquidity(collateral.deposited_amount.into())?; + + let market_value = deposit_reserve.market_value(liquidity_amount)?; + let market_value_lower_bound = + deposit_reserve.market_value_lower_bound(liquidity_amount)?; let loan_to_value_rate = Rate::from_percent(deposit_reserve.config.loan_to_value_ratio); let liquidation_threshold_rate = Rate::from_percent(deposit_reserve.config.liquidation_threshold); + collateral.market_value = market_value; deposited_value = deposited_value.try_add(market_value)?; allowed_borrow_value = - allowed_borrow_value.try_add(market_value.try_mul(loan_to_value_rate)?)?; + allowed_borrow_value.try_add(market_value_lower_bound.try_mul(loan_to_value_rate)?)?; unhealthy_borrow_value = unhealthy_borrow_value.try_add(market_value.try_mul(liquidation_threshold_rate)?)?; } @@ -962,10 +976,14 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> liquidity.accrue_interest(borrow_reserve.liquidity.cumulative_borrow_rate_wads)?; let market_value = borrow_reserve.market_value(liquidity.borrowed_amount_wads)?; + let market_value_upper_bound = + borrow_reserve.market_value_upper_bound(liquidity.borrowed_amount_wads)?; liquidity.market_value = market_value; borrowed_value = borrowed_value.try_add(market_value.try_mul(borrow_reserve.borrow_weight())?)?; + borrowed_value_upper_bound = borrowed_value_upper_bound + .try_add(market_value_upper_bound.try_mul(borrow_reserve.borrow_weight())?)?; } if account_info_iter.peek().is_some() { @@ -975,6 +993,7 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> obligation.deposited_value = deposited_value; obligation.borrowed_value = borrowed_value; + obligation.borrowed_value_upper_bound = borrowed_value_upper_bound; let global_unhealthy_borrow_value = Decimal::from(70000000u64); let global_allowed_borrow_value = Decimal::from(65000000u64); @@ -1310,49 +1329,14 @@ fn _withdraw_obligation_collateral<'a>( return Err(LendingError::InvalidMarketAuthority.into()); } - let withdraw_amount = if obligation.borrows.is_empty() { - if collateral_amount == u64::MAX { - collateral.deposited_amount - } else { - collateral.deposited_amount.min(collateral_amount) - } - } else if obligation.deposited_value == Decimal::zero() { - msg!("Obligation deposited value is zero"); - return Err(LendingError::ObligationDepositsZero.into()); - } else { - let max_withdraw_value = obligation.max_withdraw_value(Rate::from_percent( - withdraw_reserve.config.loan_to_value_ratio, - ))?; + let max_withdraw_amount = obligation.max_withdraw_amount(collateral, &withdraw_reserve)?; - if max_withdraw_value == Decimal::zero() { - msg!("Maximum withdraw value is zero"); - return Err(LendingError::WithdrawTooLarge.into()); - } + if max_withdraw_amount == 0 { + msg!("Maximum withdraw value is zero"); + return Err(LendingError::WithdrawTooLarge.into()); + } - let withdraw_amount = if collateral_amount == u64::MAX { - let withdraw_value = max_withdraw_value.min(collateral.market_value); - let withdraw_pct = withdraw_value.try_div(collateral.market_value)?; - withdraw_pct - .try_mul(collateral.deposited_amount)? - .try_floor_u64()? - .min(collateral.deposited_amount) - } else { - let withdraw_amount = collateral_amount.min(collateral.deposited_amount); - let withdraw_pct = - Decimal::from(withdraw_amount).try_div(collateral.deposited_amount)?; - let withdraw_value = collateral.market_value.try_mul(withdraw_pct)?; - if withdraw_value > max_withdraw_value { - msg!("Withdraw value cannot exceed maximum withdraw value"); - return Err(LendingError::WithdrawTooLarge.into()); - } - withdraw_amount - }; - if withdraw_amount == 0 { - msg!("Withdraw amount is too small to transfer collateral"); - return Err(LendingError::WithdrawTooSmall.into()); - } - withdraw_amount - }; + let withdraw_amount = std::cmp::min(collateral_amount, max_withdraw_amount); obligation.withdraw(withdraw_amount, collateral_index)?; obligation.last_update.mark_stale(); @@ -1486,7 +1470,9 @@ fn process_borrow_obligation_liquidity( return Err(LendingError::InvalidMarketAuthority.into()); } - let remaining_borrow_value = obligation.remaining_borrow_value()?; + let remaining_borrow_value = obligation + .remaining_borrow_value() + .unwrap_or_else(|_| Decimal::zero()); if remaining_borrow_value == Decimal::zero() { msg!("Remaining borrow value is zero"); return Err(LendingError::BorrowTooLarge.into()); @@ -2606,19 +2592,26 @@ fn get_pyth_product_quote_currency( }) } +/// get_price tries to load the oracle price from pyth, and if it fails, uses switchboard. +/// The first element in the returned tuple is the market price, and the second is the optional +/// smoothed price (eg ema, twap). fn get_price( switchboard_feed_info: Option<&AccountInfo>, pyth_price_account_info: &AccountInfo, clock: &Clock, -) -> Result { - let pyth_price = get_pyth_price(pyth_price_account_info, clock).unwrap_or_default(); - if pyth_price != Decimal::zero() { - return Ok(pyth_price); +) -> Result<(Decimal, Option), ProgramError> { + if let Ok(prices) = get_pyth_price(pyth_price_account_info, clock) { + return Ok((prices.0, Some(prices.1))); } // if switchboard was not passed in don't try to grab the price if let Some(switchboard_feed_info_unwrapped) = switchboard_feed_info { - return get_switchboard_price(switchboard_feed_info_unwrapped, clock); + // TODO: add support for switchboard smoothed prices. Probably need to add a new + // switchboard account per reserve. + return match get_switchboard_price(switchboard_feed_info_unwrapped, clock) { + Ok(price) => Ok((price, None)), + Err(e) => Err(e), + }; } Err(LendingError::InvalidOracleConfig.into()) diff --git a/token-lending/program/tests/helpers/mock_pyth.rs b/token-lending/program/tests/helpers/mock_pyth.rs index 9a0d680e195..0deac090b4a 100644 --- a/token-lending/program/tests/helpers/mock_pyth.rs +++ b/token-lending/program/tests/helpers/mock_pyth.rs @@ -1,6 +1,6 @@ use pyth_sdk_solana::state::{ - AccountType, PriceAccount, PriceStatus, ProductAccount, MAGIC, PROD_ACCT_SIZE, PROD_ATTR_SIZE, - VERSION_2, + AccountType, PriceAccount, PriceStatus, ProductAccount, Rational, MAGIC, PROD_ACCT_SIZE, + PROD_ATTR_SIZE, VERSION_2, }; /// mock oracle prices in tests with this program. use solana_program::{ @@ -34,7 +34,13 @@ pub enum MockPythInstruction { /// Accounts: /// 0: PriceAccount - SetPrice { price: i64, conf: u64, expo: i32 }, + SetPrice { + price: i64, + conf: u64, + expo: i32, + ema_price: i64, + ema_conf: u64, + }, /// Accounts: /// 0: AggregatorAccount @@ -111,7 +117,13 @@ impl Processor { Ok(()) } - MockPythInstruction::SetPrice { price, conf, expo } => { + MockPythInstruction::SetPrice { + price, + conf, + expo, + ema_price, + ema_conf, + } => { msg!("Mock Pyth: Set price"); let price_account_info = next_account_info(account_info_iter)?; let data = &mut price_account_info.try_borrow_mut_data()?; @@ -121,6 +133,19 @@ impl Processor { price_account.agg.conf = conf; price_account.expo = expo; + price_account.ema_price = Rational { + val: ema_price, + // these fields don't matter + numer: 1, + denom: 1, + }; + + price_account.ema_conf = Rational { + val: ema_conf as i64, + numer: 1, + denom: 1, + }; + price_account.last_slot = Clock::get()?.slot; price_account.agg.pub_slot = Clock::get()?.slot; price_account.agg.status = PriceStatus::Trading; @@ -201,10 +226,18 @@ pub fn set_price( price: i64, conf: u64, expo: i32, + ema_price: i64, + ema_conf: u64, ) -> Instruction { - let data = MockPythInstruction::SetPrice { price, conf, expo } - .try_to_vec() - .unwrap(); + let data = MockPythInstruction::SetPrice { + price, + conf, + expo, + ema_price, + ema_conf, + } + .try_to_vec() + .unwrap(); Instruction { program_id, accounts: vec![AccountMeta::new(price_account_pubkey, false)], diff --git a/token-lending/program/tests/helpers/mod.rs b/token-lending/program/tests/helpers/mod.rs index e839e302a0f..821e32dc32c 100644 --- a/token-lending/program/tests/helpers/mod.rs +++ b/token-lending/program/tests/helpers/mod.rs @@ -61,6 +61,10 @@ pub mod usdc_mint { solana_program::declare_id!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); } +pub mod usdt_mint { + solana_program::declare_id!("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"); +} + pub mod wsol_mint { // fake mint, not the real wsol bc i can't mint wsol programmatically solana_program::declare_id!("So1m5eppzgokXLBt9Cg8KCMPWhHfTzVaGh26Y415MRG"); diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index ad0d3461714..4b92ff95fc8 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -88,6 +88,7 @@ impl SolendProgramTest { let authority = Keypair::new(); add_mint(&mut test, usdc_mint::id(), 6, authority.pubkey()); + add_mint(&mut test, usdt_mint::id(), 6, authority.pubkey()); add_mint(&mut test, wsol_mint::id(), 9, authority.pubkey()); let mut context = test.start_with_context().await; @@ -97,7 +98,11 @@ impl SolendProgramTest { context, rent, authority, - mints: HashMap::from([(usdc_mint::id(), None), (wsol_mint::id(), None)]), + mints: HashMap::from([ + (usdc_mint::id(), None), + (wsol_mint::id(), None), + (usdt_mint::id(), None), + ]), } } @@ -360,7 +365,7 @@ impl SolendProgramTest { ); } - pub async fn set_price(&mut self, mint: &Pubkey, price: PriceArgs) { + pub async fn set_price(&mut self, mint: &Pubkey, price: &PriceArgs) { let oracle = self.mints.get(mint).unwrap().unwrap(); self.process_transaction( &[set_price( @@ -369,6 +374,8 @@ impl SolendProgramTest { price.price, price.conf, price.expo, + price.ema_price, + price.ema_conf, )], None, ) @@ -376,7 +383,7 @@ impl SolendProgramTest { .unwrap(); } - pub async fn init_switchboard_feed(&mut self, mint: &Pubkey) { + pub async fn init_switchboard_feed(&mut self, mint: &Pubkey) -> Pubkey { let switchboard_feed_pubkey = self .create_account( std::mem::size_of::() + 8, @@ -398,12 +405,13 @@ impl SolendProgramTest { let oracle = self.mints.get_mut(mint).unwrap(); if let Some(ref mut oracle) = oracle { oracle.switchboard_feed_pubkey = Some(switchboard_feed_pubkey); + switchboard_feed_pubkey } else { panic!("oracle not initialized"); } } - pub async fn set_switchboard_price(&mut self, mint: &Pubkey, price: PriceArgs) { + pub async fn set_switchboard_price(&mut self, mint: &Pubkey, price: SwitchboardPriceArgs) { let oracle = self.mints.get(mint).unwrap().unwrap(); self.process_transaction( &[set_switchboard_price( @@ -568,7 +576,7 @@ impl User { account } - Some(_) => panic!("Token account already exists!"), + Some(t) => t.clone(), } } @@ -599,6 +607,13 @@ pub struct PriceArgs { pub price: i64, pub conf: u64, pub expo: i32, + pub ema_price: i64, + pub ema_conf: u64, +} + +pub struct SwitchboardPriceArgs { + pub price: i64, + pub expo: i32, } impl Info { @@ -642,7 +657,6 @@ impl Info { .unwrap() .unwrap(); let oracle = oracle.unwrap_or(&default_oracle); - println!("{:?}", oracle); let instructions = [update_reserve_config( solend_program::id(), @@ -1293,10 +1307,12 @@ pub async fn setup_world( test.init_pyth_feed(&usdc_mint::id()).await; test.set_price( &usdc_mint::id(), - PriceArgs { + &PriceArgs { price: 1, conf: 0, expo: 0, + ema_price: 1, + ema_conf: 0, }, ) .await; @@ -1304,10 +1320,12 @@ pub async fn setup_world( test.init_pyth_feed(&wsol_mint::id()).await; test.set_price( &wsol_mint::id(), - PriceArgs { + &PriceArgs { price: 10, conf: 0, expo: 0, + ema_price: 10, + ema_conf: 0, }, ) .await; @@ -1474,3 +1492,148 @@ pub async fn scenario_1( obligation, ) } + +pub struct ReserveArgs { + pub mint: Pubkey, + pub config: ReserveConfig, + pub liquidity_amount: u64, + pub price: PriceArgs, +} + +pub struct ObligationArgs { + pub deposits: Vec<(Pubkey, u64)>, + pub borrows: Vec<(Pubkey, u64)>, +} + +pub async fn custom_scenario( + reserve_args: &[ReserveArgs], + obligation_args: &ObligationArgs, +) -> ( + SolendProgramTest, + Info, + Vec>, + Info, + User, +) { + let mut test = SolendProgramTest::start_new().await; + let mints_and_liquidity_amounts = reserve_args + .iter() + .map(|reserve_arg| (&reserve_arg.mint, reserve_arg.liquidity_amount)) + .collect::>(); + + let lending_market_owner = + User::new_with_balances(&mut test, &mints_and_liquidity_amounts).await; + + let lending_market = test + .init_lending_market(&lending_market_owner, &Keypair::new()) + .await + .unwrap(); + + let deposits_and_balances = obligation_args + .deposits + .iter() + .map(|(mint, amount)| (mint, *amount)) + .collect::>(); + + let mut obligation_owner = User::new_with_balances(&mut test, &deposits_and_balances).await; + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &obligation_owner) + .await + .unwrap(); + + test.advance_clock_by_slots(999).await; + + let mut reserves = Vec::new(); + for reserve_arg in reserve_args { + test.init_pyth_feed(&reserve_arg.mint).await; + + test.set_price(&reserve_arg.mint, &reserve_arg.price).await; + + let reserve = test + .init_reserve( + &lending_market, + &lending_market_owner, + &reserve_arg.mint, + &reserve_arg.config, + &Keypair::new(), + reserve_arg.liquidity_amount, + None, + ) + .await + .unwrap(); + + let user = User::new_with_balances( + &mut test, + &[ + (&reserve_arg.mint, reserve_arg.liquidity_amount), + (&reserve.account.collateral.mint_pubkey, 0), + ], + ) + .await; + + lending_market + .deposit(&mut test, &reserve, &user, reserve_arg.liquidity_amount) + .await + .unwrap(); + + obligation_owner + .create_token_account(&reserve_arg.mint, &mut test) + .await; + + reserves.push(reserve); + } + + for (mint, amount) in obligation_args.deposits.iter() { + let reserve = reserves + .iter() + .find(|reserve| reserve.account.liquidity.mint_pubkey == *mint) + .unwrap(); + + obligation_owner + .create_token_account(&reserve.account.collateral.mint_pubkey, &mut test) + .await; + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + reserve, + &obligation, + &obligation_owner, + *amount, + ) + .await + .unwrap(); + } + + for (mint, amount) in obligation_args.borrows.iter() { + let reserve = reserves + .iter() + .find(|reserve| reserve.account.liquidity.mint_pubkey == *mint) + .unwrap(); + + obligation_owner.create_token_account(mint, &mut test).await; + let fee_receiver = User::new_with_balances(&mut test, &[(mint, 0)]).await; + + lending_market + .borrow_obligation_liquidity( + &mut test, + reserve, + &obligation, + &obligation_owner, + &fee_receiver.get_account(mint).unwrap(), + *amount, + ) + .await + .unwrap(); + } + + (test, lending_market, reserves, obligation, obligation_owner) +} + +pub fn find_reserve(reserves: &[Info], mint: &Pubkey) -> Option> { + reserves + .iter() + .find(|reserve| reserve.account.liquidity.mint_pubkey == *mint) + .cloned() +} diff --git a/token-lending/program/tests/init_obligation.rs b/token-lending/program/tests/init_obligation.rs index ad102bf1a7a..d7964010e80 100644 --- a/token-lending/program/tests/init_obligation.rs +++ b/token-lending/program/tests/init_obligation.rs @@ -45,6 +45,7 @@ async fn test_success() { borrows: Vec::new(), deposited_value: Decimal::zero(), borrowed_value: Decimal::zero(), + borrowed_value_upper_bound: Decimal::zero(), allowed_borrow_value: Decimal::zero(), unhealthy_borrow_value: Decimal::zero() } diff --git a/token-lending/program/tests/init_reserve.rs b/token-lending/program/tests/init_reserve.rs index 88f98e51f6a..d3bd170f97c 100644 --- a/token-lending/program/tests/init_reserve.rs +++ b/token-lending/program/tests/init_reserve.rs @@ -169,6 +169,7 @@ async fn test_success() { cumulative_borrow_rate_wads: Decimal::one(), accumulated_protocol_fees_wads: Decimal::zero(), market_price: Decimal::from(10u64), + smoothed_market_price: Decimal::from(10u64), }, collateral: ReserveCollateral { mint_pubkey: reserve_collateral_mint_pubkey, diff --git a/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs b/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs index 4f3dc983170..337bae32c3d 100644 --- a/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs +++ b/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs @@ -63,10 +63,12 @@ async fn test_success_new() { // obligation gets liquidated if 100k * 0.55 = 10 SOL * sol_price => sol_price = 5.5k test.set_price( &wsol_mint::id(), - PriceArgs { + &PriceArgs { price: 5500, conf: 0, expo: 0, + ema_price: 5500, + ema_conf: 0, }, ) .await; @@ -179,6 +181,7 @@ async fn test_success_new() { .try_sub(Decimal::from(expected_borrow_repaid * LAMPORTS_TO_SOL)) .unwrap(), market_price: Decimal::from(5500u64), + smoothed_market_price: Decimal::from(5500u64), ..wsol_reserve.account.liquidity }, ..wsol_reserve.account @@ -210,6 +213,7 @@ async fn test_success_new() { .to_vec(), deposited_value: Decimal::from(100_000u64), borrowed_value: Decimal::from(55_000u64), + borrowed_value_upper_bound: Decimal::from(55_000u64), allowed_borrow_value: Decimal::from(50_000u64), unhealthy_borrow_value: Decimal::from(55_000u64), ..obligation.account @@ -285,10 +289,12 @@ async fn test_success_insufficient_liquidity() { // obligation gets liquidated if 100k * 0.55 = 10 SOL * sol_price => sol_price == 5.5k test.set_price( &wsol_mint::id(), - PriceArgs { + &PriceArgs { price: 5500, conf: 0, expo: 0, + ema_price: 5500, + ema_conf: 0, }, ) .await; diff --git a/token-lending/program/tests/redeem_fees.rs b/token-lending/program/tests/redeem_fees.rs index adcd98330ec..ff3b441b5ec 100644 --- a/token-lending/program/tests/redeem_fees.rs +++ b/token-lending/program/tests/redeem_fees.rs @@ -34,10 +34,12 @@ async fn test_success() { test.set_price( &wsol_mint::id(), - PriceArgs { + &PriceArgs { price: 10, expo: 0, conf: 0, + ema_price: 10, + ema_conf: 0, }, ) .await; diff --git a/token-lending/program/tests/refresh_obligation.rs b/token-lending/program/tests/refresh_obligation.rs index e057a08558c..3eda662d984 100644 --- a/token-lending/program/tests/refresh_obligation.rs +++ b/token-lending/program/tests/refresh_obligation.rs @@ -2,6 +2,7 @@ mod helpers; +use crate::solend_program_test::PriceArgs; use std::collections::HashSet; use helpers::solend_program_test::{setup_world, BalanceChecker, Info, SolendProgramTest, User}; @@ -135,6 +136,30 @@ async fn setup() -> ( async fn test_success() { let (mut test, lending_market, usdc_reserve, wsol_reserve, user, obligation) = setup().await; + test.set_price( + &usdc_mint::id(), + &PriceArgs { + price: 10, + conf: 1, + expo: -1, + ema_price: 9, + ema_conf: 1, + }, + ) + .await; + + test.set_price( + &wsol_mint::id(), + &PriceArgs { + price: 10, + conf: 1, + expo: 0, + ema_price: 11, + ema_conf: 1, + }, + ) + .await; + test.advance_clock_by_slots(1).await; let balance_checker = @@ -165,6 +190,10 @@ async fn test_success() { slot: 1001, stale: false }, + liquidity: ReserveLiquidity { + smoothed_market_price: Decimal::from_percent(90), + ..usdc_reserve.account.liquidity + }, ..usdc_reserve.account } ); @@ -194,6 +223,7 @@ async fn test_success() { available_amount: 0, borrowed_amount_wads: new_borrowed_amount_wads, cumulative_borrow_rate_wads: new_cumulative_borrow_rate, + smoothed_market_price: Decimal::from(11u64), ..wsol_reserve.account.liquidity }, ..wsol_reserve.account @@ -221,7 +251,29 @@ async fn test_success() { market_value: new_borrow_value }] .to_vec(), - borrowed_value: new_borrow_value, + + borrowed_value: new_borrowed_amount_wads + .try_mul(Decimal::from(10u64)) + .unwrap() + .try_div(Decimal::from(LAMPORTS_PER_SOL)) + .unwrap(), + + // uses max(10, 11) = 11 for sol price + borrowed_value_upper_bound: new_borrowed_amount_wads + .try_mul(Decimal::from(11u64)) + .unwrap() + .try_div(Decimal::from(LAMPORTS_PER_SOL)) + .unwrap(), + + // uses min(1, 0.9) for usdc price + allowed_borrow_value: Decimal::from(100_000u64) + .try_mul(Decimal::from_percent( + usdc_reserve.account.config.loan_to_value_ratio + )) + .unwrap() + .try_mul(Decimal::from_percent(90)) + .unwrap(), + ..obligation.account } ); diff --git a/token-lending/program/tests/refresh_reserve.rs b/token-lending/program/tests/refresh_reserve.rs index 67c1751a0b1..0824d1ce48b 100644 --- a/token-lending/program/tests/refresh_reserve.rs +++ b/token-lending/program/tests/refresh_reserve.rs @@ -5,8 +5,10 @@ mod helpers; use crate::solend_program_test::setup_world; use crate::solend_program_test::BalanceChecker; use crate::solend_program_test::Info; +use crate::solend_program_test::Oracle; use crate::solend_program_test::PriceArgs; use crate::solend_program_test::SolendProgramTest; +use crate::solend_program_test::SwitchboardPriceArgs; use crate::solend_program_test::User; use helpers::*; use solana_program::instruction::InstructionError; @@ -20,6 +22,7 @@ use solend_program::state::Reserve; use solend_program::state::ReserveConfig; use solend_program::state::ReserveFees; use solend_program::state::ReserveLiquidity; +use solend_program::NULL_PUBKEY; use solend_program::{ error::LendingError, math::{Decimal, Rate, TryAdd, TryDiv, TryMul, TrySub}, @@ -148,6 +151,18 @@ async fn test_success() { // should be maxed out at 30% let borrow_rate = wsol_reserve.account.current_borrow_rate().unwrap(); + test.set_price( + &wsol_mint::id(), + &PriceArgs { + price: 20, + conf: 1, + expo: 1, + ema_price: 15, + ema_conf: 1, + }, + ) + .await; + test.advance_clock_by_slots(1).await; let balance_checker = BalanceChecker::start(&mut test, &[&wsol_reserve]).await; @@ -187,6 +202,8 @@ async fn test_success() { borrowed_amount_wads: compound_borrow, cumulative_borrow_rate_wads: compound_rate.into(), accumulated_protocol_fees_wads: delta_accumulated_protocol_fees, + market_price: Decimal::from(200u64), + smoothed_market_price: Decimal::from(150u64), ..wsol_reserve.account.liquidity }, ..wsol_reserve.account @@ -220,20 +237,86 @@ async fn test_fail_pyth_price_stale() { async fn test_success_pyth_price_stale_switchboard_valid() { let (mut test, lending_market, _, wsol_reserve, lending_market_owner, _) = setup().await; + test.set_price( + &wsol_mint::id(), + &PriceArgs { + price: 9, + conf: 0, + expo: 0, + ema_price: 11, + ema_conf: 0, + }, + ) + .await; + test.advance_clock_by_slots(1).await; + + lending_market + .refresh_reserve(&mut test, &wsol_reserve) + .await + .unwrap(); + test.advance_clock_by_slots(241).await; test.init_switchboard_feed(&wsol_mint::id()).await; - test.set_switchboard_price( + test.set_switchboard_price(&wsol_mint::id(), SwitchboardPriceArgs { price: 8, expo: 0 }) + .await; + + // update reserve so the switchboard feed is not NULL_PUBKEY + lending_market + .update_reserve_config( + &mut test, + &lending_market_owner, + &wsol_reserve, + wsol_reserve.account.config, + wsol_reserve.account.rate_limiter.config, + None, + ) + .await + .unwrap(); + + let wsol_reserve = test.load_account::(wsol_reserve.pubkey).await; + lending_market + .refresh_reserve(&mut test, &wsol_reserve) + .await + .unwrap(); + + let wsol_reserve_post = test.load_account::(wsol_reserve.pubkey).await; + + // overwrite liquidity market price with the switchboard price but keep the pyth ema price + assert_eq!( + wsol_reserve_post.account.liquidity.market_price, + Decimal::from(8u64) + ); + assert_eq!( + wsol_reserve_post.account.liquidity.smoothed_market_price, + Decimal::from(11u64) + ); +} + +#[tokio::test] +async fn test_success_only_switchboard_reserve() { + let (mut test, lending_market, _, wsol_reserve, lending_market_owner, _) = setup().await; + + test.set_price( &wsol_mint::id(), - PriceArgs { + &PriceArgs { price: 10, - expo: 0, conf: 0, + expo: 0, + ema_price: 11, + ema_conf: 0, }, ) .await; - // update reserve so the switchboard feed is not NULL_PUBKEY + test.advance_clock_by_slots(1).await; + + let feed = test.init_switchboard_feed(&wsol_mint::id()).await; + test.set_switchboard_price(&wsol_mint::id(), SwitchboardPriceArgs { price: 8, expo: 0 }) + .await; + + test.advance_clock_by_slots(1).await; + lending_market .update_reserve_config( &mut test, @@ -241,14 +324,32 @@ async fn test_success_pyth_price_stale_switchboard_valid() { &wsol_reserve, wsol_reserve.account.config, wsol_reserve.account.rate_limiter.config, - None, + Some(&Oracle { + pyth_price_pubkey: NULL_PUBKEY, + pyth_product_pubkey: NULL_PUBKEY, + switchboard_feed_pubkey: Some(feed), + }), ) .await .unwrap(); + test.advance_clock_by_slots(1).await; + let wsol_reserve = test.load_account::(wsol_reserve.pubkey).await; lending_market .refresh_reserve(&mut test, &wsol_reserve) .await - .unwrap() + .unwrap(); + + let wsol_reserve_post = test.load_account::(wsol_reserve.pubkey).await; + + // when pyth is null and only switchboard exists, both price fields get overwritten + assert_eq!( + wsol_reserve_post.account.liquidity.market_price, + Decimal::from(8u64) + ); + assert_eq!( + wsol_reserve_post.account.liquidity.smoothed_market_price, + Decimal::from(8u64) + ); } diff --git a/token-lending/program/tests/two_prices.rs b/token-lending/program/tests/two_prices.rs new file mode 100644 index 00000000000..963973bc57f --- /dev/null +++ b/token-lending/program/tests/two_prices.rs @@ -0,0 +1,485 @@ +#![cfg(feature = "test-bpf")] + +use crate::solend_program_test::custom_scenario; +use crate::solend_program_test::find_reserve; +use crate::solend_program_test::User; + +use crate::solend_program_test::BalanceChecker; +use crate::solend_program_test::ObligationArgs; +use crate::solend_program_test::PriceArgs; +use crate::solend_program_test::ReserveArgs; +use crate::solend_program_test::TokenBalanceChange; +use solana_program::native_token::LAMPORTS_PER_SOL; +use solana_sdk::instruction::InstructionError; +use solana_sdk::transaction::TransactionError; +use solend_program::error::LendingError; + +use solend_program::state::ReserveConfig; +use solend_program::NULL_PUBKEY; +use solend_sdk::state::ReserveFees; +mod helpers; + +use helpers::*; +use solana_program_test::*; + +use std::collections::HashSet; + +/// the two prices feature affects a bunch of instructions. All of those instructions are tested +/// here for correctness. + +#[tokio::test] +async fn test_borrow() { + let (mut test, lending_market, reserves, obligation, user) = custom_scenario( + &[ + ReserveArgs { + mint: usdc_mint::id(), + config: test_reserve_config(), + liquidity_amount: 100_000 * FRACTIONAL_TO_USDC, + price: PriceArgs { + price: 10, + conf: 0, + expo: -1, + ema_price: 10, + ema_conf: 1, + }, + }, + ReserveArgs { + mint: wsol_mint::id(), + config: ReserveConfig { + loan_to_value_ratio: 50, + liquidation_threshold: 55, + fees: ReserveFees::default(), + optimal_borrow_rate: 0, + max_borrow_rate: 0, + ..test_reserve_config() + }, + liquidity_amount: 100 * LAMPORTS_PER_SOL, + price: PriceArgs { + price: 10, + conf: 0, + expo: 0, + ema_price: 10, + ema_conf: 0, + }, + }, + ], + &ObligationArgs { + deposits: vec![(usdc_mint::id(), 100 * FRACTIONAL_TO_USDC)], + borrows: vec![(wsol_mint::id(), LAMPORTS_PER_SOL)], + }, + ) + .await; + + // update prices + test.set_price( + &usdc_mint::id(), + &PriceArgs { + price: 9, + conf: 0, + expo: -1, + ema_price: 10, + ema_conf: 0, + }, + ) + .await; + + test.set_price( + &wsol_mint::id(), + &PriceArgs { + price: 10, + conf: 0, + expo: 0, + ema_price: 20, + ema_conf: 0, + }, + ) + .await; + + test.advance_clock_by_slots(1).await; + + let balance_checker = BalanceChecker::start(&mut test, &[&user]).await; + + // obligation currently has 100 USDC deposited and 1 sol borrowed + // if we try to borrow the max amount, how much SOL should we receive? + // allowed borrow value = 100 * min(1, 0.9) * 0.5 = $45 + // borrow value upper bound: 1 * max(10, 20) = $20 + // max SOL that can be borrowed is: ($45 - $20) / $20 = 1.25 SOL + lending_market + .borrow_obligation_liquidity( + &mut test, + &find_reserve(&reserves, &wsol_mint::id()).unwrap(), + &obligation, + &user, + &NULL_PUBKEY, + u64::MAX, + ) + .await + .unwrap(); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + let expected_balance_changes = HashSet::from([TokenBalanceChange { + token_account: user.get_account(&wsol_mint::id()).unwrap(), + mint: wsol_mint::id(), + diff: (LAMPORTS_PER_SOL * 125 / 100) as i128, + }]); + + assert_eq!(balance_changes, expected_balance_changes); + + test.advance_clock_by_slots(1).await; + + // shouldn't be able to borrow any more + let err = lending_market + .borrow_obligation_liquidity( + &mut test, + &find_reserve(&reserves, &wsol_mint::id()).unwrap(), + &obligation, + &user, + &NULL_PUBKEY, + u64::MAX, + ) + .await + .unwrap_err() + .unwrap(); + + assert_eq!( + err, + TransactionError::InstructionError( + 3, + InstructionError::Custom(LendingError::BorrowTooLarge as u32) + ) + ); +} + +#[tokio::test] +async fn test_withdraw() { + let (mut test, lending_market, reserves, obligation, user) = custom_scenario( + &[ + ReserveArgs { + mint: usdc_mint::id(), + config: test_reserve_config(), + liquidity_amount: 100_000 * FRACTIONAL_TO_USDC, + price: PriceArgs { + price: 10, + conf: 0, + expo: -1, + ema_price: 10, + ema_conf: 1, + }, + }, + ReserveArgs { + mint: usdt_mint::id(), + config: test_reserve_config(), + liquidity_amount: 100_000 * FRACTIONAL_TO_USDC, + price: PriceArgs { + price: 10, + conf: 0, + expo: -1, + ema_price: 10, + ema_conf: 1, + }, + }, + ReserveArgs { + mint: wsol_mint::id(), + config: ReserveConfig { + loan_to_value_ratio: 50, + liquidation_threshold: 55, + optimal_borrow_rate: 0, + max_borrow_rate: 0, + fees: ReserveFees::default(), + ..test_reserve_config() + }, + liquidity_amount: 100 * LAMPORTS_PER_SOL, + price: PriceArgs { + price: 10, + conf: 0, + expo: 0, + ema_price: 10, + ema_conf: 0, + }, + }, + ], + &ObligationArgs { + deposits: vec![ + (usdc_mint::id(), 100 * FRACTIONAL_TO_USDC), + (usdt_mint::id(), 20 * FRACTIONAL_TO_USDC), + ], + borrows: vec![(wsol_mint::id(), LAMPORTS_PER_SOL)], + }, + ) + .await; + + // update prices + test.set_price( + &usdc_mint::id(), + &PriceArgs { + price: 100, // massive price increase + conf: 0, + expo: 0, + ema_price: 1, + ema_conf: 0, + }, + ) + .await; + + test.set_price( + &wsol_mint::id(), + &PriceArgs { + price: 10, // big price decrease + conf: 0, + expo: 0, + ema_price: 20, + ema_conf: 0, + }, + ) + .await; + + test.advance_clock_by_slots(1).await; + + let balance_checker = BalanceChecker::start(&mut test, &[&user]).await; + + lending_market + .withdraw_obligation_collateral_and_redeem_reserve_collateral( + &mut test, + &find_reserve(&reserves, &usdc_mint::id()).unwrap(), + &obligation, + &user, + u64::MAX, + ) + .await + .unwrap(); + + // how much usdc should we able to withdraw? + // current allowed borrow value: 100 * min(100, 1) * 0.5 + 20 * min(1, 1) * 0.5 = $60 + // borrow value upper bound = 1 SOL * max($20, $10) = $20 + // max withdraw value = ($60 - $20) / 0.5 = $80 + // max withdraw liquidity amount = $80 / min(100, 1) = *80 USDC* + // note that if we didn't have this two prices feature, you could withdraw all of the USDC + // cUSDC/USDC exchange rate = 1 => max withdraw is 80 cUSDC + // + // reconciliation: + // after withdraw, we are left with 20 USDC, 20 USDT + // allowed borrow value is now 20 * min(100, 1) * 0.5 + 20 * min(1, 1) * 0.5 = $20 + // borrow value upper bound = $20 + // we have successfully borrowed the max amount + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + let expected_balance_changes = HashSet::from([TokenBalanceChange { + token_account: user.get_account(&usdc_mint::id()).unwrap(), + mint: usdc_mint::id(), + diff: (80 * FRACTIONAL_TO_USDC) as i128, + }]); + + assert_eq!(balance_changes, expected_balance_changes); + + // we shouldn't be able to withdraw anything else + for mint in [usdc_mint::id(), usdt_mint::id()] { + let err = lending_market + .withdraw_obligation_collateral_and_redeem_reserve_collateral( + &mut test, + &find_reserve(&reserves, &mint).unwrap(), + &obligation, + &user, + u64::MAX, + ) + .await + .unwrap_err() + .unwrap(); + + assert_eq!( + err, + TransactionError::InstructionError( + 4, + InstructionError::Custom(LendingError::WithdrawTooLarge as u32) + ) + ); + } +} + +#[tokio::test] +async fn test_liquidation_doesnt_use_smoothed_price() { + let (mut test, lending_market, reserves, obligation, user) = custom_scenario( + &[ + ReserveArgs { + mint: usdc_mint::id(), + config: test_reserve_config(), + liquidity_amount: 100_000 * FRACTIONAL_TO_USDC, + price: PriceArgs { + price: 1, + conf: 0, + expo: 0, + ema_price: 1, + ema_conf: 0, + }, + }, + ReserveArgs { + mint: wsol_mint::id(), + config: ReserveConfig { + loan_to_value_ratio: 50, + liquidation_threshold: 55, + fees: ReserveFees::default(), + optimal_borrow_rate: 0, + max_borrow_rate: 0, + protocol_liquidation_fee: 0, + ..test_reserve_config() + }, + liquidity_amount: 100 * LAMPORTS_PER_SOL, + price: PriceArgs { + price: 10, + conf: 0, + expo: 0, + ema_price: 10, + ema_conf: 0, + }, + }, + ], + &ObligationArgs { + deposits: vec![(usdc_mint::id(), 100 * FRACTIONAL_TO_USDC)], + borrows: vec![(wsol_mint::id(), LAMPORTS_PER_SOL)], + }, + ) + .await; + + // set ema price to 100 + test.set_price( + &wsol_mint::id(), + &PriceArgs { + price: 10, + conf: 0, + expo: 0, + ema_price: 100, + ema_conf: 0, + }, + ) + .await; + + test.advance_clock_by_slots(1).await; + + // this should fail bc the obligation is still healthy wrt the current non-ema market prices + let err = lending_market + .liquidate_obligation_and_redeem_reserve_collateral( + &mut test, + &find_reserve(&reserves, &wsol_mint::id()).unwrap(), + &find_reserve(&reserves, &usdc_mint::id()).unwrap(), + &obligation, + &user, + u64::MAX, + ) + .await + .unwrap_err() + .unwrap(); + + assert_eq!( + err, + TransactionError::InstructionError( + 3, + InstructionError::Custom(LendingError::ObligationHealthy as u32) + ) + ); + + test.set_price( + &usdc_mint::id(), + &PriceArgs { + price: 1, + conf: 0, + expo: 0, + ema_price: 0, + ema_conf: 0, + }, + ) + .await; + + test.advance_clock_by_slots(1).await; + + // this should fail bc the obligation is still healthy wrt the current non-ema market prices + let err = lending_market + .liquidate_obligation_and_redeem_reserve_collateral( + &mut test, + &find_reserve(&reserves, &wsol_mint::id()).unwrap(), + &find_reserve(&reserves, &usdc_mint::id()).unwrap(), + &obligation, + &user, + u64::MAX, + ) + .await + .unwrap_err() + .unwrap(); + + assert_eq!( + err, + TransactionError::InstructionError( + 3, + InstructionError::Custom(LendingError::ObligationHealthy as u32) + ) + ); + + // now set the spot prices. this time, the liquidation should actually work + test.set_price( + &usdc_mint::id(), + &PriceArgs { + price: 1, + conf: 0, + expo: 0, + ema_price: 10, + ema_conf: 0, + }, + ) + .await; + + test.set_price( + &wsol_mint::id(), + &PriceArgs { + price: 100, + conf: 0, + expo: 0, + ema_price: 10, + ema_conf: 0, + }, + ) + .await; + + test.advance_clock_by_slots(1).await; + + let usdc_reserve = find_reserve(&reserves, &usdc_mint::id()).unwrap(); + let wsol_reserve = find_reserve(&reserves, &wsol_mint::id()).unwrap(); + + let liquidator = User::new_with_balances( + &mut test, + &[ + (&usdc_mint::id(), 100 * FRACTIONAL_TO_USDC), + (&usdc_reserve.account.collateral.mint_pubkey, 0), + (&wsol_mint::id(), 100 * LAMPORTS_PER_SOL), + (&wsol_reserve.account.collateral.mint_pubkey, 0), + ], + ) + .await; + + let balance_checker = BalanceChecker::start(&mut test, &[&liquidator]).await; + + lending_market + .liquidate_obligation_and_redeem_reserve_collateral( + &mut test, + &find_reserve(&reserves, &wsol_mint::id()).unwrap(), + &find_reserve(&reserves, &usdc_mint::id()).unwrap(), + &obligation, + &liquidator, + u64::MAX, + ) + .await + .unwrap(); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + // make sure the liquidation amounts are also wrt spot prices + let expected_balances_changes = HashSet::from([ + TokenBalanceChange { + token_account: liquidator.get_account(&usdc_mint::id()).unwrap(), + mint: usdc_mint::id(), + diff: (20 * FRACTIONAL_TO_USDC * 105 / 100) as i128 - 1, + }, + TokenBalanceChange { + token_account: liquidator.get_account(&wsol_mint::id()).unwrap(), + mint: wsol_mint::id(), + diff: -((LAMPORTS_PER_SOL / 5) as i128), + }, + ]); + + assert_eq!(balance_changes, expected_balances_changes); +} diff --git a/token-lending/program/tests/withdraw_obligation_collateral.rs b/token-lending/program/tests/withdraw_obligation_collateral.rs index bbf9ba5b98f..49fd7dbc327 100644 --- a/token-lending/program/tests/withdraw_obligation_collateral.rs +++ b/token-lending/program/tests/withdraw_obligation_collateral.rs @@ -7,8 +7,7 @@ use helpers::solend_program_test::{BalanceChecker, TokenBalanceChange}; use helpers::*; use solana_program_test::*; -use solana_sdk::{instruction::InstructionError, transaction::TransactionError}; -use solend_program::error::LendingError; + use solend_program::state::{LastUpdate, Obligation, ObligationCollateral, Reserve}; use std::collections::HashSet; use std::u64; @@ -129,30 +128,3 @@ async fn test_success_withdraw_max() { } ); } - -#[tokio::test] -async fn test_fail_withdraw_too_much() { - let (mut test, lending_market, usdc_reserve, _wsol_reserve, user, obligation) = - scenario_1(&test_reserve_config(), &test_reserve_config()).await; - - let res = lending_market - .withdraw_obligation_collateral( - &mut test, - &usdc_reserve, - &obligation, - &user, - 100_000_000_000 - 200_000_000 + 1, - ) - .await - .err() - .unwrap() - .unwrap(); - - assert_eq!( - res, - TransactionError::InstructionError( - 3, - InstructionError::Custom(LendingError::WithdrawTooLarge as u32) - ) - ); -} diff --git a/token-lending/sdk/src/oracles.rs b/token-lending/sdk/src/oracles.rs index ed5113bbca0..aebd7ecc1d4 100644 --- a/token-lending/sdk/src/oracles.rs +++ b/token-lending/sdk/src/oracles.rs @@ -4,7 +4,8 @@ use crate::{ error::LendingError, math::{Decimal, TryDiv, TryMul}, }; -use pyth_sdk_solana; +use pyth_sdk_solana::Price; +// use pyth_sdk_solana; use solana_program::{ account_info::AccountInfo, msg, program_error::ProgramError, sysvar::clock::Clock, }; @@ -13,7 +14,7 @@ use std::{convert::TryInto, result::Result}; pub fn get_pyth_price( pyth_price_info: &AccountInfo, clock: &Clock, -) -> Result { +) -> Result<(Decimal, Decimal), ProgramError> { const PYTH_CONFIDENCE_RATIO: u64 = 10; const STALE_AFTER_SLOTS_ELAPSED: u64 = 240; // roughly 2 min @@ -50,7 +51,25 @@ pub fn get_pyth_price( return Err(LendingError::InvalidOracleConfig.into()); } - let market_price = if pyth_price.expo >= 0 { + let market_price = pyth_price_to_decimal(&pyth_price); + let ema_price = { + let price_feed = price_account.to_price_feed(pyth_price_info.key); + // this can be unchecked bc the ema price is only used to _limit_ borrows and withdraws. + // ie staleness doesn't _really_ matter for this field. + let ema_price = price_feed.get_ema_price_unchecked(); + pyth_price_to_decimal(&ema_price)? + }; + + Ok((market_price?, ema_price)) +} + +fn pyth_price_to_decimal(pyth_price: &Price) -> Result { + let price: u64 = pyth_price.price.try_into().map_err(|_| { + msg!("Oracle price cannot be negative"); + LendingError::InvalidOracleConfig + })?; + + if pyth_price.expo >= 0 { let exponent = pyth_price .expo .try_into() @@ -58,7 +77,7 @@ pub fn get_pyth_price( let zeros = 10u64 .checked_pow(exponent) .ok_or(LendingError::MathOverflow)?; - Decimal::from(price).try_mul(zeros)? + Decimal::from(price).try_mul(zeros) } else { let exponent = pyth_price .expo @@ -69,10 +88,8 @@ pub fn get_pyth_price( let decimals = 10u64 .checked_pow(exponent) .ok_or(LendingError::MathOverflow)?; - Decimal::from(price).try_div(decimals)? - }; - - Ok(market_price) + Decimal::from(price).try_div(decimals) + } } #[cfg(test)] @@ -80,6 +97,7 @@ mod test { use super::*; use bytemuck::bytes_of_mut; use proptest::prelude::*; + use pyth_sdk_solana::state::Rational; use pyth_sdk_solana::state::{ AccountType, CorpAction, PriceAccount, PriceInfo, PriceStatus, PriceType, MAGIC, VERSION_2, }; @@ -89,7 +107,7 @@ mod test { struct PythPriceTestCase { price_account: PriceAccount, clock: Clock, - expected_result: Result, + expected_result: Result<(Decimal, Decimal), ProgramError>, } fn pyth_price_cases() -> impl Strategy { @@ -102,6 +120,11 @@ mod test { atype: AccountType::Price as u32, ptype: PriceType::Price, expo: 10, + ema_price: Rational { + val: 11, + numer: 110, + denom: 10, + }, agg: PriceInfo { price: 10, conf: 1, @@ -126,6 +149,11 @@ mod test { atype: AccountType::Price as u32, ptype: PriceType::Price, expo: 10, + ema_price: Rational { + val: 11, + numer: 110, + denom: 10, + }, agg: PriceInfo { price: 10, conf: 1, @@ -149,6 +177,11 @@ mod test { atype: AccountType::Product as u32, ptype: PriceType::Price, expo: 10, + ema_price: Rational { + val: 11, + numer: 110, + denom: 10, + }, agg: PriceInfo { price: 10, conf: 1, @@ -174,6 +207,11 @@ mod test { ptype: PriceType::Price, expo: 1, timestamp: 0, + ema_price: Rational { + val: 11, + numer: 110, + denom: 10, + }, agg: PriceInfo { price: 200, conf: 1, @@ -187,7 +225,7 @@ mod test { slot: 240, ..Clock::default() }, - expected_result: Ok(Decimal::from(2000_u64)) + expected_result: Ok((Decimal::from(2000_u64), Decimal::from(110_u64))) }), // case 7: success. most recent price has status == unknown, previous price not stale Just(PythPriceTestCase { @@ -198,6 +236,11 @@ mod test { ptype: PriceType::Price, expo: 1, timestamp: 20, + ema_price: Rational { + val: 11, + numer: 110, + denom: 10, + }, agg: PriceInfo { price: 200, conf: 1, @@ -214,7 +257,7 @@ mod test { slot: 240, ..Clock::default() }, - expected_result: Ok(Decimal::from(1900_u64)) + expected_result: Ok((Decimal::from(1900_u64), Decimal::from(110_u64))) }), // case 8: failure. most recent price is stale Just(PythPriceTestCase { @@ -225,6 +268,11 @@ mod test { ptype: PriceType::Price, expo: 1, timestamp: 0, + ema_price: Rational { + val: 11, + numer: 110, + denom: 10, + }, agg: PriceInfo { price: 200, conf: 1, @@ -250,6 +298,11 @@ mod test { ptype: PriceType::Price, expo: 1, timestamp: 1, + ema_price: Rational { + val: 11, + numer: 110, + denom: 10, + }, agg: PriceInfo { price: 200, conf: 1, @@ -277,6 +330,11 @@ mod test { ptype: PriceType::Price, expo: 1, timestamp: 1, + ema_price: Rational { + val: 11, + numer: 110, + denom: 10, + }, agg: PriceInfo { price: -200, conf: 1, @@ -301,6 +359,11 @@ mod test { ptype: PriceType::Price, expo: 1, timestamp: 1, + ema_price: Rational { + val: 11, + numer: 110, + denom: 10, + }, agg: PriceInfo { price: 200, conf: 40, diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index 4cc87903a63..e42877c1cab 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -13,7 +13,7 @@ use solana_program::{ pubkey::{Pubkey, PUBKEY_BYTES}, }; use std::{ - cmp::Ordering, + cmp::{min, Ordering}, convert::{TryFrom, TryInto}, }; @@ -37,11 +37,21 @@ pub struct Obligation { pub borrows: Vec, /// Market value of deposits pub deposited_value: Decimal, - /// Risk-adjusted market value of borrows + /// Risk-adjusted market value of borrows. + /// ie sum(b.borrowed_amount * b.current_spot_price * b.borrow_weight for b in borrows) pub borrowed_value: Decimal, - /// The maximum borrow value at the weighted average loan to value ratio + /// Risk-adjusted upper bound market value of borrows. + /// ie sum(b.borrowed_amount * max(b.current_spot_price, b.smoothed_price) * b.borrow_weight for b in borrows) + pub borrowed_value_upper_bound: Decimal, + /// The maximum open borrow value. + /// ie sum(d.deposited_amount * d.ltv * min(d.current_spot_price, d.smoothed_price) for d in deposits) + /// if borrowed_value_upper_bound >= allowed_borrow_value, then the obligation is unhealthy and + /// borrows and withdraws are disabled. pub allowed_borrow_value: Decimal, - /// The dangerous borrow value at the weighted average liquidation threshold + /// The dangerous borrow value at the weighted average liquidation threshold. + /// ie sum(d.deposited_amount * d.liquidation_threshold * min(d.current_spot_price, d.smoothed_price) + /// for d in deposits) + /// if borrowed_value >= unhealthy_borrow_value, the obligation can be liquidated pub unhealthy_borrow_value: Decimal, } @@ -90,25 +100,68 @@ impl Obligation { Ok(()) } - /// Calculate the maximum collateral value that can be withdrawn - pub fn max_withdraw_value( + /// calculate the maximum amount of collateral that can be borrowed + pub fn max_withdraw_amount( &self, - withdraw_collateral_ltv: Rate, - ) -> Result { - if self.allowed_borrow_value <= self.borrowed_value { - return Ok(Decimal::zero()); + collateral: &ObligationCollateral, + withdraw_reserve: &Reserve, + ) -> Result { + if self.allowed_borrow_value <= self.borrowed_value_upper_bound { + return Ok(0); } - if withdraw_collateral_ltv == Rate::zero() { - return Ok(self.deposited_value); + + let loan_to_value_ratio = withdraw_reserve.loan_to_value_ratio(); + + if self.borrows.is_empty() || loan_to_value_ratio == Rate::zero() { + return Ok(collateral.deposited_amount); } - self.allowed_borrow_value - .try_sub(self.borrowed_value)? - .try_div(withdraw_collateral_ltv) + + // max usd value that can be withdrawn + let max_withdraw_value = self + .allowed_borrow_value + .try_sub(self.borrowed_value_upper_bound)? + .try_div(loan_to_value_ratio)?; + + // convert max_withdraw_value to max withdraw liquidity amount + + // why is min used and not max? seems scary + // + // the tldr is that allowed borrow value is calculated with the minimum + // of the spot price and the smoothed price, so we have to use the min here to be + // consistent. + // + // note that safety-wise, it doesn't actually matter. if we used the max (which appears safer), + // the initial max withdraw would be lower, but the user can immediately make another max withdraw call + // because allowed_borrow_value is still greater than borrowed_value_upper_bound + // after a large amount of consecutive max withdraw calls, the end state of using max would be the same + // as using min. + // + // therefore, we use min for the better UX. + let price = min( + withdraw_reserve.liquidity.market_price, + withdraw_reserve.liquidity.smoothed_market_price, + ); + + let decimals = 10u64 + .checked_pow(withdraw_reserve.liquidity.mint_decimals as u32) + .ok_or(LendingError::MathOverflow)?; + + let max_withdraw_liquidity_amount = max_withdraw_value.try_mul(decimals)?.try_div(price)?; + + // convert max withdraw liquidity amount to max withdraw collateral amount + Ok(min( + withdraw_reserve + .collateral_exchange_rate()? + .decimal_liquidity_to_collateral(max_withdraw_liquidity_amount)? + .try_floor_u64()?, + collateral.deposited_amount, + )) } /// Calculate the maximum liquidity value that can be borrowed pub fn remaining_borrow_value(&self) -> Result { - self.allowed_borrow_value.try_sub(self.borrowed_value) + self.allowed_borrow_value + .try_sub(self.borrowed_value_upper_bound) } /// Calculate the maximum liquidation amount for a given liquidity @@ -366,6 +419,7 @@ impl Pack for Obligation { borrowed_value, allowed_borrow_value, unhealthy_borrow_value, + borrowed_value_upper_bound, _padding, deposits_len, borrows_len, @@ -381,7 +435,8 @@ impl Pack for Obligation { 16, 16, 16, - 64, + 16, + 48, 1, 1, OBLIGATION_COLLATERAL_LEN + (OBLIGATION_LIQUIDITY_LEN * (MAX_OBLIGATION_RESERVES - 1)) @@ -395,6 +450,7 @@ impl Pack for Obligation { owner.copy_from_slice(self.owner.as_ref()); pack_decimal(self.deposited_value, deposited_value); pack_decimal(self.borrowed_value, borrowed_value); + pack_decimal(self.borrowed_value_upper_bound, borrowed_value_upper_bound); pack_decimal(self.allowed_borrow_value, allowed_borrow_value); pack_decimal(self.unhealthy_borrow_value, unhealthy_borrow_value); *deposits_len = u8::try_from(self.deposits.len()).unwrap().to_le_bytes(); @@ -450,6 +506,7 @@ impl Pack for Obligation { borrowed_value, allowed_borrow_value, unhealthy_borrow_value, + borrowed_value_upper_bound, _padding, deposits_len, borrows_len, @@ -465,7 +522,8 @@ impl Pack for Obligation { 16, 16, 16, - 64, + 16, + 48, 1, 1, OBLIGATION_COLLATERAL_LEN + (OBLIGATION_LIQUIDITY_LEN * (MAX_OBLIGATION_RESERVES - 1)) @@ -526,6 +584,7 @@ impl Pack for Obligation { borrows, deposited_value: unpack_decimal(deposited_value), borrowed_value: unpack_decimal(borrowed_value), + borrowed_value_upper_bound: unpack_decimal(borrowed_value_upper_bound), allowed_borrow_value: unpack_decimal(allowed_borrow_value), unhealthy_borrow_value: unpack_decimal(unhealthy_borrow_value), }) @@ -537,6 +596,7 @@ mod test { use super::*; use crate::math::TryAdd; use proptest::prelude::*; + use solana_program::native_token::LAMPORTS_PER_SOL; const MAX_COMPOUNDED_INTEREST: u64 = 100; // 10,000% @@ -738,4 +798,170 @@ mod test { Decimal::from(MAX_LIQUIDATABLE_VALUE_AT_ONCE) ); } + + #[derive(Debug, Clone)] + struct MaxWithdrawAmountTestCase { + obligation: Obligation, + reserve: Reserve, + + expected_max_withdraw_amount: u64, + } + + fn max_withdraw_amount_test_cases() -> impl Strategy { + prop_oneof![ + // borrowed as much as we can already, so can't borrow anything more + Just(MaxWithdrawAmountTestCase { + obligation: Obligation { + deposits: vec![ObligationCollateral { + deposited_amount: 20 * LAMPORTS_PER_SOL, + ..ObligationCollateral::default() + }], + deposited_value: Decimal::from(100u64), + borrowed_value_upper_bound: Decimal::from(50u64), + allowed_borrow_value: Decimal::from(50u64), + ..Obligation::default() + }, + reserve: Reserve::default(), + expected_max_withdraw_amount: 0, + }), + // regular case + Just(MaxWithdrawAmountTestCase { + obligation: Obligation { + deposits: vec![ObligationCollateral { + deposited_amount: 20 * LAMPORTS_PER_SOL, + ..ObligationCollateral::default() + }], + borrows: vec![ObligationLiquidity { + borrowed_amount_wads: Decimal::from(10u64), + ..ObligationLiquidity::default() + }], + + allowed_borrow_value: Decimal::from(100u64), + borrowed_value_upper_bound: Decimal::from(50u64), + ..Obligation::default() + }, + + reserve: Reserve { + config: ReserveConfig { + loan_to_value_ratio: 50, + ..ReserveConfig::default() + }, + liquidity: ReserveLiquidity { + available_amount: 100 * LAMPORTS_PER_SOL, + borrowed_amount_wads: Decimal::zero(), + market_price: Decimal::from(10u64), + smoothed_market_price: Decimal::from(5u64), + mint_decimals: 9, + ..ReserveLiquidity::default() + }, + collateral: ReserveCollateral { + mint_total_supply: 50 * LAMPORTS_PER_SOL, + ..ReserveCollateral::default() + }, + ..Reserve::default() + }, + + // deposited 20 cSOL + // => allowed borrow value: 20 cSOL * 2(SOL/cSOL) * 0.5(ltv) * $5 = $100 + // => borrowed value upper bound: $50 + // => max withdraw value: ($100 - $50) / 0.5 = $100 + // => max withdraw liquidity amount: $100 / $5 = 20 SOL + // => max withdraw collateral amount: 20 SOL / 2(SOL/cSOL) = 10 cSOL + // after withdrawing, the new allowed borrow value is: + // 10 cSOL * 2(SOL/cSOL) * 0.5(ltv) * $5 = $50, which is exactly what we want. + expected_max_withdraw_amount: 10 * LAMPORTS_PER_SOL, // 10 cSOL + }), + // same case as above but this time we didn't deposit that much collateral + Just(MaxWithdrawAmountTestCase { + obligation: Obligation { + deposits: vec![ObligationCollateral { + deposited_amount: 2 * LAMPORTS_PER_SOL, + ..ObligationCollateral::default() + }], + borrows: vec![ObligationLiquidity { + borrowed_amount_wads: Decimal::from(10u64), + ..ObligationLiquidity::default() + }], + + allowed_borrow_value: Decimal::from(100u64), + borrowed_value_upper_bound: Decimal::from(50u64), + ..Obligation::default() + }, + + reserve: Reserve { + config: ReserveConfig { + loan_to_value_ratio: 50, + ..ReserveConfig::default() + }, + liquidity: ReserveLiquidity { + available_amount: 100 * LAMPORTS_PER_SOL, + borrowed_amount_wads: Decimal::zero(), + market_price: Decimal::from(10u64), + smoothed_market_price: Decimal::from(5u64), + mint_decimals: 9, + ..ReserveLiquidity::default() + }, + collateral: ReserveCollateral { + mint_total_supply: 50 * LAMPORTS_PER_SOL, + ..ReserveCollateral::default() + }, + ..Reserve::default() + }, + + expected_max_withdraw_amount: 2 * LAMPORTS_PER_SOL, + }), + // no borrows so we can withdraw everything + Just(MaxWithdrawAmountTestCase { + obligation: Obligation { + deposits: vec![ObligationCollateral { + deposited_amount: 100 * LAMPORTS_PER_SOL, + ..ObligationCollateral::default() + }], + + allowed_borrow_value: Decimal::from(100u64), + ..Obligation::default() + }, + + reserve: Reserve { + config: ReserveConfig { + loan_to_value_ratio: 50, + ..ReserveConfig::default() + }, + ..Reserve::default() + }, + expected_max_withdraw_amount: 100 * LAMPORTS_PER_SOL, + }), + // ltv is 0 so we can withdraw everything + Just(MaxWithdrawAmountTestCase { + obligation: Obligation { + deposits: vec![ObligationCollateral { + deposited_amount: 100 * LAMPORTS_PER_SOL, + ..ObligationCollateral::default() + }], + borrows: vec![ObligationLiquidity { + borrowed_amount_wads: Decimal::from(10u64), + ..ObligationLiquidity::default() + }], + + allowed_borrow_value: Decimal::from(100u64), + ..Obligation::default() + }, + + reserve: Reserve::default(), + expected_max_withdraw_amount: 100 * LAMPORTS_PER_SOL, + }), + ] + } + + proptest! { + #[test] + fn max_withdraw_amount(test_case in max_withdraw_amount_test_cases()) { + let max_withdraw_amount = test_case.obligation.max_withdraw_amount( + &test_case.obligation.deposits[0], + &test_case.reserve, + ).unwrap(); + + assert_eq!(max_withdraw_amount, test_case.expected_max_withdraw_amount); + } + } } diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index eadcd7befda..9c7b6402580 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -13,7 +13,7 @@ use solana_program::{ pubkey::{Pubkey, PUBKEY_BYTES}, }; use std::{ - cmp::{min, Ordering}, + cmp::{max, min, Ordering}, convert::{TryFrom, TryInto}, }; @@ -71,7 +71,12 @@ impl Reserve { .unwrap() } - /// find price of tokens in quote currency + /// get loan to value ratio as a Rate + pub fn loan_to_value_ratio(&self) -> Rate { + Rate::from_percent(self.config.loan_to_value_ratio) + } + + /// find current market value of tokens pub fn market_value(&self, liquidity_amount: Decimal) -> Result { self.liquidity .market_price @@ -83,6 +88,46 @@ impl Reserve { )) } + /// find the current upper bound market value of tokens. + /// ie max(market_price, smoothed_market_price) * liquidity_amount + pub fn market_value_upper_bound( + &self, + liquidity_amount: Decimal, + ) -> Result { + let price_upper_bound = std::cmp::max( + self.liquidity.market_price, + self.liquidity.smoothed_market_price, + ); + + price_upper_bound + .try_mul(liquidity_amount)? + .try_div(Decimal::from( + (10u128) + .checked_pow(self.liquidity.mint_decimals as u32) + .ok_or(LendingError::MathOverflow)?, + )) + } + + /// find the current lower bound market value of tokens. + /// ie min(market_price, smoothed_market_price) * liquidity_amount + pub fn market_value_lower_bound( + &self, + liquidity_amount: Decimal, + ) -> Result { + let price_lower_bound = std::cmp::min( + self.liquidity.market_price, + self.liquidity.smoothed_market_price, + ); + + price_lower_bound + .try_mul(liquidity_amount)? + .try_div(Decimal::from( + (10u128) + .checked_pow(self.liquidity.mint_decimals as u32) + .ok_or(LendingError::MathOverflow)?, + )) + } + /// Record deposited liquidity and return amount of collateral tokens to mint pub fn deposit_liquidity(&mut self, liquidity_amount: u64) -> Result { let collateral_amount = self @@ -190,7 +235,10 @@ impl Reserve { if amount_to_borrow == u64::MAX { let borrow_amount = max_borrow_value .try_mul(decimals)? - .try_div(self.liquidity.market_price)? + .try_div(max( + self.liquidity.market_price, + self.liquidity.smoothed_market_price, + ))? .try_div(self.borrow_weight())? .min(remaining_reserve_borrow) .min(self.liquidity.available_amount.into()); @@ -219,7 +267,7 @@ impl Reserve { let borrow_amount = borrow_amount.try_add(borrow_fee.into())?; let borrow_value = self - .market_value(borrow_amount)? + .market_value_upper_bound(borrow_amount)? .try_mul(self.borrow_weight())?; if borrow_value > max_borrow_value { msg!("Borrow value cannot exceed maximum borrow value"); @@ -443,6 +491,8 @@ pub struct ReserveLiquidity { pub accumulated_protocol_fees_wads: Decimal, /// Reserve liquidity market price in quote currency pub market_price: Decimal, + /// Smoothed reserve liquidity market price for the liquidity (eg TWAP, VWAP, EMA) + pub smoothed_market_price: Decimal, } impl ReserveLiquidity { @@ -459,6 +509,7 @@ impl ReserveLiquidity { cumulative_borrow_rate_wads: Decimal::one(), accumulated_protocol_fees_wads: Decimal::zero(), market_price: params.market_price, + smoothed_market_price: params.smoothed_market_price, } } @@ -588,6 +639,8 @@ pub struct NewReserveLiquidityParams { pub switchboard_oracle_pubkey: Pubkey, /// Reserve liquidity market price in quote currency pub market_price: Decimal, + /// Smoothed reserve liquidity market price in quote currency + pub smoothed_market_price: Decimal, } /// Reserve collateral @@ -884,6 +937,7 @@ impl Pack for Reserve { liquidity_accumulated_protocol_fees_wads, rate_limiter, config_added_borrow_weight_bps, + liquidity_smoothed_market_price, _padding, ) = mut_array_refs![ output, @@ -921,7 +975,8 @@ impl Pack for Reserve { 16, RATE_LIMITER_LEN, 8, - 166 + 16, + 150 ]; // reserve @@ -951,6 +1006,10 @@ impl Pack for Reserve { liquidity_accumulated_protocol_fees_wads, ); pack_decimal(self.liquidity.market_price, liquidity_market_price); + pack_decimal( + self.liquidity.smoothed_market_price, + liquidity_smoothed_market_price, + ); // collateral collateral_mint_pubkey.copy_from_slice(self.collateral.mint_pubkey.as_ref()); @@ -1018,6 +1077,7 @@ impl Pack for Reserve { liquidity_accumulated_protocol_fees_wads, rate_limiter, config_added_borrow_weight_bps, + liquidity_smoothed_market_price, _padding, ) = array_refs![ input, @@ -1055,7 +1115,8 @@ impl Pack for Reserve { 16, RATE_LIMITER_LEN, 8, - 166 + 16, + 150 ]; let version = u8::from_le_bytes(*version); @@ -1086,6 +1147,7 @@ impl Pack for Reserve { liquidity_accumulated_protocol_fees_wads, ), market_price: unpack_decimal(liquidity_market_price), + smoothed_market_price: unpack_decimal(liquidity_smoothed_market_price), }, collateral: ReserveCollateral { mint_pubkey: Pubkey::new_from_array(*collateral_mint_pubkey), @@ -1654,6 +1716,7 @@ mod test { // reserve state market_price: Decimal, + smoothed_market_price: Decimal, decimal: u8, added_borrow_weight_bps: u64, @@ -1672,6 +1735,7 @@ mod test { remaining_reserve_capacity: Decimal::from(LAMPORTS_PER_SOL * 10), market_price: Decimal::from(1u64), + smoothed_market_price: Decimal::from(1u64), decimal: 9, added_borrow_weight_bps: 0, @@ -1692,6 +1756,7 @@ mod test { remaining_reserve_capacity: Decimal::from(LAMPORTS_PER_SOL * 101 / 100), market_price: Decimal::from(1u64), + smoothed_market_price: Decimal::from(1u64), decimal: 9, added_borrow_weight_bps: 0, @@ -1712,6 +1777,7 @@ mod test { remaining_reserve_capacity: Decimal::from(LAMPORTS_PER_SOL), market_price: Decimal::from(1u64), + smoothed_market_price: Decimal::from(1u64), decimal: 9, added_borrow_weight_bps: 10_000, @@ -1732,6 +1798,7 @@ mod test { remaining_reserve_capacity: Decimal::from(LAMPORTS_PER_SOL), market_price: Decimal::from(1u64), + smoothed_market_price: Decimal::from(1u64), decimal: 9, added_borrow_weight_bps: 10_000, @@ -1745,6 +1812,65 @@ mod test { host_fee: 0, }), }), + // borrow max where ema price is 2x the market price + Just(CalculateBorrowTestCase { + borrow_amount: u64::MAX, + remaining_borrow_value: Decimal::from(100u64), + remaining_reserve_capacity: Decimal::from(100 * LAMPORTS_PER_SOL), + + market_price: Decimal::from(10u64), + smoothed_market_price: Decimal::from(20u64), + decimal: 9, + added_borrow_weight_bps: 0, + + borrow_fee_wad: 0, + host_fee: 0, + + result: Ok(CalculateBorrowResult { + borrow_amount: Decimal::from(5 * LAMPORTS_PER_SOL), + receive_amount: 5 * LAMPORTS_PER_SOL, + borrow_fee: 0, + host_fee: 0 + }), + }), + // borrow max where market price is 2x ema price + Just(CalculateBorrowTestCase { + borrow_amount: u64::MAX, + remaining_borrow_value: Decimal::from(100u64), + remaining_reserve_capacity: Decimal::from(100 * LAMPORTS_PER_SOL), + + market_price: Decimal::from(20u64), + smoothed_market_price: Decimal::from(10u64), + decimal: 9, + added_borrow_weight_bps: 0, + + borrow_fee_wad: 0, + host_fee: 0, + + result: Ok(CalculateBorrowResult { + borrow_amount: Decimal::from(5 * LAMPORTS_PER_SOL), + receive_amount: 5 * LAMPORTS_PER_SOL, + borrow_fee: 0, + host_fee: 0 + }), + }), + // borrow enough where it would be fine if we were just using the market price but + // not fine when using both market and ema price + Just(CalculateBorrowTestCase { + borrow_amount: 7 * LAMPORTS_PER_SOL, + remaining_borrow_value: Decimal::from(100u64), + remaining_reserve_capacity: Decimal::from(100 * LAMPORTS_PER_SOL), + + market_price: Decimal::from(10u64), + smoothed_market_price: Decimal::from(20u64), + decimal: 9, + added_borrow_weight_bps: 0, + + borrow_fee_wad: 0, + host_fee: 0, + + result: Err(LendingError::BorrowTooLarge.into()), + }), ] } @@ -1764,6 +1890,7 @@ mod test { liquidity: ReserveLiquidity { mint_decimals: test_case.decimal, market_price: test_case.market_price, + smoothed_market_price: test_case.smoothed_market_price, available_amount: test_case.remaining_reserve_capacity.to_scaled_val().unwrap() as u64, ..ReserveLiquidity::default() },