From 95a4e5554c8a1f2f0fdee39388ca05e0e40a7966 Mon Sep 17 00:00:00 2001 From: 0xripleys <105607696+0xripleys@users.noreply.github.com> Date: Fri, 24 Feb 2023 16:02:19 -0500 Subject: [PATCH] 0xripleys outflow limits (#125) Use a sliding window rate limiter to limit borrows and withdraws at the lending pool owner's discretion. --- token-lending/cli/src/main.rs | 5 + token-lending/program/src/processor.rs | 89 ++++++- .../tests/borrow_obligation_liquidity.rs | 236 +++++++++++++++--- .../tests/deposit_reserve_liquidity.rs | 34 +-- .../tests/helpers/solend_program_test.rs | 55 ++-- .../program/tests/init_lending_market.rs | 7 +- token-lending/program/tests/init_reserve.rs | 18 +- .../program/tests/outflow_rate_limits.rs | 213 ++++++++++++++++ .../tests/redeem_reserve_collateral.rs | 25 +- .../program/tests/refresh_reserve.rs | 1 + .../program/tests/set_lending_market_owner.rs | 39 ++- ...ollateral_and_redeem_reserve_collateral.rs | 34 +++ token-lending/sdk/src/error.rs | 3 + token-lending/sdk/src/instruction.rs | 65 ++++- token-lending/sdk/src/state/lending_market.rs | 15 +- token-lending/sdk/src/state/mod.rs | 2 + token-lending/sdk/src/state/rate_limiter.rs | 225 +++++++++++++++++ token-lending/sdk/src/state/reserve.rs | 28 ++- 18 files changed, 985 insertions(+), 109 deletions(-) create mode 100644 token-lending/program/tests/outflow_rate_limits.rs create mode 100644 token-lending/sdk/src/state/rate_limiter.rs diff --git a/token-lending/cli/src/main.rs b/token-lending/cli/src/main.rs index abe1b341cbe..0ec4a984dfd 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::state::{RateLimiterConfig, SLOTS_PER_YEAR}; use solend_sdk::{ instruction::{ liquidate_obligation_and_redeem_reserve_collateral, redeem_reserve_collateral, @@ -1675,6 +1676,10 @@ fn command_update_reserve( &[update_reserve_config( config.lending_program_id, reserve.config, + RateLimiterConfig { + window_duration: SLOTS_PER_YEAR / 365, + max_outflow: u64::MAX, + }, 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..ecdb09e4218 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"); @@ -190,6 +202,7 @@ fn process_init_lending_market( token_program_id: *token_program_id.key, oracle_program_id: *oracle_program_id.key, switchboard_oracle_program_id: *switchboard_oracle_program_id.key, + current_slot: Clock::get()?.slot, }); LendingMarket::pack(lending_market, &mut lending_market_info.data.borrow_mut())?; @@ -197,9 +210,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 +235,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(()) @@ -347,6 +366,7 @@ fn process_init_reserve( supply_pubkey: *reserve_collateral_supply_info.key, }), config, + rate_limiter_config: RateLimiterConfig::default(), }); let collateral_amount = reserve.deposit_liquidity(liquidity_amount)?; @@ -659,7 +679,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 +692,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 +715,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 +771,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(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(), @@ -1359,7 +1403,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()); @@ -1477,6 +1521,27 @@ 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(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 +1950,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 +2025,7 @@ fn process_withdraw_obligation_collateral_and_redeem_reserve_liquidity( user_transfer_authority_info, clock, token_program_id, + true, )?; Ok(()) } @@ -1967,6 +2034,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 +2092,11 @@ fn process_update_reserve_config( return Err(LendingError::InvalidMarketAuthority.into()); } + // if window duration and 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; 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/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/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index 8626da96e13..c81886571ef 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, @@ -632,6 +633,7 @@ impl Info { lending_market_owner: &User, reserve: &Info, config: ReserveConfig, + rate_limiter_config: RateLimiterConfig, oracle: Option<&Oracle>, ) -> Result<(), BanksClientError> { let default_oracle = test @@ -645,6 +647,7 @@ impl Info { let instructions = [update_reserve_config( solend_program::id(), config, + rate_limiter_config, reserve.pubkey, self.pubkey, lending_market_owner.keypair.pubkey(), @@ -695,19 +698,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 @@ -872,8 +883,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 +1034,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 +1091,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])) @@ -1342,6 +1359,8 @@ pub async fn setup_world( } /// Scenario 1 +/// sol = $10 +/// usdc = $1 /// LendingMarket /// - USDC Reserve /// - WSOL Reserve 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_reserve.rs b/token-lending/program/tests/init_reserve.rs index f54578c702a..88f98e51f6a 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, @@ -172,7 +175,8 @@ async fn test_success() { 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 +317,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 +339,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 +364,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 +376,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/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_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_reserve.rs b/token-lending/program/tests/refresh_reserve.rs index 84ffedd084d..67c1751a0b1 100644 --- a/token-lending/program/tests/refresh_reserve.rs +++ b/token-lending/program/tests/refresh_reserve.rs @@ -240,6 +240,7 @@ async fn test_success_pyth_price_stale_switchboard_valid() { &lending_market_owner, &wsol_reserve, wsol_reserve.account.config, + wsol_reserve.account.rate_limiter.config, None, ) .await 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/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..e408dc53b42 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)?; @@ -579,7 +591,10 @@ 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 (window_duration, rest) = Self::unpack_u64(rest)?; + let (max_outflow, _rest) = Self::unpack_u64(rest)?; + Self::UpdateReserveConfig { config: ReserveConfig { optimal_utilization_rate, @@ -600,6 +615,10 @@ impl LendingInstruction { protocol_liquidation_fee, protocol_take_rate, }, + rate_limiter_config: RateLimiterConfig { + window_duration, + max_outflow, + }, } } 17 => { @@ -690,9 +709,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, @@ -785,7 +809,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 +829,8 @@ 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(&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 +883,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 +896,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 +1036,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 +1189,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 +1261,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 +1346,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 +1370,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/state/lending_market.rs b/token-lending/sdk/src/state/lending_market.rs index 4edb3e92019..7be5ab2cb8c 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(); } } @@ -62,6 +65,8 @@ pub struct InitLendingMarketParams { pub oracle_program_id: Pubkey, /// Oracle (Switchboard) program id pub switchboard_oracle_program_id: Pubkey, + /// Current slot + pub current_slot: u64, } impl Sealed for LendingMarket {} @@ -86,6 +91,7 @@ impl Pack for LendingMarket { token_program_id, oracle_program_id, switchboard_oracle_program_id, + rate_limiter, _padding, ) = mut_array_refs![ output, @@ -96,7 +102,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 +113,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 +128,7 @@ impl Pack for LendingMarket { token_program_id, oracle_program_id, switchboard_oracle_program_id, + rate_limiter, _padding, ) = array_refs![ input, @@ -130,7 +139,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 +157,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/rate_limiter.rs b/token-lending/sdk/src/state/rate_limiter.rs new file mode 100644 index 00000000000..935cd98cbdd --- /dev/null +++ b/token-lending/sdk/src/state/rate_limiter.rs @@ -0,0 +1,225 @@ +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()); + } + + // 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..82cc3558ef1 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -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,19 @@ 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); + } + + /// find price of tokens in quote currency + 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)?, + )) } /// Record deposited liquidity and return amount of collateral tokens to mint @@ -359,6 +374,8 @@ pub struct InitReserveParams { pub collateral: ReserveCollateral, /// Reserve configuration values pub config: ReserveConfig, + /// rate limiter config + pub rate_limiter_config: RateLimiterConfig, } /// Calculate borrow result @@ -854,6 +871,7 @@ impl Pack for Reserve { config_protocol_liquidation_fee, config_protocol_take_rate, liquidity_accumulated_protocol_fees_wads, + rate_limiter, _padding, ) = mut_array_refs![ output, @@ -889,7 +907,8 @@ impl Pack for Reserve { 1, 1, 16, - 230 + RATE_LIMITER_LEN, + 230 - RATE_LIMITER_LEN ]; // reserve @@ -941,6 +960,8 @@ 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); } /// Unpacks a byte buffer into a [ReserveInfo](struct.ReserveInfo.html). @@ -980,6 +1001,7 @@ impl Pack for Reserve { config_protocol_liquidation_fee, config_protocol_take_rate, liquidity_accumulated_protocol_fees_wads, + rate_limiter, _padding, ) = array_refs![ input, @@ -1015,7 +1037,8 @@ impl Pack for Reserve { 1, 1, 16, - 230 + RATE_LIMITER_LEN, + 230 - RATE_LIMITER_LEN ]; let version = u8::from_le_bytes(*version); @@ -1071,6 +1094,7 @@ impl Pack for Reserve { protocol_liquidation_fee: u8::from_le_bytes(*config_protocol_liquidation_fee), protocol_take_rate: u8::from_le_bytes(*config_protocol_take_rate), }, + rate_limiter: RateLimiter::unpack_from_slice(rate_limiter)?, }) } }