diff --git a/token-lending/cli/src/main.rs b/token-lending/cli/src/main.rs index abe1b341cbe..efd3b899473 100644 --- a/token-lending/cli/src/main.rs +++ b/token-lending/cli/src/main.rs @@ -1,6 +1,7 @@ use lending_state::SolendState; use solana_client::rpc_config::RpcSendTransactionConfig; use solana_sdk::{commitment_config::CommitmentLevel, compute_budget::ComputeBudgetInstruction}; +use solend_program::{instruction::set_lending_market_owner_and_config, state::RateLimiterConfig}; use solend_sdk::{ instruction::{ liquidate_obligation_and_redeem_reserve_collateral, redeem_reserve_collateral, @@ -88,6 +89,12 @@ struct PartialReserveConfig { pub protocol_liquidation_fee: Option, /// Protocol take rate is the amount borrowed interest protocol recieves, as a percentage pub protocol_take_rate: Option, + /// Rate Limiter's max window size + pub rate_limiter_window_duration: Option, + /// Rate Limiter's max outflow per window + pub rate_limiter_max_outflow: Option, + /// Added borrow weight in basis points + pub added_borrow_weight_bps: Option, } /// Reserve Fees with optional fields @@ -523,6 +530,55 @@ fn main() { .help("Borrow limit"), ) ) + .subcommand( + SubCommand::with_name("set-lending-market-owner-and-config") + .about("Set lending market owner and config") + .arg( + Arg::with_name("lending_market_owner") + .long("market-owner") + .validator(is_keypair) + .value_name("KEYPAIR") + .takes_value(true) + .required(true) + .help("Owner of the lending market"), + ) + .arg( + Arg::with_name("lending_market") + .long("market") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Lending market address"), + ) + .arg( + Arg::with_name("new_lending_market_owner") + .long("new-lending-market-owner") + .validator(is_keypair) + .value_name("KEYPAIR") + .takes_value(true) + .required(false) + .help("Owner of the lending market"), + ) + .arg( + Arg::with_name("rate_limiter_window_duration") + .long("rate-limiter-window-duration") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(false) + .help("Rate Limiter Window Duration in Slots"), + ) + .arg( + Arg::with_name("rate_limiter_max_outflow") + .long("rate-limiter-max-outflow") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(false) + .help("Rate Limiter max outflow denominated in dollars within 1 window"), + ) + ) .subcommand( SubCommand::with_name("update-reserve") .about("Update a reserve config") @@ -716,6 +772,33 @@ fn main() { .required(false) .help("Switchboard price feed account: https://switchboard.xyz/#/explorer"), ) + .arg( + Arg::with_name("rate_limiter_window_duration") + .long("rate-limiter-window-duration") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(false) + .help("Rate Limiter Window Duration in Slots"), + ) + .arg( + Arg::with_name("rate_limiter_max_outflow") + .long("rate-limiter-max-outflow") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(false) + .help("Rate Limiter max outflow of token amounts within 1 window"), + ) + .arg( + Arg::with_name("added_borrow_weight_bps") + .long("added-borrow-weight-bps") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(false) + .help("Added borrow weight in basis points"), + ) ) .get_matches(); @@ -871,6 +954,7 @@ fn main() { fee_receiver: liquidity_fee_receiver_keypair.pubkey(), protocol_liquidation_fee, protocol_take_rate, + added_borrow_weight_bps: 10000, }, source_liquidity_pubkey, source_liquidity_owner_keypair, @@ -883,6 +967,24 @@ fn main() { source_liquidity, ) } + ("set-lending-market-owner-and-config", Some(arg_matches)) => { + let lending_market_owner_keypair = + keypair_of(arg_matches, "lending_market_owner").unwrap(); + let lending_market_pubkey = pubkey_of(arg_matches, "lending_market").unwrap(); + let new_lending_market_owner_keypair = + keypair_of(arg_matches, "new_lending_market_owner"); + let rate_limiter_window_duration = + value_of(arg_matches, "rate_limiter_window_duration"); + let rate_limiter_max_outflow = value_of(arg_matches, "rate_limiter_max_outflow"); + command_set_lending_market_owner_and_config( + &mut config, + lending_market_pubkey, + lending_market_owner_keypair, + new_lending_market_owner_keypair, + rate_limiter_window_duration, + rate_limiter_max_outflow, + ) + } ("update-reserve", Some(arg_matches)) => { let reserve_pubkey = pubkey_of(arg_matches, "reserve").unwrap(); let lending_market_owner_keypair = @@ -906,6 +1008,10 @@ fn main() { let pyth_product_pubkey = pubkey_of(arg_matches, "pyth_product"); let pyth_price_pubkey = pubkey_of(arg_matches, "pyth_price"); let switchboard_feed_pubkey = pubkey_of(arg_matches, "switchboard_feed"); + let rate_limiter_window_duration = + value_of(arg_matches, "rate_limiter_window_duration"); + let rate_limiter_max_outflow = value_of(arg_matches, "rate_limiter_max_outflow"); + let added_borrow_weight_bps = value_of(arg_matches, "added_borrow_weight_bps"); let borrow_fee_wad = borrow_fee.map(|fee| (fee * WAD as f64) as u64); let flash_loan_fee_wad = flash_loan_fee.map(|fee| (fee * WAD as f64) as u64); @@ -930,6 +1036,9 @@ fn main() { fee_receiver, protocol_liquidation_fee, protocol_take_rate, + rate_limiter_window_duration, + rate_limiter_max_outflow, + added_borrow_weight_bps, }, pyth_product_pubkey, pyth_price_pubkey, @@ -1437,6 +1546,50 @@ fn command_add_reserve( Ok(()) } +fn command_set_lending_market_owner_and_config( + config: &mut Config, + lending_market_pubkey: Pubkey, + lending_market_owner_keypair: Keypair, + new_lending_market_owner_keypair: Option, + rate_limiter_window_duration: Option, + rate_limiter_max_outflow: Option, +) -> CommandResult { + let lending_market_info = config.rpc_client.get_account(&lending_market_pubkey)?; + let lending_market = LendingMarket::unpack_from_slice(lending_market_info.data.borrow())?; + println!("{:#?}", lending_market); + + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + let message = Message::new_with_blockhash( + &[set_lending_market_owner_and_config( + config.lending_program_id, + lending_market_pubkey, + lending_market_owner_keypair.pubkey(), + if let Some(owner) = new_lending_market_owner_keypair { + owner.pubkey() + } else { + lending_market.owner + }, + RateLimiterConfig { + window_duration: rate_limiter_window_duration + .unwrap_or(lending_market.rate_limiter.config.window_duration), + max_outflow: rate_limiter_max_outflow + .unwrap_or(lending_market.rate_limiter.config.max_outflow), + }, + )], + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + + let transaction = Transaction::new( + &vec![config.fee_payer.as_ref(), &lending_market_owner_keypair], + message, + recent_blockhash, + ); + + send_transaction(config, transaction)?; + Ok(()) +} + #[allow(clippy::too_many_arguments, clippy::unnecessary_unwrap)] fn command_update_reserve( config: &mut Config, @@ -1450,6 +1603,7 @@ fn command_update_reserve( ) -> CommandResult { let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; let mut reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + println!("Reserve: {:#?}", reserve); let mut no_change = true; if reserve_config.optimal_utilization_rate.is_some() && reserve.config.optimal_utilization_rate @@ -1664,6 +1818,46 @@ fn command_update_reserve( ); reserve.liquidity.switchboard_oracle_pubkey = switchboard_feed_pubkey.unwrap(); } + + if reserve_config.rate_limiter_window_duration.is_some() + && reserve.rate_limiter.config.window_duration + != reserve_config.rate_limiter_window_duration.unwrap() + { + no_change = false; + println!( + "Updating rate_limiter_window_duration from {} to {}", + reserve.rate_limiter.config.window_duration, + reserve_config.rate_limiter_window_duration.unwrap(), + ); + reserve.rate_limiter.config.window_duration = + reserve_config.rate_limiter_window_duration.unwrap(); + } + + if reserve_config.rate_limiter_max_outflow.is_some() + && reserve.rate_limiter.config.max_outflow + != reserve_config.rate_limiter_max_outflow.unwrap() + { + no_change = false; + println!( + "Updating rate_limiter_max_outflow from {} to {}", + reserve.rate_limiter.config.max_outflow, + reserve_config.rate_limiter_max_outflow.unwrap(), + ); + reserve.rate_limiter.config.max_outflow = reserve_config.rate_limiter_max_outflow.unwrap(); + } + + if reserve_config.added_borrow_weight_bps.is_some() + && reserve.config.added_borrow_weight_bps != reserve_config.added_borrow_weight_bps.unwrap() + { + no_change = false; + println!( + "Updating added_borrow_weight_bps from {} to {}", + reserve.config.added_borrow_weight_bps, + reserve_config.added_borrow_weight_bps.unwrap(), + ); + reserve.config.added_borrow_weight_bps = reserve_config.added_borrow_weight_bps.unwrap(); + } + if no_change { println!("No changes made for reserve {}", reserve_pubkey); return Ok(()); @@ -1675,6 +1869,10 @@ fn command_update_reserve( &[update_reserve_config( config.lending_program_id, reserve.config, + RateLimiterConfig { + window_duration: reserve.rate_limiter.config.window_duration, + max_outflow: reserve.rate_limiter.config.max_outflow, + }, reserve_pubkey, lending_market_pubkey, lending_market_owner_keypair.pubkey(), diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index e2a92513528..174e99cdb9e 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -30,6 +30,7 @@ use solana_program::{ Sysvar, }, }; +use solend_sdk::state::{RateLimiter, RateLimiterConfig}; use solend_sdk::{switchboard_v2_devnet, switchboard_v2_mainnet}; use spl_token::state::Mint; use std::{cmp::min, result::Result}; @@ -53,9 +54,17 @@ pub fn process_instruction( msg!("Instruction: Init Lending Market"); process_init_lending_market(program_id, owner, quote_currency, accounts) } - LendingInstruction::SetLendingMarketOwner { new_owner } => { + LendingInstruction::SetLendingMarketOwnerAndConfig { + new_owner, + rate_limiter_config, + } => { msg!("Instruction: Set Lending Market Owner"); - process_set_lending_market_owner(program_id, new_owner, accounts) + process_set_lending_market_owner_and_config( + program_id, + new_owner, + rate_limiter_config, + accounts, + ) } LendingInstruction::InitReserve { liquidity_amount, @@ -128,9 +137,12 @@ pub fn process_instruction( accounts, ) } - LendingInstruction::UpdateReserveConfig { config } => { + LendingInstruction::UpdateReserveConfig { + config, + rate_limiter_config, + } => { msg!("Instruction: UpdateReserveConfig"); - process_update_reserve_config(program_id, config, accounts) + process_update_reserve_config(program_id, config, rate_limiter_config, accounts) } LendingInstruction::LiquidateObligationAndRedeemReserveCollateral { liquidity_amount } => { msg!("Instruction: Liquidate Obligation and Redeem Reserve Collateral"); @@ -197,9 +209,10 @@ fn process_init_lending_market( } #[inline(never)] // avoid stack frame limit -fn process_set_lending_market_owner( +fn process_set_lending_market_owner_and_config( program_id: &Pubkey, new_owner: Pubkey, + rate_limiter_config: RateLimiterConfig, accounts: &[AccountInfo], ) -> ProgramResult { let account_info_iter = &mut accounts.iter(); @@ -221,6 +234,11 @@ fn process_set_lending_market_owner( } lending_market.owner = new_owner; + + if rate_limiter_config != lending_market.rate_limiter.config { + lending_market.rate_limiter = RateLimiter::new(rate_limiter_config, Clock::get()?.slot); + } + LendingMarket::pack(lending_market, &mut lending_market_info.data.borrow_mut())?; Ok(()) @@ -310,7 +328,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(), @@ -341,12 +360,14 @@ 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, supply_pubkey: *reserve_collateral_supply_info.key, }), config, + rate_limiter_config: RateLimiterConfig::default(), }); let collateral_amount = reserve.deposit_liquidity(liquidity_amount)?; @@ -462,7 +483,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) @@ -659,7 +694,6 @@ fn process_redeem_reserve_collateral( } let token_program_id = next_account_info(account_info_iter)?; - _refresh_reserve_interest(program_id, reserve_info, clock)?; _redeem_reserve_collateral( program_id, collateral_amount, @@ -673,6 +707,7 @@ fn process_redeem_reserve_collateral( user_transfer_authority_info, clock, token_program_id, + true, )?; let mut reserve = Reserve::unpack(&reserve_info.data.borrow())?; reserve.last_update.mark_stale(); @@ -695,8 +730,9 @@ fn _redeem_reserve_collateral<'a>( user_transfer_authority_info: &AccountInfo<'a>, clock: &Clock, token_program_id: &AccountInfo<'a>, + check_rate_limits: bool, ) -> Result { - let lending_market = LendingMarket::unpack(&lending_market_info.data.borrow())?; + let mut lending_market = LendingMarket::unpack(&lending_market_info.data.borrow())?; if lending_market_info.owner != program_id { msg!("Lending market provided is not owned by the lending program"); return Err(LendingError::InvalidAccountOwner.into()); @@ -750,8 +786,31 @@ fn _redeem_reserve_collateral<'a>( } let liquidity_amount = reserve.redeem_collateral(collateral_amount)?; + + if check_rate_limits { + lending_market + .rate_limiter + .update( + clock.slot, + reserve.market_value_upper_bound(Decimal::from(liquidity_amount))?, + ) + .map_err(|err| { + msg!("Market outflow limit exceeded! Please try again later."); + err + })?; + + reserve + .rate_limiter + .update(clock.slot, Decimal::from(liquidity_amount)) + .map_err(|err| { + msg!("Reserve outflow limit exceeded! Please try again later."); + err + })?; + } + reserve.last_update.mark_stale(); Reserve::pack(reserve, &mut reserve_info.data.borrow_mut())?; + LendingMarket::pack(lending_market, &mut lending_market_info.data.borrow_mut())?; spl_token_burn(TokenBurnParams { mint: reserve_collateral_mint_info.clone(), @@ -837,6 +896,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(); @@ -866,25 +926,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)?)?; } @@ -917,18 +974,15 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> liquidity.accrue_interest(borrow_reserve.liquidity.cumulative_borrow_rate_wads)?; - // @TODO: add lookup table https://git.io/JOCYq - let decimals = 10u64 - .checked_pow(borrow_reserve.liquidity.mint_decimals as u32) - .ok_or(LendingError::MathOverflow)?; - - let market_value = liquidity - .borrowed_amount_wads - .try_mul(borrow_reserve.liquidity.market_price)? - .try_div(decimals)?; + 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)?; + 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() { @@ -938,6 +992,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); @@ -1273,49 +1328,13 @@ 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, - ))?; - - if max_withdraw_value == Decimal::zero() { - msg!("Maximum withdraw value is zero"); - return Err(LendingError::WithdrawTooLarge.into()); - } + let max_withdraw_amount = obligation.max_withdraw_amount(collateral, &withdraw_reserve)?; + let withdraw_amount = std::cmp::min(collateral_amount, max_withdraw_amount); - 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 - }; + if withdraw_amount == 0 { + msg!("Maximum withdraw value is zero"); + return Err(LendingError::WithdrawTooLarge.into()); + } obligation.withdraw(withdraw_amount, collateral_index)?; obligation.last_update.mark_stale(); @@ -1359,7 +1378,7 @@ fn process_borrow_obligation_liquidity( } let token_program_id = next_account_info(account_info_iter)?; - let lending_market = LendingMarket::unpack(&lending_market_info.data.borrow())?; + let mut lending_market = LendingMarket::unpack(&lending_market_info.data.borrow())?; if lending_market_info.owner != program_id { msg!("Lending market provided is not owned by the lending program"); return Err(LendingError::InvalidAccountOwner.into()); @@ -1449,7 +1468,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()); @@ -1477,6 +1498,30 @@ fn process_borrow_obligation_liquidity( let cumulative_borrow_rate_wads = borrow_reserve.liquidity.cumulative_borrow_rate_wads; + // check outflow rate limits + { + lending_market + .rate_limiter + .update( + clock.slot, + borrow_reserve.market_value_upper_bound(borrow_amount)?, + ) + .map_err(|err| { + msg!("Market outflow limit exceeded! Please try again later."); + err + })?; + + borrow_reserve + .rate_limiter + .update(clock.slot, borrow_amount) + .map_err(|err| { + msg!("Reserve outflow limit exceeded! Please try again later"); + err + })?; + } + + LendingMarket::pack(lending_market, &mut lending_market_info.data.borrow_mut())?; + borrow_reserve.liquidity.borrow(borrow_amount)?; borrow_reserve.last_update.mark_stale(); Reserve::pack(borrow_reserve, &mut borrow_reserve_info.data.borrow_mut())?; @@ -1885,6 +1930,7 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( user_transfer_authority_info, clock, token_program_id, + false, )?; let withdraw_reserve = Reserve::unpack(&withdraw_reserve_info.data.borrow())?; if &withdraw_reserve.config.fee_receiver != withdraw_reserve_liquidity_fee_receiver_info.key @@ -1959,6 +2005,7 @@ fn process_withdraw_obligation_collateral_and_redeem_reserve_liquidity( user_transfer_authority_info, clock, token_program_id, + true, )?; Ok(()) } @@ -1967,6 +2014,7 @@ fn process_withdraw_obligation_collateral_and_redeem_reserve_liquidity( fn process_update_reserve_config( program_id: &Pubkey, config: ReserveConfig, + rate_limiter_config: RateLimiterConfig, accounts: &[AccountInfo], ) -> ProgramResult { validate_reserve_config(config)?; @@ -2024,6 +2072,11 @@ fn process_update_reserve_config( return Err(LendingError::InvalidMarketAuthority.into()); } + // if window duration or max outflow are different, then create a new rate limiter instance. + if rate_limiter_config != reserve.rate_limiter.config { + reserve.rate_limiter = RateLimiter::new(rate_limiter_config, Clock::get()?.slot); + } + if *pyth_price_info.key != reserve.liquidity.pyth_oracle_pubkey { validate_pyth_keys(&lending_market, pyth_product_info, pyth_price_info)?; reserve.liquidity.pyth_oracle_pubkey = *pyth_price_info.key; @@ -2540,19 +2593,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/borrow_obligation_liquidity.rs b/token-lending/program/tests/borrow_obligation_liquidity.rs index 645a2a0face..0016bd37215 100644 --- a/token-lending/program/tests/borrow_obligation_liquidity.rs +++ b/token-lending/program/tests/borrow_obligation_liquidity.rs @@ -1,8 +1,9 @@ #![cfg(feature = "test-bpf")] +use solend_program::math::TryDiv; mod helpers; -use solend_program::state::ReserveFees; +use solend_program::state::{RateLimiterConfig, ReserveFees}; use std::collections::HashSet; use helpers::solend_program_test::{ @@ -31,8 +32,9 @@ async fn setup( User, Info, User, + User, ) { - let (mut test, lending_market, usdc_reserve, wsol_reserve, _, user) = + let (mut test, lending_market, usdc_reserve, wsol_reserve, lending_market_owner, user) = setup_world(&test_reserve_config(), wsol_reserve_config).await; let obligation = lending_market @@ -98,21 +100,30 @@ async fn setup( user, obligation, host_fee_receiver, + lending_market_owner, ) } #[tokio::test] async fn test_success() { - let (mut test, lending_market, usdc_reserve, wsol_reserve, user, obligation, host_fee_receiver) = - setup(&ReserveConfig { - fees: ReserveFees { - borrow_fee_wad: 100_000_000_000, - flash_loan_fee_wad: 0, - host_fee_percentage: 20, - }, - ..test_reserve_config() - }) - .await; + let ( + mut test, + lending_market, + usdc_reserve, + wsol_reserve, + user, + obligation, + host_fee_receiver, + _, + ) = setup(&ReserveConfig { + fees: ReserveFees { + borrow_fee_wad: 100_000_000_000, + flash_loan_fee_wad: 0, + host_fee_percentage: 20, + }, + ..test_reserve_config() + }) + .await; let balance_checker = BalanceChecker::start( &mut test, @@ -166,26 +177,54 @@ async fn test_success() { assert_eq!(mint_supply_changes, HashSet::new()); // check program state - let lending_market_post = test.load_account(lending_market.pubkey).await; - assert_eq!(lending_market, lending_market_post); - - let wsol_reserve_post = test.load_account::(wsol_reserve.pubkey).await; + let lending_market_post = test + .load_account::(lending_market.pubkey) + .await; assert_eq!( - wsol_reserve_post.account, - Reserve { - last_update: LastUpdate { - slot: 1000, - stale: true + lending_market_post.account, + LendingMarket { + rate_limiter: { + let mut rate_limiter = lending_market.account.rate_limiter; + rate_limiter + .update( + 1000, + Decimal::from(10 * (4 * LAMPORTS_PER_SOL + 400)) + .try_div(Decimal::from(1_000_000_000_u64)) + .unwrap(), + ) + .unwrap(); + rate_limiter }, - liquidity: ReserveLiquidity { - available_amount: 6 * LAMPORTS_PER_SOL - (4 * LAMPORTS_PER_SOL + 400), - borrowed_amount_wads: Decimal::from(4 * LAMPORTS_PER_SOL + 400), - ..wsol_reserve.account.liquidity - }, - ..wsol_reserve.account + ..lending_market.account + } + ); + + let wsol_reserve_post = test.load_account::(wsol_reserve.pubkey).await; + let expected_wsol_reserve_post = Reserve { + last_update: LastUpdate { + slot: 1000, + stale: true, }, - "{:#?}", - wsol_reserve_post + liquidity: ReserveLiquidity { + available_amount: 6 * LAMPORTS_PER_SOL - (4 * LAMPORTS_PER_SOL + 400), + borrowed_amount_wads: Decimal::from(4 * LAMPORTS_PER_SOL + 400), + ..wsol_reserve.account.liquidity + }, + rate_limiter: { + let mut rate_limiter = wsol_reserve.account.rate_limiter; + rate_limiter + .update(1000, Decimal::from(4 * LAMPORTS_PER_SOL + 400)) + .unwrap(); + + rate_limiter + }, + ..wsol_reserve.account + }; + + assert_eq!( + wsol_reserve_post.account, expected_wsol_reserve_post, + "{:#?} {:#?}", + wsol_reserve_post, expected_wsol_reserve_post ); let obligation_post = test.load_account::(obligation.pubkey).await; @@ -220,16 +259,24 @@ async fn test_success() { // FIXME this should really be a unit test #[tokio::test] async fn test_borrow_max() { - let (mut test, lending_market, usdc_reserve, wsol_reserve, user, obligation, host_fee_receiver) = - setup(&ReserveConfig { - fees: ReserveFees { - borrow_fee_wad: 100_000_000_000, - flash_loan_fee_wad: 0, - host_fee_percentage: 20, - }, - ..test_reserve_config() - }) - .await; + let ( + mut test, + lending_market, + usdc_reserve, + wsol_reserve, + user, + obligation, + host_fee_receiver, + _, + ) = setup(&ReserveConfig { + fees: ReserveFees { + borrow_fee_wad: 100_000_000_000, + flash_loan_fee_wad: 0, + host_fee_percentage: 20, + }, + ..test_reserve_config() + }) + .await; let balance_checker = BalanceChecker::start( &mut test, @@ -286,7 +333,7 @@ async fn test_borrow_max() { #[tokio::test] async fn test_fail_borrow_over_reserve_borrow_limit() { - let (mut test, lending_market, _, wsol_reserve, user, obligation, host_fee_receiver) = + let (mut test, lending_market, _, wsol_reserve, user, obligation, host_fee_receiver, _) = setup(&ReserveConfig { borrow_limit: LAMPORTS_PER_SOL, ..test_reserve_config() @@ -315,3 +362,112 @@ async fn test_fail_borrow_over_reserve_borrow_limit() { ) ); } + +#[tokio::test] +async fn test_fail_reserve_borrow_rate_limit_exceeded() { + let ( + mut test, + lending_market, + _, + wsol_reserve, + user, + obligation, + host_fee_receiver, + lending_market_owner, + ) = setup(&ReserveConfig { + ..test_reserve_config() + }) + .await; + + // ie, within 10 slots, the maximum outflow is 1 SOL + lending_market + .update_reserve_config( + &mut test, + &lending_market_owner, + &wsol_reserve, + wsol_reserve.account.config, + RateLimiterConfig { + window_duration: 10, + max_outflow: LAMPORTS_PER_SOL, + }, + None, + ) + .await + .unwrap(); + + // borrow maximum amount + lending_market + .borrow_obligation_liquidity( + &mut test, + &wsol_reserve, + &obligation, + &user, + &host_fee_receiver.get_account(&wsol_mint::id()).unwrap(), + LAMPORTS_PER_SOL, + ) + .await + .unwrap(); + + // for the next 10 slots, we shouldn't be able to borrow anything. + let cur_slot = test.get_clock().await.slot; + for _ in cur_slot..(cur_slot + 10) { + let res = lending_market + .borrow_obligation_liquidity( + &mut test, + &wsol_reserve, + &obligation, + &user, + &host_fee_receiver.get_account(&wsol_mint::id()).unwrap(), + 1, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + res, + TransactionError::InstructionError( + 3, + InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) + ) + ); + + test.advance_clock_by_slots(1).await; + } + + // after 10 slots, we should be able to at borrow most 0.1 SOL + let res = lending_market + .borrow_obligation_liquidity( + &mut test, + &wsol_reserve, + &obligation, + &user, + &host_fee_receiver.get_account(&wsol_mint::id()).unwrap(), + LAMPORTS_PER_SOL / 10 + 1, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + res, + TransactionError::InstructionError( + 3, + InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) + ) + ); + + lending_market + .borrow_obligation_liquidity( + &mut test, + &wsol_reserve, + &obligation, + &user, + &host_fee_receiver.get_account(&wsol_mint::id()).unwrap(), + LAMPORTS_PER_SOL / 10, + ) + .await + .unwrap(); +} diff --git a/token-lending/program/tests/borrow_weight.rs b/token-lending/program/tests/borrow_weight.rs new file mode 100644 index 00000000000..b873b6d07fd --- /dev/null +++ b/token-lending/program/tests/borrow_weight.rs @@ -0,0 +1,382 @@ +#![cfg(feature = "test-bpf")] +/// the borrow weight feature affects a bunch of instructions. All of those instructions are tested +/// here for correctness. +use crate::solend_program_test::setup_world; +use crate::solend_program_test::BalanceChecker; +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_sdk::state::ReserveFees; +mod helpers; + +use crate::solend_program_test::scenario_1; +use crate::solend_program_test::User; +use helpers::*; +use solana_program_test::*; +use solana_sdk::signature::Keypair; +use solend_program::math::Decimal; +use solend_program::state::Obligation; +use std::collections::HashSet; + +#[tokio::test] +async fn test_refresh_obligation() { + let (mut test, lending_market, _, _, _, obligation) = scenario_1( + &test_reserve_config(), + &ReserveConfig { + added_borrow_weight_bps: 10_000, + ..test_reserve_config() + }, + ) + .await; + + lending_market + .refresh_obligation(&mut test, &obligation) + .await + .unwrap(); + + let obligation_post = test.load_account::(obligation.pubkey).await; + + // obligation has borrowed 10 sol and sol = $10 but since borrow weight == 2, the + // borrowed_value is 200 instead of 100. + assert_eq!( + obligation_post.account, + Obligation { + borrowed_value: Decimal::from(200u64), + ..obligation.account + } + ); +} + +#[tokio::test] +async fn test_borrow() { + let (mut test, lending_market, usdc_reserve, wsol_reserve, _, _) = setup_world( + &test_reserve_config(), + &ReserveConfig { + added_borrow_weight_bps: 10_000, + fees: ReserveFees { + borrow_fee_wad: 10_000_000_000_000_000, // 1% + host_fee_percentage: 20, + flash_loan_fee_wad: 0, + }, + ..test_reserve_config() + }, + ) + .await; + + // create obligation with 100 USDC deposited. + let (user, obligation) = { + let user = User::new_with_balances( + &mut test, + &[ + (&usdc_mint::id(), 200 * FRACTIONAL_TO_USDC), + (&usdc_reserve.account.collateral.mint_pubkey, 0), + (&wsol_mint::id(), 0), + ], + ) + .await; + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("This should succeed"); + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + 100 * FRACTIONAL_TO_USDC, + ) + .await + .unwrap(); + (user, obligation) + }; + + // deposit 100 WSOL into reserve + let host_fee_receiver = { + let wsol_depositor = User::new_with_balances( + &mut test, + &[ + (&wsol_mint::id(), 5 * LAMPORTS_PER_SOL), + (&wsol_reserve.account.collateral.mint_pubkey, 0), + ], + ) + .await; + + lending_market + .deposit( + &mut test, + &wsol_reserve, + &wsol_depositor, + 5 * LAMPORTS_PER_SOL, + ) + .await + .unwrap(); + + wsol_depositor.get_account(&wsol_mint::id()).unwrap() + }; + + // borrow max amount of SOL + { + lending_market + .borrow_obligation_liquidity( + &mut test, + &wsol_reserve, + &obligation, + &user, + &host_fee_receiver, + u64::MAX, + ) + .await + .unwrap(); + + let obligation_post = test.load_account::(obligation.pubkey).await; + // - usdc ltv is 0.5, + // - sol borrow weight is 2 + // max you can borrow is 100 * 0.5 / 2 = 2.5 SOL + assert_eq!( + obligation_post.account.borrows[0].borrowed_amount_wads, + Decimal::from(LAMPORTS_PER_SOL * 25 / 10) + ); + } + + // check that we shouldn't be able to withdraw anything + { + let res = lending_market + .withdraw_obligation_collateral(&mut test, &usdc_reserve, &obligation, &user, u64::MAX) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + res, + TransactionError::InstructionError( + 3, + InstructionError::Custom(LendingError::WithdrawTooLarge as u32) + ) + ); + } + + // deposit another 50 USDC + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + 50 * FRACTIONAL_TO_USDC, + ) + .await + .unwrap(); + + test.advance_clock_by_slots(1).await; + + // max withdraw + { + let balance_checker = BalanceChecker::start(&mut test, &[&user]).await; + + lending_market + .withdraw_obligation_collateral(&mut test, &usdc_reserve, &obligation, &user, u64::MAX) + .await + .unwrap(); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + // should only be able to withdraw 50 USDC because the rest is needed to collateralize the + // SOL borrow + assert_eq!( + balance_changes, + HashSet::from([TokenBalanceChange { + token_account: user + .get_account(&usdc_reserve.account.collateral.mint_pubkey) + .unwrap(), + mint: usdc_reserve.account.collateral.mint_pubkey, + diff: (50 * FRACTIONAL_TO_USDC - 1) as i128, + }]) + ); + } +} + +#[tokio::test] +async fn test_liquidation() { + let (mut test, lending_market, usdc_reserve, wsol_reserve, lending_market_owner, _) = + setup_world( + &test_reserve_config(), + &ReserveConfig { + added_borrow_weight_bps: 0, + fees: ReserveFees { + borrow_fee_wad: 10_000_000_000_000_000, // 1% + host_fee_percentage: 20, + flash_loan_fee_wad: 0, + }, + ..test_reserve_config() + }, + ) + .await; + + // create obligation with 100 USDC deposited. + let (user, obligation) = { + let user = User::new_with_balances( + &mut test, + &[ + (&usdc_mint::id(), 200 * FRACTIONAL_TO_USDC), + (&usdc_reserve.account.collateral.mint_pubkey, 0), + (&wsol_mint::id(), 0), + ], + ) + .await; + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("This should succeed"); + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + 100 * FRACTIONAL_TO_USDC, + ) + .await + .unwrap(); + (user, obligation) + }; + + // deposit 100 WSOL into reserve + let host_fee_receiver = { + let wsol_depositor = User::new_with_balances( + &mut test, + &[ + (&wsol_mint::id(), 5 * LAMPORTS_PER_SOL), + (&wsol_reserve.account.collateral.mint_pubkey, 0), + ], + ) + .await; + + lending_market + .deposit( + &mut test, + &wsol_reserve, + &wsol_depositor, + 5 * LAMPORTS_PER_SOL, + ) + .await + .unwrap(); + + wsol_depositor.get_account(&wsol_mint::id()).unwrap() + }; + + // borrow max amount of SOL + { + lending_market + .borrow_obligation_liquidity( + &mut test, + &wsol_reserve, + &obligation, + &user, + &host_fee_receiver, + u64::MAX, + ) + .await + .unwrap(); + + let obligation_post = test.load_account::(obligation.pubkey).await; + // - usdc ltv is 0.5, + // - sol borrow weight is 1 + // max you can borrow is 100 * 0.5 = 5 SOL + assert_eq!( + obligation_post.account.borrows[0].borrowed_amount_wads, + Decimal::from(LAMPORTS_PER_SOL * 5) + ); + } + + let liquidator = User::new_with_balances( + &mut test, + &[ + (&wsol_mint::id(), 100 * LAMPORTS_TO_SOL), + (&usdc_reserve.account.collateral.mint_pubkey, 0), + (&usdc_mint::id(), 0), + ], + ) + .await; + + // liquidating now would clearly fail because the obligation is healthy + { + let res = lending_market + .liquidate_obligation_and_redeem_reserve_collateral( + &mut test, + &wsol_reserve, + &usdc_reserve, + &obligation, + &liquidator, + u64::MAX, + ) + .await + .err() + .unwrap() + .unwrap(); + assert_eq!( + res, + TransactionError::InstructionError( + 3, + InstructionError::Custom(LendingError::ObligationHealthy as u32) + ) + ); + } + + // what is the minimum borrow weight we need for the obligation to be eligible for liquidation? + // 100 * 0.55 = 5 * 10 * borrow_weight + // => borrow_weight = 1.1 + + // set borrow weight to 1.1 + lending_market + .update_reserve_config( + &mut test, + &lending_market_owner, + &wsol_reserve, + ReserveConfig { + added_borrow_weight_bps: 1_000, + ..wsol_reserve.account.config + }, + wsol_reserve.account.rate_limiter.config, + None, + ) + .await + .unwrap(); + + test.advance_clock_by_slots(1).await; + + // liquidating now should work + { + let balance_checker = BalanceChecker::start(&mut test, &[&liquidator]).await; + lending_market + .liquidate_obligation_and_redeem_reserve_collateral( + &mut test, + &wsol_reserve, + &usdc_reserve, + &obligation, + &liquidator, + u64::MAX, + ) + .await + .unwrap(); + + // how much should be liquidated? + // => borrow value * close factor + // (5 sol * $10 * 1.1) * 0.2 = 11 usd worth of sol => repay ~1.1 sol (approximate because + // there is 1 slot worth of interest that is unaccounted for) + // note that if there were no borrow weight, we would only liquidate 10 usdc. + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + assert!(balance_changes.contains(&TokenBalanceChange { + token_account: liquidator.get_account(&wsol_mint::id()).unwrap(), + mint: wsol_mint::id(), + diff: -1100000002 // ~1.1 SOL + })); + } +} diff --git a/token-lending/program/tests/deposit_reserve_liquidity.rs b/token-lending/program/tests/deposit_reserve_liquidity.rs index 60802ffcda5..7e7da771b88 100644 --- a/token-lending/program/tests/deposit_reserve_liquidity.rs +++ b/token-lending/program/tests/deposit_reserve_liquidity.rs @@ -89,23 +89,25 @@ async fn test_success() { assert_eq!(lending_market.account, lending_market_post.account); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + let expected_usdc_reserve_post = Reserve { + last_update: LastUpdate { + slot: 1000, + stale: true, + }, + liquidity: ReserveLiquidity { + available_amount: usdc_reserve.account.liquidity.available_amount + 1_000_000, + ..usdc_reserve.account.liquidity + }, + collateral: ReserveCollateral { + mint_total_supply: usdc_reserve.account.collateral.mint_total_supply + 1_000_000, + ..usdc_reserve.account.collateral + }, + ..usdc_reserve.account + }; assert_eq!( - usdc_reserve_post.account, - Reserve { - last_update: LastUpdate { - slot: 1000, - stale: true - }, - liquidity: ReserveLiquidity { - available_amount: usdc_reserve.account.liquidity.available_amount + 1_000_000, - ..usdc_reserve.account.liquidity - }, - collateral: ReserveCollateral { - mint_total_supply: usdc_reserve.account.collateral.mint_total_supply + 1_000_000, - ..usdc_reserve.account.collateral - }, - ..usdc_reserve.account - } + usdc_reserve_post.account, expected_usdc_reserve_post, + "{:#?} {:#?}", + usdc_reserve_post.account, expected_usdc_reserve_post ); } 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 b1eda18ff6e..821e32dc32c 100644 --- a/token-lending/program/tests/helpers/mod.rs +++ b/token-lending/program/tests/helpers/mod.rs @@ -53,6 +53,7 @@ pub fn test_reserve_config() -> ReserveConfig { fee_receiver: Keypair::new().pubkey(), protocol_liquidation_fee: 0, protocol_take_rate: 0, + added_borrow_weight_bps: 0, } } @@ -60,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 8626da96e13..4b92ff95fc8 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -4,6 +4,7 @@ use super::{ }; use crate::helpers::*; use solana_program::native_token::LAMPORTS_PER_SOL; +use solend_program::state::RateLimiterConfig; use solend_sdk::{instruction::update_reserve_config, NULL_PUBKEY}; use pyth_sdk_solana::state::PROD_ACCT_SIZE; @@ -25,7 +26,7 @@ use solend_program::{ instruction::{ deposit_obligation_collateral, deposit_reserve_liquidity, init_lending_market, init_reserve, liquidate_obligation_and_redeem_reserve_collateral, redeem_fees, - redeem_reserve_collateral, repay_obligation_liquidity, set_lending_market_owner, + redeem_reserve_collateral, repay_obligation_liquidity, set_lending_market_owner_and_config, withdraw_obligation_collateral, }, processor::process_instruction, @@ -87,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; @@ -96,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), + ]), } } @@ -359,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( @@ -368,6 +374,8 @@ impl SolendProgramTest { price.price, price.conf, price.expo, + price.ema_price, + price.ema_conf, )], None, ) @@ -375,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, @@ -397,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( @@ -567,7 +576,7 @@ impl User { account } - Some(_) => panic!("Token account already exists!"), + Some(t) => t.clone(), } } @@ -598,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 { @@ -632,6 +648,7 @@ impl Info { lending_market_owner: &User, reserve: &Info, config: ReserveConfig, + rate_limiter_config: RateLimiterConfig, oracle: Option<&Oracle>, ) -> Result<(), BanksClientError> { let default_oracle = test @@ -640,11 +657,11 @@ impl Info { .unwrap() .unwrap(); let oracle = oracle.unwrap_or(&default_oracle); - println!("{:?}", oracle); let instructions = [update_reserve_config( solend_program::id(), config, + rate_limiter_config, reserve.pubkey, self.pubkey, lending_market_owner.keypair.pubkey(), @@ -695,19 +712,27 @@ impl Info { user: &User, collateral_amount: u64, ) -> Result<(), BanksClientError> { - let instructions = [redeem_reserve_collateral( - solend_program::id(), - collateral_amount, - user.get_account(&reserve.account.collateral.mint_pubkey) - .unwrap(), - user.get_account(&reserve.account.liquidity.mint_pubkey) - .unwrap(), - reserve.pubkey, - reserve.account.collateral.mint_pubkey, - reserve.account.liquidity.supply_pubkey, - self.pubkey, - user.keypair.pubkey(), - )]; + let instructions = [ + refresh_reserve( + solend_program::id(), + reserve.pubkey, + reserve.account.liquidity.pyth_oracle_pubkey, + reserve.account.liquidity.switchboard_oracle_pubkey, + ), + redeem_reserve_collateral( + solend_program::id(), + collateral_amount, + user.get_account(&reserve.account.collateral.mint_pubkey) + .unwrap(), + user.get_account(&reserve.account.liquidity.mint_pubkey) + .unwrap(), + reserve.pubkey, + reserve.account.collateral.mint_pubkey, + reserve.account.liquidity.supply_pubkey, + self.pubkey, + user.keypair.pubkey(), + ), + ]; test.process_transaction(&instructions, Some(&[&user.keypair])) .await @@ -794,6 +819,7 @@ impl Info { obligation: &Info, extra_reserve: Option<&Info>, ) -> Vec { + let obligation = test.load_account::(obligation.pubkey).await; let reserve_pubkeys: Vec = { let mut r = HashSet::new(); r.extend( @@ -872,8 +898,10 @@ impl Info { host_fee_receiver_pubkey: &Pubkey, liquidity_amount: u64, ) -> Result<(), BanksClientError> { + let obligation = test.load_account::(obligation.pubkey).await; + let mut instructions = self - .build_refresh_instructions(test, obligation, Some(borrow_reserve)) + .build_refresh_instructions(test, &obligation, Some(borrow_reserve)) .await; instructions.push(borrow_obligation_liquidity( @@ -1021,8 +1049,10 @@ impl Info { user: &User, collateral_amount: u64, ) -> Result<(), BanksClientError> { + let obligation = test.load_account::(obligation.pubkey).await; + let mut instructions = self - .build_refresh_instructions(test, obligation, Some(withdraw_reserve)) + .build_refresh_instructions(test, &obligation, Some(withdraw_reserve)) .await; instructions.push( @@ -1076,17 +1106,19 @@ impl Info { .await } - pub async fn set_lending_market_owner( + pub async fn set_lending_market_owner_and_config( &self, test: &mut SolendProgramTest, lending_market_owner: &User, new_owner: &Pubkey, + config: RateLimiterConfig, ) -> Result<(), BanksClientError> { - let instructions = [set_lending_market_owner( + let instructions = [set_lending_market_owner_and_config( solend_program::id(), self.pubkey, lending_market_owner.keypair.pubkey(), *new_owner, + config, )]; test.process_transaction(&instructions, Some(&[&lending_market_owner.keypair])) @@ -1275,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; @@ -1286,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; @@ -1342,6 +1378,8 @@ pub async fn setup_world( } /// Scenario 1 +/// sol = $10 +/// usdc = $1 /// LendingMarket /// - USDC Reserve /// - WSOL Reserve @@ -1454,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_lending_market.rs b/token-lending/program/tests/init_lending_market.rs index 89180a0f8bd..3cf7937c179 100644 --- a/token-lending/program/tests/init_lending_market.rs +++ b/token-lending/program/tests/init_lending_market.rs @@ -12,11 +12,13 @@ use solana_sdk::signer::Signer; use solana_sdk::transaction::TransactionError; use solend_program::error::LendingError; use solend_program::instruction::init_lending_market; -use solend_program::state::{LendingMarket, PROGRAM_VERSION}; +use solend_program::state::{LendingMarket, RateLimiter, PROGRAM_VERSION}; #[tokio::test] async fn test_success() { let mut test = SolendProgramTest::start_new().await; + test.advance_clock_by_slots(1000).await; + let lending_market_owner = User::new_with_balances(&mut test, &[]).await; let lending_market = test @@ -33,6 +35,7 @@ async fn test_success() { token_program_id: spl_token::id(), oracle_program_id: mock_pyth_program::id(), switchboard_oracle_program_id: mock_pyth_program::id(), + rate_limiter: RateLimiter::default(), } ); } @@ -40,6 +43,8 @@ async fn test_success() { #[tokio::test] async fn test_already_initialized() { let mut test = SolendProgramTest::start_new().await; + test.advance_clock_by_slots(1000).await; + let lending_market_owner = User::new_with_balances(&mut test, &[]).await; let keypair = Keypair::new(); 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 f54578c702a..d3bd170f97c 100644 --- a/token-lending/program/tests/init_reserve.rs +++ b/token-lending/program/tests/init_reserve.rs @@ -24,11 +24,14 @@ use solana_sdk::{ transaction::TransactionError, }; use solend_program::state::LastUpdate; +use solend_program::state::RateLimiter; +use solend_program::state::RateLimiterConfig; use solend_program::state::Reserve; use solend_program::state::ReserveCollateral; use solend_program::state::ReserveLiquidity; use solend_program::state::PROGRAM_VERSION; use solend_program::NULL_PUBKEY; + use solend_program::{ error::LendingError, instruction::init_reserve, @@ -166,13 +169,15 @@ 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, mint_total_supply: 1000, supply_pubkey: reserve_collateral_supply_pubkey, }, - config: reserve_config + config: reserve_config, + rate_limiter: RateLimiter::new(RateLimiter::default().config, 1001) } ); } @@ -313,12 +318,18 @@ async fn test_update_reserve_config() { .unwrap(); let new_reserve_config = test_reserve_config(); + let new_rate_limiter_config = RateLimiterConfig { + window_duration: 50, + max_outflow: 100, + }; + lending_market .update_reserve_config( &mut test, &lending_market_owner, &wsol_reserve, new_reserve_config, + new_rate_limiter_config, None, ) .await @@ -329,6 +340,7 @@ async fn test_update_reserve_config() { wsol_reserve_post.account, Reserve { config: new_reserve_config, + rate_limiter: RateLimiter::new(new_rate_limiter_config, 1000), ..wsol_reserve.account } ); @@ -353,6 +365,10 @@ async fn test_update_invalid_oracle_config() { let oracle = test.mints.get(&wsol_mint::id()).unwrap().unwrap(); let new_reserve_config = test_reserve_config(); + let new_rate_limiter_config = RateLimiterConfig { + window_duration: 50, + max_outflow: 100, + }; // Try setting both of the oracles to null: Should fail let res = lending_market @@ -361,6 +377,7 @@ async fn test_update_invalid_oracle_config() { &lending_market_owner, &wsol_reserve, new_reserve_config, + new_rate_limiter_config, Some(&Oracle { pyth_product_pubkey: oracle.pyth_product_pubkey, pyth_price_pubkey: NULL_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/outflow_rate_limits.rs b/token-lending/program/tests/outflow_rate_limits.rs new file mode 100644 index 00000000000..6a685004764 --- /dev/null +++ b/token-lending/program/tests/outflow_rate_limits.rs @@ -0,0 +1,213 @@ +#![cfg(feature = "test-bpf")] + +use solana_program::instruction::InstructionError; +use solana_sdk::native_token::LAMPORTS_PER_SOL; +use solana_sdk::signature::Signer; +use solana_sdk::signer::keypair::Keypair; +use solana_sdk::transaction::TransactionError; + +mod helpers; + +use helpers::solend_program_test::{setup_world, Info, SolendProgramTest, User}; +use solend_sdk::error::LendingError; + +use solend_sdk::state::{LendingMarket, RateLimiterConfig, Reserve, ReserveConfig}; + +use helpers::*; + +use solana_program_test::*; + +use solend_sdk::state::Obligation; + +async fn setup( + wsol_reserve_config: &ReserveConfig, +) -> ( + SolendProgramTest, + Info, + Info, + Info, + User, + Info, + User, + User, + User, +) { + let (mut test, lending_market, usdc_reserve, wsol_reserve, lending_market_owner, user) = + setup_world(&test_reserve_config(), wsol_reserve_config).await; + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("This should succeed"); + + lending_market + .deposit(&mut test, &usdc_reserve, &user, 100_000_000) + .await + .expect("This should succeed"); + + let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; + + lending_market + .deposit_obligation_collateral(&mut test, &usdc_reserve, &obligation, &user, 100_000_000) + .await + .expect("This should succeed"); + + let wsol_depositor = User::new_with_balances( + &mut test, + &[ + (&wsol_mint::id(), 5 * LAMPORTS_PER_SOL), + (&wsol_reserve.account.collateral.mint_pubkey, 0), + ], + ) + .await; + + lending_market + .deposit( + &mut test, + &wsol_reserve, + &wsol_depositor, + 5 * LAMPORTS_PER_SOL, + ) + .await + .unwrap(); + + // populate market price correctly + lending_market + .refresh_reserve(&mut test, &wsol_reserve) + .await + .unwrap(); + + // populate deposit value correctly. + let obligation = test.load_account::(obligation.pubkey).await; + lending_market + .refresh_obligation(&mut test, &obligation) + .await + .unwrap(); + + let lending_market = test.load_account(lending_market.pubkey).await; + let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; + let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; + let obligation = test.load_account::(obligation.pubkey).await; + + let host_fee_receiver = User::new_with_balances(&mut test, &[(&wsol_mint::id(), 0)]).await; + ( + test, + lending_market, + usdc_reserve, + wsol_reserve, + user, + obligation, + host_fee_receiver, + lending_market_owner, + wsol_depositor, + ) +} + +#[tokio::test] +async fn test_outflow_reserve() { + let ( + mut test, + lending_market, + usdc_reserve, + wsol_reserve, + user, + obligation, + host_fee_receiver, + lending_market_owner, + wsol_depositor, + ) = setup(&ReserveConfig { + ..test_reserve_config() + }) + .await; + + // ie, within 10 slots, the maximum outflow is $10 + lending_market + .set_lending_market_owner_and_config( + &mut test, + &lending_market_owner, + &lending_market_owner.keypair.pubkey(), + RateLimiterConfig { + window_duration: 10, + max_outflow: 10, + }, + ) + .await + .unwrap(); + + // borrow max amount + lending_market + .borrow_obligation_liquidity( + &mut test, + &wsol_reserve, + &obligation, + &user, + &host_fee_receiver.get_account(&wsol_mint::id()).unwrap(), + LAMPORTS_PER_SOL, + ) + .await + .unwrap(); + + // for the next 10 slots, we shouldn't be able to withdraw, borrow, or redeem anything. + let cur_slot = test.get_clock().await.slot; + for _ in cur_slot..(cur_slot + 10) { + let res = lending_market + .borrow_obligation_liquidity( + &mut test, + &wsol_reserve, + &obligation, + &user, + &host_fee_receiver.get_account(&wsol_mint::id()).unwrap(), + 1, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + res, + TransactionError::InstructionError( + 3, + InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) + ) + ); + + let res = lending_market + .withdraw_obligation_collateral_and_redeem_reserve_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + 1, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + res, + TransactionError::InstructionError( + 3, + InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) + ) + ); + + let res = lending_market + .redeem(&mut test, &wsol_reserve, &wsol_depositor, 1) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + res, + TransactionError::InstructionError( + 1, + InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) + ) + ); + + test.advance_clock_by_slots(1).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/redeem_reserve_collateral.rs b/token-lending/program/tests/redeem_reserve_collateral.rs index 68b885c6ecf..ac5605e3721 100644 --- a/token-lending/program/tests/redeem_reserve_collateral.rs +++ b/token-lending/program/tests/redeem_reserve_collateral.rs @@ -3,6 +3,7 @@ mod helpers; use crate::solend_program_test::MintSupplyChange; +use solend_sdk::math::Decimal; use std::collections::HashSet; use helpers::solend_program_test::{ @@ -87,7 +88,17 @@ async fn test_success() { let lending_market_post = test .load_account::(lending_market.pubkey) .await; - assert_eq!(lending_market.account, lending_market_post.account); + assert_eq!( + lending_market_post.account, + LendingMarket { + rate_limiter: { + let mut rate_limiter = lending_market.account.rate_limiter; + rate_limiter.update(1000, Decimal::from(1u64)).unwrap(); + rate_limiter + }, + ..lending_market.account + } + ); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; assert_eq!( @@ -105,6 +116,14 @@ async fn test_success() { mint_total_supply: usdc_reserve.account.collateral.mint_total_supply - 1_000_000, ..usdc_reserve.account.collateral }, + rate_limiter: { + let mut rate_limiter = usdc_reserve.account.rate_limiter; + rate_limiter + .update(1000, Decimal::from(1_000_000u64)) + .unwrap(); + + rate_limiter + }, ..usdc_reserve.account } ); @@ -123,9 +142,9 @@ async fn test_fail_redeem_too_much() { match res { // TokenError::Insufficient Funds - TransactionError::InstructionError(0, InstructionError::Custom(1)) => (), + TransactionError::InstructionError(1, InstructionError::Custom(1)) => (), // LendingError::TokenBurnFailed - TransactionError::InstructionError(0, InstructionError::Custom(19)) => (), + TransactionError::InstructionError(1, InstructionError::Custom(19)) => (), _ => panic!("Unexpected error: {:#?}", res), }; } 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 84ffedd084d..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,34 +237,119 @@ 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, &lending_market_owner, &wsol_reserve, wsol_reserve.account.config, - None, + wsol_reserve.account.rate_limiter.config, + 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/set_lending_market_owner.rs b/token-lending/program/tests/set_lending_market_owner.rs index aed837d5d21..f848a73bdd0 100644 --- a/token-lending/program/tests/set_lending_market_owner.rs +++ b/token-lending/program/tests/set_lending_market_owner.rs @@ -16,6 +16,8 @@ use solana_sdk::{ transaction::TransactionError, }; use solend_program::state::LendingMarket; +use solend_program::state::RateLimiterConfig; +use solend_sdk::state::RateLimiter; use solend_program::{error::LendingError, instruction::LendingInstruction}; @@ -30,15 +32,33 @@ async fn setup() -> (SolendProgramTest, Info, User) { async fn test_success() { let (mut test, lending_market, lending_market_owner) = setup().await; let new_owner = Keypair::new(); + let new_config = RateLimiterConfig { + max_outflow: 100, + window_duration: 5, + }; + lending_market - .set_lending_market_owner(&mut test, &lending_market_owner, &new_owner.pubkey()) + .set_lending_market_owner_and_config( + &mut test, + &lending_market_owner, + &new_owner.pubkey(), + new_config, + ) .await .unwrap(); - let lending_market = test + let lending_market_post = test .load_account::(lending_market.pubkey) .await; - assert_eq!(lending_market.account.owner, new_owner.pubkey()); + + assert_eq!( + lending_market_post.account, + LendingMarket { + owner: new_owner.pubkey(), + rate_limiter: RateLimiter::new(new_config, 1000), + ..lending_market_post.account + } + ); } #[tokio::test] @@ -48,7 +68,12 @@ async fn test_invalid_owner() { let new_owner = Keypair::new(); let res = lending_market - .set_lending_market_owner(&mut test, &invalid_owner, &new_owner.pubkey()) + .set_lending_market_owner_and_config( + &mut test, + &invalid_owner, + &new_owner.pubkey(), + RateLimiterConfig::default(), + ) .await .unwrap_err() .unwrap(); @@ -74,7 +99,11 @@ async fn test_owner_not_signer() { AccountMeta::new(lending_market.pubkey, false), AccountMeta::new_readonly(lending_market.account.owner, false), ], - data: LendingInstruction::SetLendingMarketOwner { new_owner }.pack(), + data: LendingInstruction::SetLendingMarketOwnerAndConfig { + new_owner, + rate_limiter_config: RateLimiterConfig::default(), + } + .pack(), }], None, ) diff --git a/token-lending/program/tests/two_prices.rs b/token-lending/program/tests/two_prices.rs new file mode 100644 index 00000000000..ca48a15a436 --- /dev/null +++ b/token-lending/program/tests/two_prices.rs @@ -0,0 +1,487 @@ +#![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); + + test.advance_clock_by_slots(1).await; + + // 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/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs b/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs index 8ca403e41b0..221a0880e11 100644 --- a/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs +++ b/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs @@ -1,8 +1,11 @@ #![cfg(feature = "test-bpf")] +use solend_program::math::TryDiv; mod helpers; use crate::solend_program_test::MintSupplyChange; +use solend_sdk::math::Decimal; +use solend_sdk::state::LendingMarket; use solend_sdk::state::ObligationCollateral; use solend_sdk::state::ReserveCollateral; use std::collections::HashSet; @@ -42,6 +45,7 @@ async fn test_success() { // check token balances let (balance_changes, mint_supply_changes) = balance_checker.find_balance_changes(&mut test).await; + // still borrowing 100usd worth of sol so we need to leave 200usd in the obligation. let withdraw_amount = (100_000 * FRACTIONAL_TO_USDC - 200 * FRACTIONAL_TO_USDC) as i128; let expected_balance_changes = HashSet::from([ @@ -71,6 +75,28 @@ async fn test_success() { ); // check program state + let lending_market_post = test + .load_account::(lending_market.pubkey) + .await; + assert_eq!( + lending_market_post.account, + LendingMarket { + rate_limiter: { + let mut rate_limiter = lending_market.account.rate_limiter; + rate_limiter + .update( + 1000, + Decimal::from(withdraw_amount as u64) + .try_div(Decimal::from(1_000_000u64)) + .unwrap(), + ) + .unwrap(); + rate_limiter + }, + ..lending_market.account + } + ); + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; assert_eq!( usdc_reserve_post.account, @@ -89,6 +115,14 @@ async fn test_success() { - withdraw_amount as u64, ..usdc_reserve.account.collateral }, + rate_limiter: { + let mut rate_limiter = usdc_reserve.account.rate_limiter; + rate_limiter + .update(1000, Decimal::from(withdraw_amount as u64)) + .unwrap(); + + rate_limiter + }, ..usdc_reserve.account } ); diff --git a/token-lending/sdk/src/error.rs b/token-lending/sdk/src/error.rs index 1e15ed63e65..cbf8f845812 100644 --- a/token-lending/sdk/src/error.rs +++ b/token-lending/sdk/src/error.rs @@ -192,6 +192,9 @@ pub enum LendingError { /// Deprecated instruction #[error("Instruction is deprecated")] DeprecatedInstruction, + /// Outflow Rate Limit Exceeded + #[error("Outflow Rate Limit Exceeded")] + OutflowRateLimitExceeded, } impl From for ProgramError { diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index b50adec199a..f1551cba4c2 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -2,7 +2,7 @@ use crate::{ error::LendingError, - state::{ReserveConfig, ReserveFees}, + state::{RateLimiterConfig, ReserveConfig, ReserveFees}, }; use solana_program::{ instruction::{AccountMeta, Instruction}, @@ -41,9 +41,11 @@ pub enum LendingInstruction { /// /// 0. `[writable]` Lending market account. /// 1. `[signer]` Current owner. - SetLendingMarketOwner { + SetLendingMarketOwnerAndConfig { /// The new owner new_owner: Pubkey, + /// The new config + rate_limiter_config: RateLimiterConfig, }, // 2 @@ -379,6 +381,8 @@ pub enum LendingInstruction { UpdateReserveConfig { /// Reserve config to update to config: ReserveConfig, + /// Rate limiter config + rate_limiter_config: RateLimiterConfig, }, // 17 @@ -478,8 +482,16 @@ impl LendingInstruction { } } 1 => { - let (new_owner, _rest) = Self::unpack_pubkey(rest)?; - Self::SetLendingMarketOwner { new_owner } + let (new_owner, rest) = Self::unpack_pubkey(rest)?; + let (window_duration, rest) = Self::unpack_u64(rest)?; + let (max_outflow, _rest) = Self::unpack_u64(rest)?; + Self::SetLendingMarketOwnerAndConfig { + new_owner, + rate_limiter_config: RateLimiterConfig { + window_duration, + max_outflow, + }, + } } 2 => { let (liquidity_amount, rest) = Self::unpack_u64(rest)?; @@ -497,7 +509,8 @@ impl LendingInstruction { let (borrow_limit, rest) = Self::unpack_u64(rest)?; let (fee_receiver, rest) = Self::unpack_pubkey(rest)?; let (protocol_liquidation_fee, rest) = Self::unpack_u8(rest)?; - let (protocol_take_rate, _rest) = Self::unpack_u8(rest)?; + let (protocol_take_rate, rest) = Self::unpack_u8(rest)?; + let (added_borrow_weight_bps, _rest) = Self::unpack_u64(rest)?; Self::InitReserve { liquidity_amount, config: ReserveConfig { @@ -518,6 +531,7 @@ impl LendingInstruction { fee_receiver, protocol_liquidation_fee, protocol_take_rate, + added_borrow_weight_bps, }, } } @@ -579,7 +593,11 @@ impl LendingInstruction { let (borrow_limit, rest) = Self::unpack_u64(rest)?; let (fee_receiver, rest) = Self::unpack_pubkey(rest)?; let (protocol_liquidation_fee, rest) = Self::unpack_u8(rest)?; - let (protocol_take_rate, _rest) = Self::unpack_u8(rest)?; + let (protocol_take_rate, rest) = Self::unpack_u8(rest)?; + let (added_borrow_weight_bps, rest) = Self::unpack_u64(rest)?; + let (window_duration, rest) = Self::unpack_u64(rest)?; + let (max_outflow, _rest) = Self::unpack_u64(rest)?; + Self::UpdateReserveConfig { config: ReserveConfig { optimal_utilization_rate, @@ -599,6 +617,11 @@ impl LendingInstruction { fee_receiver, protocol_liquidation_fee, protocol_take_rate, + added_borrow_weight_bps, + }, + rate_limiter_config: RateLimiterConfig { + window_duration, + max_outflow, }, } } @@ -690,9 +713,14 @@ impl LendingInstruction { buf.extend_from_slice(owner.as_ref()); buf.extend_from_slice(quote_currency.as_ref()); } - Self::SetLendingMarketOwner { new_owner } => { + Self::SetLendingMarketOwnerAndConfig { + new_owner, + rate_limiter_config: config, + } => { buf.push(1); buf.extend_from_slice(new_owner.as_ref()); + buf.extend_from_slice(&config.window_duration.to_le_bytes()); + buf.extend_from_slice(&config.max_outflow.to_le_bytes()); } Self::InitReserve { liquidity_amount, @@ -716,6 +744,7 @@ impl LendingInstruction { fee_receiver, protocol_liquidation_fee, protocol_take_rate, + added_borrow_weight_bps: borrow_weight_bps, }, } => { buf.push(2); @@ -735,6 +764,7 @@ impl LendingInstruction { buf.extend_from_slice(&fee_receiver.to_bytes()); buf.extend_from_slice(&protocol_liquidation_fee.to_le_bytes()); buf.extend_from_slice(&protocol_take_rate.to_le_bytes()); + buf.extend_from_slice(&borrow_weight_bps.to_le_bytes()); } Self::RefreshReserve => { buf.push(3); @@ -785,7 +815,10 @@ impl LendingInstruction { buf.push(15); buf.extend_from_slice(&collateral_amount.to_le_bytes()); } - Self::UpdateReserveConfig { config } => { + Self::UpdateReserveConfig { + config, + rate_limiter_config, + } => { buf.push(16); buf.extend_from_slice(&config.optimal_utilization_rate.to_le_bytes()); buf.extend_from_slice(&config.loan_to_value_ratio.to_le_bytes()); @@ -802,6 +835,9 @@ impl LendingInstruction { buf.extend_from_slice(&config.fee_receiver.to_bytes()); buf.extend_from_slice(&config.protocol_liquidation_fee.to_le_bytes()); buf.extend_from_slice(&config.protocol_take_rate.to_le_bytes()); + buf.extend_from_slice(&config.added_borrow_weight_bps.to_le_bytes()); + buf.extend_from_slice(&rate_limiter_config.window_duration.to_le_bytes()); + buf.extend_from_slice(&rate_limiter_config.max_outflow.to_le_bytes()); } Self::LiquidateObligationAndRedeemReserveCollateral { liquidity_amount } => { buf.push(17); @@ -854,11 +890,12 @@ pub fn init_lending_market( } /// Creates a 'SetLendingMarketOwner' instruction. -pub fn set_lending_market_owner( +pub fn set_lending_market_owner_and_config( program_id: Pubkey, lending_market_pubkey: Pubkey, lending_market_owner: Pubkey, new_owner: Pubkey, + rate_limiter_config: RateLimiterConfig, ) -> Instruction { Instruction { program_id, @@ -866,7 +903,11 @@ pub fn set_lending_market_owner( AccountMeta::new(lending_market_pubkey, false), AccountMeta::new_readonly(lending_market_owner, true), ], - data: LendingInstruction::SetLendingMarketOwner { new_owner }.pack(), + data: LendingInstruction::SetLendingMarketOwnerAndConfig { + new_owner, + rate_limiter_config, + } + .pack(), } } @@ -1002,7 +1043,7 @@ pub fn redeem_reserve_collateral( AccountMeta::new(reserve_pubkey, false), AccountMeta::new(reserve_collateral_mint_pubkey, false), AccountMeta::new(reserve_liquidity_supply_pubkey, false), - AccountMeta::new_readonly(lending_market_pubkey, false), + AccountMeta::new(lending_market_pubkey, false), AccountMeta::new_readonly(lending_market_authority_pubkey, false), AccountMeta::new_readonly(user_transfer_authority_pubkey, true), AccountMeta::new_readonly(spl_token::id(), false), @@ -1155,7 +1196,7 @@ pub fn withdraw_obligation_collateral_and_redeem_reserve_collateral( AccountMeta::new(destination_collateral_pubkey, false), AccountMeta::new(withdraw_reserve_pubkey, false), AccountMeta::new(obligation_pubkey, false), - AccountMeta::new_readonly(lending_market_pubkey, false), + AccountMeta::new(lending_market_pubkey, false), AccountMeta::new_readonly(lending_market_authority_pubkey, false), AccountMeta::new(destination_liquidity_pubkey, false), AccountMeta::new(reserve_collateral_mint_pubkey, false), @@ -1227,7 +1268,7 @@ pub fn borrow_obligation_liquidity( AccountMeta::new(borrow_reserve_pubkey, false), AccountMeta::new(borrow_reserve_liquidity_fee_receiver_pubkey, false), AccountMeta::new(obligation_pubkey, false), - AccountMeta::new_readonly(lending_market_pubkey, false), + AccountMeta::new(lending_market_pubkey, false), AccountMeta::new_readonly(lending_market_authority_pubkey, false), AccountMeta::new_readonly(obligation_owner_pubkey, true), AccountMeta::new_readonly(spl_token::id(), false), @@ -1312,6 +1353,7 @@ pub fn liquidate_obligation( pub fn update_reserve_config( program_id: Pubkey, config: ReserveConfig, + rate_limiter_config: RateLimiterConfig, reserve_pubkey: Pubkey, lending_market_pubkey: Pubkey, lending_market_owner_pubkey: Pubkey, @@ -1335,7 +1377,11 @@ pub fn update_reserve_config( Instruction { program_id, accounts, - data: LendingInstruction::UpdateReserveConfig { config }.pack(), + data: LendingInstruction::UpdateReserveConfig { + config, + rate_limiter_config, + } + .pack(), } } diff --git a/token-lending/sdk/src/math/common.rs b/token-lending/sdk/src/math/common.rs index 878e224fe74..081ee56b0f0 100644 --- a/token-lending/sdk/src/math/common.rs +++ b/token-lending/sdk/src/math/common.rs @@ -10,6 +10,8 @@ pub const WAD: u64 = 1_000_000_000_000_000_000; pub const HALF_WAD: u64 = 500_000_000_000_000_000; /// Scale for percentages pub const PERCENT_SCALER: u64 = 10_000_000_000_000_000; +/// Scale for basis points +pub const BPS_SCALER: u64 = 100_000_000_000_000; /// Try to subtract, return an error on underflow pub trait TrySub: Sized { diff --git a/token-lending/sdk/src/math/decimal.rs b/token-lending/sdk/src/math/decimal.rs index 861c64322fd..e3362f72ac6 100644 --- a/token-lending/sdk/src/math/decimal.rs +++ b/token-lending/sdk/src/math/decimal.rs @@ -26,7 +26,7 @@ construct_uint! { } /// Large decimal values, precise to 18 digits -#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd, Eq, Ord)] +#[derive(Clone, Copy, Default, PartialEq, PartialOrd, Eq, Ord)] pub struct Decimal(pub U192); impl Decimal { @@ -55,6 +55,11 @@ impl Decimal { Self(U192::from(percent as u64 * PERCENT_SCALER)) } + /// Create scaled decimal from bps value + pub fn from_bps(bps: u64) -> Self { + Self::from(bps).try_div(10_000).unwrap() + } + /// Return raw scaled value if it fits within u128 #[allow(clippy::wrong_self_convention)] pub fn to_scaled_val(&self) -> Result { @@ -111,6 +116,12 @@ impl fmt::Display for Decimal { } } +impl fmt::Debug for Decimal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } +} + impl From for Decimal { fn from(val: u64) -> Self { Self(Self::wad() * U192::from(val)) @@ -235,6 +246,12 @@ mod test { assert_eq!(left, right); } + #[test] + fn test_from_bps() { + let left = Decimal::from_bps(190000); + assert_eq!(left, Decimal::from(19u64)); + } + #[test] fn test_to_scaled_val() { assert_eq!( diff --git a/token-lending/sdk/src/oracles.rs b/token-lending/sdk/src/oracles.rs index ed5113bbca0..63f91f799b5 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,28 @@ 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. + // + // the pyth EMA is also updated every time the regular spot price is updated anyways so in + // reality the staleness should never be an issue. + 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 +80,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 +91,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 +100,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 +110,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 +123,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 +152,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 +180,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 +210,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 +228,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 +239,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 +260,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 +271,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 +301,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 +333,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 +362,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/lending_market.rs b/token-lending/sdk/src/state/lending_market.rs index 4edb3e92019..cb199b74bb1 100644 --- a/token-lending/sdk/src/state/lending_market.rs +++ b/token-lending/sdk/src/state/lending_market.rs @@ -25,6 +25,8 @@ pub struct LendingMarket { pub oracle_program_id: Pubkey, /// Oracle (Switchboard) program id pub switchboard_oracle_program_id: Pubkey, + /// Outflow rate limiter denominated in dollars + pub rate_limiter: RateLimiter, } impl LendingMarket { @@ -44,6 +46,7 @@ impl LendingMarket { self.token_program_id = params.token_program_id; self.oracle_program_id = params.oracle_program_id; self.switchboard_oracle_program_id = params.switchboard_oracle_program_id; + self.rate_limiter = RateLimiter::default(); } } @@ -86,6 +89,7 @@ impl Pack for LendingMarket { token_program_id, oracle_program_id, switchboard_oracle_program_id, + rate_limiter, _padding, ) = mut_array_refs![ output, @@ -96,7 +100,8 @@ impl Pack for LendingMarket { PUBKEY_BYTES, PUBKEY_BYTES, PUBKEY_BYTES, - 128 + RATE_LIMITER_LEN, + 128 - RATE_LIMITER_LEN ]; *version = self.version.to_le_bytes(); @@ -106,6 +111,7 @@ impl Pack for LendingMarket { token_program_id.copy_from_slice(self.token_program_id.as_ref()); oracle_program_id.copy_from_slice(self.oracle_program_id.as_ref()); switchboard_oracle_program_id.copy_from_slice(self.switchboard_oracle_program_id.as_ref()); + self.rate_limiter.pack_into_slice(rate_limiter); } /// Unpacks a byte buffer into a [LendingMarketInfo](struct.LendingMarketInfo.html) @@ -120,6 +126,7 @@ impl Pack for LendingMarket { token_program_id, oracle_program_id, switchboard_oracle_program_id, + rate_limiter, _padding, ) = array_refs![ input, @@ -130,7 +137,8 @@ impl Pack for LendingMarket { PUBKEY_BYTES, PUBKEY_BYTES, PUBKEY_BYTES, - 128 + RATE_LIMITER_LEN, + 128 - RATE_LIMITER_LEN ]; let version = u8::from_le_bytes(*version); @@ -147,6 +155,7 @@ impl Pack for LendingMarket { token_program_id: Pubkey::new_from_array(*token_program_id), oracle_program_id: Pubkey::new_from_array(*oracle_program_id), switchboard_oracle_program_id: Pubkey::new_from_array(*switchboard_oracle_program_id), + rate_limiter: RateLimiter::unpack_from_slice(rate_limiter)?, }) } } diff --git a/token-lending/sdk/src/state/mod.rs b/token-lending/sdk/src/state/mod.rs index a14b2566fdf..278804c65bb 100644 --- a/token-lending/sdk/src/state/mod.rs +++ b/token-lending/sdk/src/state/mod.rs @@ -3,11 +3,13 @@ mod last_update; mod lending_market; mod obligation; +mod rate_limiter; mod reserve; pub use last_update::*; pub use lending_market::*; pub use obligation::*; +pub use rate_limiter::*; pub use reserve::*; use crate::math::{Decimal, WAD}; diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index f6071e8193e..110ea1cbfee 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,20 @@ pub struct Obligation { pub borrows: Vec, /// Market value of deposits pub deposited_value: Decimal, - /// 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 * d.current_spot_price for d in deposits) + /// if borrowed_value >= unhealthy_borrow_value, the obligation can be liquidated pub unhealthy_borrow_value: Decimal, } @@ -90,25 +99,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 +418,7 @@ impl Pack for Obligation { borrowed_value, allowed_borrow_value, unhealthy_borrow_value, + borrowed_value_upper_bound, _padding, deposits_len, borrows_len, @@ -381,7 +434,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 +449,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 +505,7 @@ impl Pack for Obligation { borrowed_value, allowed_borrow_value, unhealthy_borrow_value, + borrowed_value_upper_bound, _padding, deposits_len, borrows_len, @@ -465,7 +521,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 +583,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 +595,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 +797,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/rate_limiter.rs b/token-lending/sdk/src/state/rate_limiter.rs new file mode 100644 index 00000000000..fc345daf515 --- /dev/null +++ b/token-lending/sdk/src/state/rate_limiter.rs @@ -0,0 +1,231 @@ +use crate::state::{pack_decimal, unpack_decimal}; +use solana_program::msg; +use solana_program::program_pack::IsInitialized; +use solana_program::{program_error::ProgramError, slot_history::Slot}; + +use crate::{ + error::LendingError, + math::{Decimal, TryAdd, TryDiv, TryMul, TrySub}, +}; +use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; +use solana_program::program_pack::{Pack, Sealed}; + +/// Sliding Window Rate limiter +/// guarantee: at any point, the outflow between [cur_slot - slot.window_duration, cur_slot] +/// is less than 2x max_outflow. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateLimiter { + /// configuration parameters + pub config: RateLimiterConfig, + + // state + /// prev qty is the sum of all outflows from [window_start - config.window_duration, window_start) + prev_qty: Decimal, + /// window_start is the start of the current window + window_start: Slot, + /// cur qty is the sum of all outflows from [window_start, window_start + config.window_duration) + cur_qty: Decimal, +} + +/// Lending market configuration parameters +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RateLimiterConfig { + /// Rate limiter window size in slots + pub window_duration: u64, + /// Rate limiter param. Max outflow of tokens in a window + pub max_outflow: u64, +} + +impl Default for RateLimiterConfig { + fn default() -> Self { + Self { + window_duration: 1, + max_outflow: u64::MAX, + } + } +} + +impl RateLimiter { + /// initialize rate limiter + pub fn new(config: RateLimiterConfig, cur_slot: u64) -> Self { + let slot_start = cur_slot / config.window_duration * config.window_duration; + Self { + config, + prev_qty: Decimal::zero(), + window_start: slot_start, + cur_qty: Decimal::zero(), + } + } + + /// update rate limiter with new quantity. errors if rate limit has been reached + pub fn update(&mut self, cur_slot: u64, qty: Decimal) -> Result<(), ProgramError> { + if cur_slot < self.window_start { + msg!("Current slot is less than window start, which is impossible"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // rate limiter is disabled if window duration == 0. this is here because we don't want to + // brick borrows/withdraws in permissionless pools on program upgrade. + if self.config.window_duration == 0 { + return Ok(()); + } + + // floor wrt window duration + let cur_slot_start = cur_slot / self.config.window_duration * self.config.window_duration; + + // update prev window, current window + match cur_slot_start.cmp(&(self.window_start + self.config.window_duration)) { + // |<-prev window->|<-cur window (cur_slot is in here)->| + std::cmp::Ordering::Less => (), + + // |<-prev window->|<-cur window->| (cur_slot is in here) | + std::cmp::Ordering::Equal => { + self.prev_qty = self.cur_qty; + self.window_start = cur_slot_start; + self.cur_qty = Decimal::zero(); + } + + // |<-prev window->|<-cur window->|<-cur window + 1->| ... | (cur_slot is in here) | + std::cmp::Ordering::Greater => { + self.prev_qty = Decimal::zero(); + self.window_start = cur_slot_start; + self.cur_qty = Decimal::zero(); + } + }; + + // assume the prev_window's outflow is even distributed across the window + // this isn't true, but it's a good enough approximation + let prev_weight = Decimal::from(self.config.window_duration) + .try_sub(Decimal::from(cur_slot - self.window_start + 1))? + .try_div(self.config.window_duration)?; + let cur_outflow = prev_weight.try_mul(self.prev_qty)?.try_add(self.cur_qty)?; + + if cur_outflow.try_add(qty)? > Decimal::from(self.config.max_outflow) { + Err(LendingError::OutflowRateLimitExceeded.into()) + } else { + self.cur_qty = self.cur_qty.try_add(qty)?; + Ok(()) + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_rate_limiter() { + let mut rate_limiter = RateLimiter::new( + RateLimiterConfig { + window_duration: 10, + max_outflow: 100, + }, + 10, + ); + + assert_eq!( + rate_limiter.update(9, Decimal::from(1u64)), + Err(LendingError::InvalidAccountInput.into()) + ); + + // case 1: no prev window, all quantity is taken up in first slot + assert_eq!( + rate_limiter.update(10, Decimal::from(101u64)), + Err(LendingError::OutflowRateLimitExceeded.into()) + ); + assert_eq!(rate_limiter.update(10, Decimal::from(100u64)), Ok(())); + for i in 11..20 { + assert_eq!( + rate_limiter.update(i, Decimal::from(1u64)), + Err(LendingError::OutflowRateLimitExceeded.into()) + ); + } + + // case 2: prev window qty affects cur window's allowed qty. exactly 10 qty frees up every + // slot. + for i in 20..30 { + assert_eq!( + rate_limiter.update(i, Decimal::from(11u64)), + Err(LendingError::OutflowRateLimitExceeded.into()) + ); + + assert_eq!(rate_limiter.update(i, Decimal::from(10u64)), Ok(())); + + assert_eq!( + rate_limiter.update(i, Decimal::from(1u64)), + Err(LendingError::OutflowRateLimitExceeded.into()) + ); + } + + // case 3: new slot is so far ahead, prev window is dropped + assert_eq!(rate_limiter.update(100, Decimal::from(10u64)), Ok(())); + for i in 101..109 { + assert_eq!(rate_limiter.update(i, Decimal::from(10u64)), Ok(())); + } + println!("{:#?}", rate_limiter); + } +} + +impl Default for RateLimiter { + fn default() -> Self { + Self::new( + RateLimiterConfig { + window_duration: 1, + max_outflow: u64::MAX, + }, + 1, + ) + } +} + +impl Sealed for RateLimiter {} + +impl IsInitialized for RateLimiter { + fn is_initialized(&self) -> bool { + true + } +} + +/// Size of RateLimiter when packed into account +pub const RATE_LIMITER_LEN: usize = 56; +impl Pack for RateLimiter { + const LEN: usize = RATE_LIMITER_LEN; + + fn pack_into_slice(&self, dst: &mut [u8]) { + let dst = array_mut_ref![dst, 0, RATE_LIMITER_LEN]; + let ( + config_max_outflow_dst, + config_window_duration_dst, + prev_qty_dst, + window_start_dst, + cur_qty_dst, + ) = mut_array_refs![dst, 8, 8, 16, 8, 16]; + *config_max_outflow_dst = self.config.max_outflow.to_le_bytes(); + *config_window_duration_dst = self.config.window_duration.to_le_bytes(); + pack_decimal(self.prev_qty, prev_qty_dst); + *window_start_dst = self.window_start.to_le_bytes(); + pack_decimal(self.cur_qty, cur_qty_dst); + } + + fn unpack_from_slice(src: &[u8]) -> Result { + let src = array_ref![src, 0, RATE_LIMITER_LEN]; + let ( + config_max_outflow_src, + config_window_duration_src, + prev_qty_src, + window_start_src, + cur_qty_src, + ) = array_refs![src, 8, 8, 16, 8, 16]; + + Ok(Self { + config: RateLimiterConfig { + max_outflow: u64::from_le_bytes(*config_max_outflow_src), + window_duration: u64::from_le_bytes(*config_window_duration_src), + }, + prev_qty: unpack_decimal(prev_qty_src), + window_start: u64::from_le_bytes(*window_start_src), + cur_qty: unpack_decimal(cur_qty_src), + }) + } +} diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index 10a61897a2f..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}, }; @@ -41,6 +41,8 @@ pub struct Reserve { pub collateral: ReserveCollateral, /// Reserve configuration values pub config: ReserveConfig, + /// Outflow Rate Limiter (denominated in tokens) + pub rate_limiter: RateLimiter, } impl Reserve { @@ -59,6 +61,71 @@ impl Reserve { self.liquidity = params.liquidity; self.collateral = params.collateral; self.config = params.config; + self.rate_limiter = RateLimiter::new(params.rate_limiter_config, params.current_slot); + } + + /// get borrow weight. Guaranteed to be greater than 1 + pub fn borrow_weight(&self) -> Decimal { + Decimal::one() + .try_add(Decimal::from_bps(self.config.added_borrow_weight_bps)) + .unwrap() + } + + /// 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 + .try_mul(liquidity_amount)? + .try_div(Decimal::from( + (10u128) + .checked_pow(self.liquidity.mint_decimals as u32) + .ok_or(LendingError::MathOverflow)?, + )) + } + + /// 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 @@ -168,7 +235,11 @@ 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()); let (borrow_fee, host_fee) = self @@ -195,9 +266,9 @@ impl Reserve { .calculate_borrow_fees(borrow_amount, FeeCalculation::Exclusive)?; let borrow_amount = borrow_amount.try_add(borrow_fee.into())?; - let borrow_value = borrow_amount - .try_mul(self.liquidity.market_price)? - .try_div(decimals)?; + let borrow_value = self + .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"); return Err(LendingError::BorrowTooLarge.into()); @@ -359,10 +430,12 @@ pub struct InitReserveParams { pub collateral: ReserveCollateral, /// Reserve configuration values pub config: ReserveConfig, + /// rate limiter config + pub rate_limiter_config: RateLimiterConfig, } /// Calculate borrow result -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CalculateBorrowResult { /// Total amount of borrow including fees pub borrow_amount: Decimal, @@ -418,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 { @@ -434,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, } } @@ -563,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 @@ -698,6 +776,9 @@ pub struct ReserveConfig { pub protocol_liquidation_fee: u8, /// Protocol take rate is the amount borrowed interest protocol recieves, as a percentage pub protocol_take_rate: u8, + /// Added borrow weight in basis points. THIS FIELD SHOULD NEVER BE USED DIRECTLY. Always use + /// borrow_weight() + pub added_borrow_weight_bps: u64, } /// Additional fee information on a reserve @@ -854,6 +935,9 @@ impl Pack for Reserve { config_protocol_liquidation_fee, config_protocol_take_rate, liquidity_accumulated_protocol_fees_wads, + rate_limiter, + config_added_borrow_weight_bps, + liquidity_smoothed_market_price, _padding, ) = mut_array_refs![ output, @@ -889,7 +973,10 @@ impl Pack for Reserve { 1, 1, 16, - 230 + RATE_LIMITER_LEN, + 8, + 16, + 150 ]; // reserve @@ -919,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()); @@ -941,6 +1032,10 @@ impl Pack for Reserve { config_fee_receiver.copy_from_slice(self.config.fee_receiver.as_ref()); *config_protocol_liquidation_fee = self.config.protocol_liquidation_fee.to_le_bytes(); *config_protocol_take_rate = self.config.protocol_take_rate.to_le_bytes(); + + self.rate_limiter.pack_into_slice(rate_limiter); + + *config_added_borrow_weight_bps = self.config.added_borrow_weight_bps.to_le_bytes(); } /// Unpacks a byte buffer into a [ReserveInfo](struct.ReserveInfo.html). @@ -980,6 +1075,9 @@ impl Pack for Reserve { config_protocol_liquidation_fee, config_protocol_take_rate, liquidity_accumulated_protocol_fees_wads, + rate_limiter, + config_added_borrow_weight_bps, + liquidity_smoothed_market_price, _padding, ) = array_refs![ input, @@ -1015,7 +1113,10 @@ impl Pack for Reserve { 1, 1, 16, - 230 + RATE_LIMITER_LEN, + 8, + 16, + 150 ]; let version = u8::from_le_bytes(*version); @@ -1046,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), @@ -1070,7 +1172,9 @@ impl Pack for Reserve { fee_receiver: Pubkey::new_from_array(*config_fee_receiver), protocol_liquidation_fee: u8::from_le_bytes(*config_protocol_liquidation_fee), protocol_take_rate: u8::from_le_bytes(*config_protocol_take_rate), + added_borrow_weight_bps: u64::from_le_bytes(*config_added_borrow_weight_bps), }, + rate_limiter: RateLimiter::unpack_from_slice(rate_limiter)?, }) } } @@ -1080,6 +1184,7 @@ mod test { use super::*; use crate::math::{PERCENT_SCALER, WAD}; use proptest::prelude::*; + use solana_program::native_token::LAMPORTS_PER_SOL; use std::cmp::Ordering; use std::default::Default; @@ -1601,4 +1706,201 @@ mod test { test_case.liquidation_result); } } + + #[derive(Debug, Clone)] + struct CalculateBorrowTestCase { + // args + borrow_amount: u64, + remaining_borrow_value: Decimal, + remaining_reserve_capacity: Decimal, + + // reserve state + market_price: Decimal, + smoothed_market_price: Decimal, + decimal: u8, + added_borrow_weight_bps: u64, + + borrow_fee_wad: u64, + host_fee: u8, + + result: Result, + } + + fn calculate_borrow_test_cases() -> impl Strategy { + // borrow fee is 1%, host fee is 20% on all test cases + prop_oneof![ + Just(CalculateBorrowTestCase { + borrow_amount: LAMPORTS_PER_SOL, + remaining_borrow_value: Decimal::from(10u64), + 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, + + borrow_fee_wad: 10_000_000_000_000_000, // 1% + host_fee: 20, + + result: Ok(CalculateBorrowResult { + borrow_amount: Decimal::from(LAMPORTS_PER_SOL * 101 / 100), + receive_amount: LAMPORTS_PER_SOL, + borrow_fee: LAMPORTS_PER_SOL / 100, + host_fee: LAMPORTS_PER_SOL / 100 / 100 * 20 + }), + }), + // borrow max + Just(CalculateBorrowTestCase { + borrow_amount: u64::MAX, + remaining_borrow_value: Decimal::from(10u64), + 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, + + borrow_fee_wad: 10_000_000_000_000_000, // 1% + host_fee: 20, + + result: Ok(CalculateBorrowResult { + borrow_amount: Decimal::from(LAMPORTS_PER_SOL * 101 / 100), + receive_amount: LAMPORTS_PER_SOL, + borrow_fee: LAMPORTS_PER_SOL / 100, + host_fee: LAMPORTS_PER_SOL / 100 / 100 * 20 + }), + }), + // borrow weight is 2, can only borrow 0.5 sol + Just(CalculateBorrowTestCase { + borrow_amount: LAMPORTS_PER_SOL / 2, + remaining_borrow_value: Decimal::from(1u64), + 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, + + borrow_fee_wad: 0, + host_fee: 0, + + result: Ok(CalculateBorrowResult { + borrow_amount: Decimal::from(LAMPORTS_PER_SOL / 2), + receive_amount: LAMPORTS_PER_SOL / 2, + borrow_fee: 0, + host_fee: 0, + }), + }), + // borrow weight is 2, can only max borrow 0.5 sol + Just(CalculateBorrowTestCase { + borrow_amount: u64::MAX, + remaining_borrow_value: Decimal::from(1u64), + 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, + + borrow_fee_wad: 0, + host_fee: 0, + + result: Ok(CalculateBorrowResult { + borrow_amount: Decimal::from(LAMPORTS_PER_SOL / 2), + receive_amount: LAMPORTS_PER_SOL / 2, + borrow_fee: 0, + 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()), + }), + ] + } + + proptest! { + #[test] + fn calculate_borrow(test_case in calculate_borrow_test_cases()) { + let reserve = Reserve { + config: ReserveConfig { + added_borrow_weight_bps: test_case.added_borrow_weight_bps, + fees: ReserveFees { + borrow_fee_wad: test_case.borrow_fee_wad, + host_fee_percentage: test_case.host_fee, + flash_loan_fee_wad: 0, + }, + ..ReserveConfig::default() + }, + 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() + }, + ..Reserve::default() + }; + assert_eq!(reserve.calculate_borrow( + test_case.borrow_amount, + test_case.remaining_borrow_value, + test_case.remaining_reserve_capacity, + ), test_case.result); + } + } }