diff --git a/token-lending/cli/src/main.rs b/token-lending/cli/src/main.rs index c590542e59d..167bcd3d078 100644 --- a/token-lending/cli/src/main.rs +++ b/token-lending/cli/src/main.rs @@ -1153,7 +1153,10 @@ fn main() { let added_borrow_weight_bps = value_of(arg_matches, "added_borrow_weight_bps").unwrap(); let reserve_type = value_of(arg_matches, "reserve_type").unwrap(); - let attributed_borrow_limit = value_of(arg_matches, "attributed_borrow_limit").unwrap(); + let attributed_borrow_limit_open = + value_of(arg_matches, "attributed_borrow_limit_open").unwrap(); + let attributed_borrow_limit_close = + value_of(arg_matches, "attributed_borrow_limit_close").unwrap(); let borrow_fee_wad = (borrow_fee * WAD as f64) as u64; let flash_loan_fee_wad = (flash_loan_fee * WAD as f64) as u64; @@ -1207,7 +1210,8 @@ fn main() { protocol_take_rate, added_borrow_weight_bps, reserve_type, - attributed_borrow_limit, + attributed_borrow_limit_open, + attributed_borrow_limit_close, }, source_liquidity_pubkey, source_liquidity_owner_keypair, diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 7855b0a7e20..7b8ca676528 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -1,5 +1,6 @@ //! Program state processor +use crate::state::Bonus; use crate::{ self as solend_program, error::LendingError, @@ -15,6 +16,7 @@ use crate::{ }; use bytemuck::bytes_of; use pyth_sdk_solana::{self, state::ProductAccount}; + use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, @@ -205,6 +207,10 @@ pub fn process_instruction( msg!("Instruction: Resize Reserve"); process_resize_reserve(program_id, accounts) } + LendingInstruction::SetObligationCloseabilityStatus { closeable } => { + msg!("Instruction: Mark Obligation As Closable"); + process_set_obligation_closeability_status(program_id, closeable, accounts) + } } } @@ -1081,7 +1087,10 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> obligation.last_update.update_slot(clock.slot); - update_borrow_attribution_values(&mut obligation, &accounts[1..], false)?; + let (_, close_exceeded) = update_borrow_attribution_values(&mut obligation, &accounts[1..])?; + if close_exceeded.is_none() { + obligation.closeable = false; + } // move the ObligationLiquidity with the max borrow weight to the front if let Some((_, max_borrow_weight_index)) = max_borrow_weight { @@ -1113,10 +1122,12 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> fn update_borrow_attribution_values( obligation: &mut Obligation, deposit_reserve_infos: &[AccountInfo], - error_if_limit_exceeded: bool, -) -> ProgramResult { +) -> Result<(Option, Option), ProgramError> { let deposit_infos = &mut deposit_reserve_infos.iter(); + let mut open_exceeded = None; + let mut close_exceeded = None; + for collateral in obligation.deposits.iter_mut() { let deposit_reserve_info = next_account_info(deposit_infos)?; let mut deposit_reserve = Reserve::unpack(&deposit_reserve_info.data.borrow())?; @@ -1127,11 +1138,9 @@ fn update_borrow_attribution_values( return Err(LendingError::InvalidAccountInput.into()); } - if obligation.updated_borrow_attribution_after_upgrade { - deposit_reserve.attributed_borrow_value = deposit_reserve - .attributed_borrow_value - .saturating_sub(collateral.attributed_borrow_value); - } + deposit_reserve.attributed_borrow_value = deposit_reserve + .attributed_borrow_value + .saturating_sub(collateral.attributed_borrow_value); if obligation.deposited_value > Decimal::zero() { collateral.attributed_borrow_value = collateral @@ -1146,24 +1155,21 @@ fn update_borrow_attribution_values( .attributed_borrow_value .try_add(collateral.attributed_borrow_value)?; - if error_if_limit_exceeded - && deposit_reserve.attributed_borrow_value - > Decimal::from(deposit_reserve.config.attributed_borrow_limit) + if deposit_reserve.attributed_borrow_value + > Decimal::from(deposit_reserve.config.attributed_borrow_limit_open) { - msg!( - "Attributed borrow value is over the limit for reserve {} and mint {}", - deposit_reserve_info.key, - deposit_reserve.liquidity.mint_pubkey - ); - return Err(LendingError::BorrowAttributionLimitExceeded.into()); + open_exceeded = Some(*deposit_reserve_info.key); + } + if deposit_reserve.attributed_borrow_value + > Decimal::from(deposit_reserve.config.attributed_borrow_limit_close) + { + close_exceeded = Some(*deposit_reserve_info.key); } Reserve::pack(deposit_reserve, &mut deposit_reserve_info.data.borrow_mut())?; } - obligation.updated_borrow_attribution_after_upgrade = true; - - Ok(()) + Ok((open_exceeded, close_exceeded)) } #[inline(never)] // avoid stack frame limit @@ -1550,7 +1556,15 @@ fn _withdraw_obligation_collateral<'a>( .market_value .saturating_sub(withdraw_value); - update_borrow_attribution_values(&mut obligation, deposit_reserve_infos, true)?; + let (open_exceeded, _) = + update_borrow_attribution_values(&mut obligation, deposit_reserve_infos)?; + if let Some(reserve_pubkey) = open_exceeded { + msg!( + "Open borrow attribution limit exceeded for reserve {:?}", + reserve_pubkey + ); + return Err(LendingError::BorrowAttributionLimitExceeded.into()); + } // obligation.withdraw must be called after updating borrow attribution values, since we can // lose information if an entire deposit is removed, making the former calculation incorrect @@ -1805,8 +1819,16 @@ fn process_borrow_obligation_liquidity( obligation_liquidity.borrow(borrow_amount)?; obligation.last_update.mark_stale(); - update_borrow_attribution_values(&mut obligation, &accounts[9..], true)?; - // HACK: fast forward through the used account info's + let (open_exceeded, _) = update_borrow_attribution_values(&mut obligation, &accounts[9..])?; + if let Some(reserve_pubkey) = open_exceeded { + msg!( + "Open borrow attribution limit exceeded for reserve {:?}", + reserve_pubkey + ); + return Err(LendingError::BorrowAttributionLimitExceeded.into()); + } + + // HACK: fast forward through the deposit reserve infos for _ in 0..obligation.deposits.len() { next_account_info(account_info_iter)?; } @@ -1975,7 +1997,7 @@ fn _liquidate_obligation<'a>( user_transfer_authority_info: &AccountInfo<'a>, clock: &Clock, token_program_id: &AccountInfo<'a>, -) -> Result<(u64, Decimal), ProgramError> { +) -> Result<(u64, Bonus), ProgramError> { let 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"); @@ -2061,8 +2083,9 @@ fn _liquidate_obligation<'a>( msg!("Obligation borrowed value is zero"); return Err(LendingError::ObligationBorrowsZero.into()); } - if obligation.borrowed_value < obligation.unhealthy_borrow_value { - msg!("Obligation is healthy and cannot be liquidated"); + + if obligation.borrowed_value < obligation.unhealthy_borrow_value && !obligation.closeable { + msg!("Obligation must be unhealthy or marked as closeable to be liquidated"); return Err(LendingError::ObligationHealthy.into()); } @@ -2104,16 +2127,17 @@ fn _liquidate_obligation<'a>( return Err(LendingError::InvalidMarketAuthority.into()); } + let bonus = withdraw_reserve.calculate_bonus(&obligation)?; let CalculateLiquidationResult { settle_amount, repay_amount, withdraw_amount, - bonus_rate, } = withdraw_reserve.calculate_liquidation( liquidity_amount, &obligation, liquidity, collateral, + &bonus, )?; if repay_amount == 0 { @@ -2168,7 +2192,7 @@ fn _liquidate_obligation<'a>( token_program: token_program_id.clone(), })?; - Ok((withdraw_amount, bonus_rate)) + Ok((withdraw_amount, bonus)) } #[inline(never)] // avoid stack frame limit @@ -2200,7 +2224,7 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( let token_program_id = next_account_info(account_info_iter)?; let clock = &Clock::get()?; - let (withdrawn_collateral_amount, bonus_rate) = _liquidate_obligation( + let (withdrawn_collateral_amount, bonus) = _liquidate_obligation( program_id, liquidity_amount, source_liquidity_info, @@ -2247,7 +2271,7 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( return Err(LendingError::InvalidAccountInput.into()); } let protocol_fee = withdraw_reserve - .calculate_protocol_liquidation_fee(withdraw_liquidity_amount, bonus_rate)?; + .calculate_protocol_liquidation_fee(withdraw_liquidity_amount, &bonus)?; spl_token_transfer(TokenTransferParams { source: destination_liquidity_info.clone(), @@ -3115,6 +3139,86 @@ pub fn process_resize_reserve(_program_id: &Pubkey, accounts: &[AccountInfo]) -> Ok(()) } +/// process mark obligation as closable +pub fn process_set_obligation_closeability_status( + program_id: &Pubkey, + closeable: bool, + accounts: &[AccountInfo], +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let obligation_info = next_account_info(account_info_iter)?; + let lending_market_info = next_account_info(account_info_iter)?; + let reserve_info = next_account_info(account_info_iter)?; + let signer_info = next_account_info(account_info_iter)?; + let clock = Clock::get()?; + + let 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()); + } + + let reserve = Reserve::unpack(&reserve_info.data.borrow())?; + if reserve_info.owner != program_id { + msg!("Reserve provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + if &reserve.lending_market != lending_market_info.key { + msg!("Reserve lending market does not match the lending market provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if reserve.attributed_borrow_value < Decimal::from(reserve.config.attributed_borrow_limit_close) + { + msg!("Reserve attributed borrow value is below the attributed borrow limit"); + return Err(LendingError::BorrowAttributionLimitNotExceeded.into()); + } + + let mut obligation = Obligation::unpack(&obligation_info.data.borrow())?; + if obligation_info.owner != program_id { + msg!("Obligation provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + + if &obligation.lending_market != lending_market_info.key { + msg!("Obligation lending market does not match the lending market provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + if obligation.last_update.is_stale(clock.slot)? { + msg!("Obligation is stale and must be refreshed"); + return Err(LendingError::ObligationStale.into()); + } + + if &lending_market.risk_authority != signer_info.key && &lending_market.owner != signer_info.key + { + msg!("Signer must be risk authority or lending market owner"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if !signer_info.is_signer { + msg!("Risk authority or lending market owner must be a signer"); + return Err(LendingError::InvalidSigner.into()); + } + + if obligation.borrowed_value == Decimal::zero() { + msg!("Obligation borrowed value is zero"); + return Err(LendingError::ObligationBorrowsZero.into()); + } + + obligation + .find_collateral_in_deposits(*reserve_info.key) + .map_err(|_| { + msg!("Obligation does not have a deposit for the reserve provided"); + LendingError::ObligationCollateralEmpty + })?; + + obligation.closeable = closeable; + + Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; + + Ok(()) +} + fn assert_uninitialized( account_info: &AccountInfo, ) -> Result { diff --git a/token-lending/program/tests/attributed_borrows.rs b/token-lending/program/tests/attributed_borrows.rs index 2e2851d9e9a..6d62ccfb657 100644 --- a/token-lending/program/tests/attributed_borrows.rs +++ b/token-lending/program/tests/attributed_borrows.rs @@ -1,17 +1,10 @@ #![cfg(feature = "test-bpf")] -use solana_sdk::compute_budget::ComputeBudgetInstruction; -use solend_sdk::instruction::refresh_obligation; - use crate::solend_program_test::custom_scenario; -use crate::solend_program_test::SolendProgramTest; + use crate::solend_program_test::User; -use solana_sdk::pubkey::Pubkey; + use solend_program::math::TryDiv; -use solend_program::processor::process_instruction; -use solend_sdk::state::ObligationCollateral; -use solend_sdk::state::ObligationLiquidity; -use solend_sdk::state::PROGRAM_VERSION; use solana_sdk::instruction::InstructionError; use solana_sdk::transaction::TransactionError; @@ -157,7 +150,7 @@ async fn test_refresh_obligation() { &lending_market_owner, &reserves[0], ReserveConfig { - attributed_borrow_limit: 1, + attributed_borrow_limit_open: 1, ..reserves[0].account.config }, reserves[0].account.rate_limiter.config, @@ -295,7 +288,7 @@ async fn test_calculations() { &lending_market_owner, &reserves[0], ReserveConfig { - attributed_borrow_limit: 113, + attributed_borrow_limit_open: 113, ..reserves[0].account.config }, reserves[0].account.rate_limiter.config, @@ -333,7 +326,7 @@ async fn test_calculations() { &lending_market_owner, &reserves[0], ReserveConfig { - attributed_borrow_limit: 120, + attributed_borrow_limit_open: 120, ..reserves[0].account.config }, reserves[0].account.rate_limiter.config, @@ -386,7 +379,7 @@ async fn test_calculations() { }, attributed_borrow_value: Decimal::from(120u64), config: ReserveConfig { - attributed_borrow_limit: 120, + attributed_borrow_limit_open: 120, ..usdc_reserve.config }, ..usdc_reserve @@ -619,7 +612,7 @@ async fn test_withdraw() { &lending_market_owner, &reserves[0], ReserveConfig { - attributed_borrow_limit: 6, + attributed_borrow_limit_open: 6, ..reserves[0].account.config }, reserves[0].account.rate_limiter.config, @@ -656,7 +649,7 @@ async fn test_withdraw() { &lending_market_owner, &reserves[0], ReserveConfig { - attributed_borrow_limit: 10, + attributed_borrow_limit_open: 10, ..reserves[0].account.config }, reserves[0].account.rate_limiter.config, @@ -860,115 +853,3 @@ async fn test_liquidate() { Decimal::zero() ); } - -#[tokio::test] -async fn test_calculation_on_program_upgrade() { - let mut test = ProgramTest::new( - "solend_program", - solend_program::id(), - processor!(process_instruction), - ); - - let reserve_1 = Reserve { - version: PROGRAM_VERSION, - last_update: LastUpdate { - slot: 1, - stale: false, - }, - attributed_borrow_value: Decimal::from(10u64), - liquidity: ReserveLiquidity { - market_price: Decimal::from(10u64), - mint_decimals: 0, - ..ReserveLiquidity::default() - }, - ..Reserve::default() - }; - let reserve_1_pubkey = Pubkey::new_unique(); - - test.add_packable_account( - reserve_1_pubkey, - u32::MAX as u64, - &reserve_1, - &solend_program::id(), - ); - - let reserve_2 = Reserve { - version: PROGRAM_VERSION, - last_update: LastUpdate { - slot: 1, - stale: false, - }, - liquidity: ReserveLiquidity { - market_price: Decimal::from(10u64), - mint_decimals: 0, - ..ReserveLiquidity::default() - }, - ..Reserve::default() - }; - let reserve_2_pubkey = Pubkey::new_unique(); - test.add_packable_account( - reserve_2_pubkey, - u32::MAX as u64, - &reserve_2, - &solend_program::id(), - ); - - let obligation_pubkey = Pubkey::new_unique(); - let obligation = Obligation { - version: PROGRAM_VERSION, - deposits: vec![ObligationCollateral { - deposit_reserve: reserve_1_pubkey, - deposited_amount: 2u64, - market_value: Decimal::from(20u64), - attributed_borrow_value: Decimal::from(10u64), - }], - borrows: vec![ObligationLiquidity { - borrow_reserve: reserve_2_pubkey, - borrowed_amount_wads: Decimal::from(1u64), - ..ObligationLiquidity::default() - }], - updated_borrow_attribution_after_upgrade: false, - ..Obligation::default() - }; - - test.add_packable_account( - obligation_pubkey, - u32::MAX as u64, - &obligation, - &solend_program::id(), - ); - - let mut test = SolendProgramTest::start_with_test(test).await; - - let ix = [refresh_obligation( - solend_program::id(), - obligation_pubkey, - vec![reserve_1_pubkey, reserve_2_pubkey], - )]; - - test.process_transaction(&ix, None).await.unwrap(); - - let reserve_1 = test.load_account::(reserve_1_pubkey).await; - assert_eq!( - reserve_1.account.attributed_borrow_value, - Decimal::from(20u64) - ); - - // run it again, this time make sure the borrow attribution value gets correctly subtracted - let ix = [ - ComputeBudgetInstruction::set_compute_unit_price(1), - refresh_obligation( - solend_program::id(), - obligation_pubkey, - vec![reserve_1_pubkey, reserve_2_pubkey], - ), - ]; - - test.process_transaction(&ix, None).await.unwrap(); - - let reserve_1 = test.load_account::(reserve_1_pubkey).await; - assert_eq!( - reserve_1.account.attributed_borrow_value, - Decimal::from(20u64) - ); -} diff --git a/token-lending/program/tests/helpers/mod.rs b/token-lending/program/tests/helpers/mod.rs index 8657fa5bb0a..6850cfadc5a 100644 --- a/token-lending/program/tests/helpers/mod.rs +++ b/token-lending/program/tests/helpers/mod.rs @@ -27,6 +27,36 @@ pub const QUOTE_CURRENCY: [u8; 32] = pub const LAMPORTS_TO_SOL: u64 = 1_000_000_000; pub const FRACTIONAL_TO_USDC: u64 = 1_000_000; +pub fn reserve_config_no_fees() -> ReserveConfig { + ReserveConfig { + optimal_utilization_rate: 80, + max_utilization_rate: 80, + loan_to_value_ratio: 50, + liquidation_bonus: 0, + max_liquidation_bonus: 0, + liquidation_threshold: 55, + max_liquidation_threshold: 65, + min_borrow_rate: 0, + optimal_borrow_rate: 0, + max_borrow_rate: 0, + super_max_borrow_rate: 0, + fees: ReserveFees { + borrow_fee_wad: 0, + flash_loan_fee_wad: 0, + host_fee_percentage: 0, + }, + deposit_limit: u64::MAX, + borrow_limit: u64::MAX, + fee_receiver: Keypair::new().pubkey(), + protocol_liquidation_fee: 0, + protocol_take_rate: 0, + added_borrow_weight_bps: 0, + reserve_type: ReserveType::Regular, + attributed_borrow_limit_open: u64::MAX, + attributed_borrow_limit_close: u64::MAX, + } +} + pub fn test_reserve_config() -> ReserveConfig { ReserveConfig { optimal_utilization_rate: 80, @@ -52,7 +82,8 @@ pub fn test_reserve_config() -> ReserveConfig { protocol_take_rate: 0, added_borrow_weight_bps: 0, reserve_type: ReserveType::Regular, - attributed_borrow_limit: u64::MAX, + attributed_borrow_limit_open: u64::MAX, + attributed_borrow_limit_close: u64::MAX, } } diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index bb8c2c94485..176ee44bb3d 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -1,4 +1,5 @@ use bytemuck::checked::from_bytes; + use solend_sdk::instruction::*; use solend_sdk::state::*; @@ -679,6 +680,32 @@ pub struct SwitchboardPriceArgs { } impl Info { + pub async fn set_obligation_closeability_status( + &self, + test: &mut SolendProgramTest, + obligation: &Info, + reserve: &Info, + risk_authority: &User, + closeable: bool, + ) -> Result<(), BanksClientError> { + let refresh_ixs = self + .build_refresh_instructions(test, obligation, None) + .await; + test.process_transaction(&refresh_ixs, None).await.unwrap(); + + let ix = vec![set_obligation_closeability_status( + solend_program::id(), + obligation.pubkey, + reserve.pubkey, + self.pubkey, + risk_authority.keypair.pubkey(), + closeable, + )]; + + test.process_transaction(&ix, Some(&[&risk_authority.keypair])) + .await + } + pub async fn deposit( &self, test: &mut SolendProgramTest, diff --git a/token-lending/program/tests/init_obligation.rs b/token-lending/program/tests/init_obligation.rs index bc802bc3954..943f5768d6a 100644 --- a/token-lending/program/tests/init_obligation.rs +++ b/token-lending/program/tests/init_obligation.rs @@ -51,7 +51,7 @@ async fn test_success() { unhealthy_borrow_value: Decimal::zero(), super_unhealthy_borrow_value: Decimal::zero(), borrowing_isolated_asset: false, - updated_borrow_attribution_after_upgrade: false + closeable: false, } ); } 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 19f31a5fd6b..009fe817e8c 100644 --- a/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs +++ b/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs @@ -15,6 +15,7 @@ use solend_program::state::ObligationCollateral; use solend_program::state::ObligationLiquidity; use solend_program::state::ReserveConfig; use solend_program::state::ReserveFees; +use solend_sdk::state::Bonus; use solend_sdk::NULL_PUBKEY; mod helpers; @@ -458,7 +459,12 @@ async fn test_success_insufficient_liquidity() { .account .calculate_protocol_liquidation_fee( available_amount * FRACTIONAL_TO_USDC, - Decimal::from_percent(105), + &Bonus { + total_bonus: Decimal::from_percent(bonus as u8), + protocol_liquidation_fee: Decimal::from_deca_bps( + usdc_reserve.account.config.protocol_liquidation_fee, + ), + }, ) .unwrap(); @@ -660,3 +666,157 @@ async fn test_liquidity_ordering() { .await .unwrap(); } + +#[tokio::test] +async fn test_liquidate_closeable_obligation() { + let (mut test, lending_market, reserves, obligations, _users, lending_market_owner) = + custom_scenario( + &[ + ReserveArgs { + mint: usdc_mint::id(), + config: ReserveConfig { + liquidation_bonus: 5, + max_liquidation_bonus: 10, + protocol_liquidation_fee: 1, + ..reserve_config_no_fees() + }, + 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: reserve_config_no_fees(), + liquidity_amount: LAMPORTS_PER_SOL, + price: PriceArgs { + price: 10, + conf: 0, + expo: 0, + ema_price: 10, + ema_conf: 0, + }, + }, + ], + &[ObligationArgs { + deposits: vec![(usdc_mint::id(), 20 * FRACTIONAL_TO_USDC)], + borrows: vec![(wsol_mint::id(), LAMPORTS_PER_SOL)], + }], + ) + .await; + + let usdc_reserve = reserves + .iter() + .find(|r| r.account.liquidity.mint_pubkey == usdc_mint::id()) + .unwrap(); + let wsol_reserve = reserves + .iter() + .find(|r| r.account.liquidity.mint_pubkey == wsol_mint::id()) + .unwrap(); + + 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; + + let balance_checker = + BalanceChecker::start(&mut test, &[usdc_reserve, &liquidator, wsol_reserve]).await; + + lending_market + .update_reserve_config( + &mut test, + &lending_market_owner, + usdc_reserve, + ReserveConfig { + attributed_borrow_limit_open: 1, + attributed_borrow_limit_close: 1, + ..usdc_reserve.account.config + }, + usdc_reserve.account.rate_limiter.config, + None, + ) + .await + .unwrap(); + + lending_market + .set_obligation_closeability_status( + &mut test, + &obligations[0], + usdc_reserve, + &lending_market_owner, + true, + ) + .await + .unwrap(); + + test.advance_clock_by_slots(1).await; + + lending_market + .liquidate_obligation_and_redeem_reserve_collateral( + &mut test, + wsol_reserve, + usdc_reserve, + &obligations[0], + &liquidator, + u64::MAX, + ) + .await + .unwrap(); + + let (balance_changes, mint_supply_changes) = + balance_checker.find_balance_changes(&mut test).await; + + let expected_balance_changes = HashSet::from([ + // liquidator + TokenBalanceChange { + token_account: liquidator.get_account(&usdc_mint::id()).unwrap(), + mint: usdc_mint::id(), + diff: (2 * FRACTIONAL_TO_USDC - 1) as i128, + }, + TokenBalanceChange { + token_account: liquidator.get_account(&wsol_mint::id()).unwrap(), + mint: wsol_mint::id(), + diff: -((LAMPORTS_PER_SOL / 5) as i128), + }, + // usdc reserve + TokenBalanceChange { + token_account: usdc_reserve.account.collateral.supply_pubkey, + mint: usdc_reserve.account.collateral.mint_pubkey, + diff: -((2 * FRACTIONAL_TO_USDC) as i128), + }, + TokenBalanceChange { + token_account: usdc_reserve.account.liquidity.supply_pubkey, + mint: usdc_mint::id(), + diff: -((2 * FRACTIONAL_TO_USDC) as i128), + }, + TokenBalanceChange { + token_account: usdc_reserve.account.config.fee_receiver, + mint: usdc_mint::id(), + diff: 1, + }, + // wsol reserve + TokenBalanceChange { + token_account: wsol_reserve.account.liquidity.supply_pubkey, + mint: wsol_mint::id(), + diff: (LAMPORTS_TO_SOL / 5) as i128, + }, + ]); + assert_eq!(balance_changes, expected_balance_changes); + + assert_eq!( + mint_supply_changes, + HashSet::from([MintSupplyChange { + mint: usdc_reserve.account.collateral.mint_pubkey, + diff: -((2 * FRACTIONAL_TO_USDC) as i128) + }]) + ); +} diff --git a/token-lending/program/tests/mark_obligation_as_closeable.rs b/token-lending/program/tests/mark_obligation_as_closeable.rs new file mode 100644 index 00000000000..16b81c45ff5 --- /dev/null +++ b/token-lending/program/tests/mark_obligation_as_closeable.rs @@ -0,0 +1,270 @@ +#![cfg(feature = "test-bpf")] + +use crate::solend_program_test::custom_scenario; +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +use crate::solend_program_test::User; + +use solana_sdk::signer::keypair::Keypair; +use solana_sdk::signer::Signer; + +use crate::solend_program_test::ObligationArgs; +use crate::solend_program_test::PriceArgs; +use crate::solend_program_test::ReserveArgs; + +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::{instruction::LendingInstruction, solend_mainnet, state::*}; +mod helpers; + +use helpers::*; +use solana_program_test::*; + +#[tokio::test] +async fn test_mark_obligation_as_closeable_success() { + let (mut test, lending_market, reserves, obligations, _users, lending_market_owner) = + custom_scenario( + &[ + ReserveArgs { + mint: usdc_mint::id(), + config: reserve_config_no_fees(), + 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: reserve_config_no_fees(), + liquidity_amount: LAMPORTS_PER_SOL, + price: PriceArgs { + price: 10, + conf: 0, + expo: 0, + ema_price: 10, + ema_conf: 0, + }, + }, + ], + &[ObligationArgs { + deposits: vec![(usdc_mint::id(), 20 * FRACTIONAL_TO_USDC)], + borrows: vec![(wsol_mint::id(), LAMPORTS_PER_SOL)], + }], + ) + .await; + + let risk_authority = User::new_with_keypair(Keypair::new()); + lending_market + .set_lending_market_owner_and_config( + &mut test, + &lending_market_owner, + &lending_market_owner.keypair.pubkey(), + lending_market.account.rate_limiter.config, + lending_market.account.whitelisted_liquidator, + risk_authority.keypair.pubkey(), + ) + .await + .unwrap(); + + test.advance_clock_by_slots(1).await; + + let err = lending_market + .set_obligation_closeability_status( + &mut test, + &obligations[0], + &reserves[0], + &risk_authority, + true, + ) + .await + .unwrap_err() + .unwrap(); + + assert_eq!( + err, + TransactionError::InstructionError( + 0, + InstructionError::Custom(LendingError::BorrowAttributionLimitNotExceeded as u32) + ) + ); + + test.advance_clock_by_slots(1).await; + + lending_market + .update_reserve_config( + &mut test, + &lending_market_owner, + &reserves[0], + ReserveConfig { + attributed_borrow_limit_open: 1, + attributed_borrow_limit_close: 1, + ..reserves[0].account.config + }, + reserves[0].account.rate_limiter.config, + None, + ) + .await + .unwrap(); + + lending_market + .set_obligation_closeability_status( + &mut test, + &obligations[0], + &reserves[0], + &risk_authority, + true, + ) + .await + .unwrap(); + + let obligation_post = test.load_account::(obligations[0].pubkey).await; + assert_eq!( + obligation_post.account, + Obligation { + last_update: LastUpdate { + slot: 1002, + stale: false + }, + closeable: true, + ..obligations[0].account.clone() + } + ); +} + +#[tokio::test] +async fn invalid_signer() { + let (mut test, lending_market, reserves, obligations, _users, lending_market_owner) = + custom_scenario( + &[ + ReserveArgs { + mint: usdc_mint::id(), + config: reserve_config_no_fees(), + 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: reserve_config_no_fees(), + liquidity_amount: LAMPORTS_PER_SOL, + price: PriceArgs { + price: 10, + conf: 0, + expo: 0, + ema_price: 10, + ema_conf: 0, + }, + }, + ], + &[ObligationArgs { + deposits: vec![(usdc_mint::id(), 20 * FRACTIONAL_TO_USDC)], + borrows: vec![(wsol_mint::id(), LAMPORTS_PER_SOL)], + }], + ) + .await; + + let risk_authority = User::new_with_keypair(Keypair::new()); + lending_market + .set_lending_market_owner_and_config( + &mut test, + &lending_market_owner, + &lending_market_owner.keypair.pubkey(), + lending_market.account.rate_limiter.config, + lending_market.account.whitelisted_liquidator, + risk_authority.keypair.pubkey(), + ) + .await + .unwrap(); + + lending_market + .update_reserve_config( + &mut test, + &lending_market_owner, + &reserves[0], + ReserveConfig { + attributed_borrow_limit_open: 1, + attributed_borrow_limit_close: 1, + ..reserves[0].account.config + }, + reserves[0].account.rate_limiter.config, + None, + ) + .await + .unwrap(); + + let rando = User::new_with_keypair(Keypair::new()); + let err = lending_market + .set_obligation_closeability_status(&mut test, &obligations[0], &reserves[0], &rando, true) + .await + .unwrap_err() + .unwrap(); + + assert_eq!( + err, + TransactionError::InstructionError( + 0, + InstructionError::Custom(LendingError::InvalidAccountInput as u32) + ) + ); + + let err = test + .process_transaction( + &[malicious_set_obligation_closeability_status( + solend_mainnet::id(), + obligations[0].pubkey, + reserves[0].pubkey, + lending_market.pubkey, + risk_authority.keypair.pubkey(), + true, + )], + None, + ) + .await + .unwrap_err() + .unwrap(); + + assert_eq!( + err, + TransactionError::InstructionError( + 0, + InstructionError::Custom(LendingError::InvalidSigner as u32) + ) + ); +} + +pub fn malicious_set_obligation_closeability_status( + program_id: Pubkey, + obligation_pubkey: Pubkey, + reserve_pubkey: Pubkey, + lending_market_pubkey: Pubkey, + risk_authority: Pubkey, + closeable: bool, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(obligation_pubkey, false), + AccountMeta::new_readonly(lending_market_pubkey, false), + AccountMeta::new_readonly(reserve_pubkey, false), + AccountMeta::new_readonly(risk_authority, false), + ], + data: LendingInstruction::SetObligationCloseabilityStatus { closeable }.pack(), + } +} diff --git a/token-lending/program/tests/two_prices.rs b/token-lending/program/tests/two_prices.rs index cbd224fb899..463b562fb06 100644 --- a/token-lending/program/tests/two_prices.rs +++ b/token-lending/program/tests/two_prices.rs @@ -478,7 +478,7 @@ async fn test_liquidation_doesnt_use_smoothed_price() { TokenBalanceChange { token_account: liquidator.get_account(&usdc_mint::id()).unwrap(), mint: usdc_mint::id(), - diff: (20 * FRACTIONAL_TO_USDC * 105 / 100) as i128 - 1, + diff: (20 * FRACTIONAL_TO_USDC * 105 / 100 - 1) as i128, }, TokenBalanceChange { token_account: liquidator.get_account(&wsol_mint::id()).unwrap(), diff --git a/token-lending/sdk/src/error.rs b/token-lending/sdk/src/error.rs index 8666d57e238..597521cd91c 100644 --- a/token-lending/sdk/src/error.rs +++ b/token-lending/sdk/src/error.rs @@ -206,6 +206,9 @@ pub enum LendingError { /// Borrow Attribution Limit Exceeded #[error("Borrow Attribution Limit Exceeded")] BorrowAttributionLimitExceeded, + /// Borrow Attribution Limit Not Exceeded + #[error("Borrow Attribution Limit Not Exceeded")] + BorrowAttributionLimitNotExceeded, } impl From for ProgramError { diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index a2ae04f901c..0e4d37b6362 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -507,6 +507,19 @@ pub enum LendingInstruction { /// 1. `[signer]` fee payer. /// 2. '[]' System Program ResizeReserve, + + // 24 + /// MarkObligationAsClosable + /// + /// Accounts expected by this instruction + /// 0. `[writable]` Obligation account - refreshed. + /// 1. `[]` Lending market account. + /// 2. `[]` Reserve account - refreshed. + /// 3. `[signer]` risk authority of lending market or lending market owner + SetObligationCloseabilityStatus { + /// Obligation is closable + closeable: bool, + }, } impl LendingInstruction { @@ -571,7 +584,8 @@ impl LendingInstruction { let (asset_type, rest) = Self::unpack_u8(rest)?; let (max_liquidation_bonus, rest) = Self::unpack_u8(rest)?; let (max_liquidation_threshold, rest) = Self::unpack_u8(rest)?; - let (attributed_borrow_limit, _rest) = Self::unpack_u64(rest)?; + let (attributed_borrow_limit_open, rest) = Self::unpack_u64(rest)?; + let (attributed_borrow_limit_close, _rest) = Self::unpack_u64(rest)?; Self::InitReserve { liquidity_amount, config: ReserveConfig { @@ -598,7 +612,8 @@ impl LendingInstruction { protocol_take_rate, added_borrow_weight_bps, reserve_type: ReserveType::from_u8(asset_type).unwrap(), - attributed_borrow_limit, + attributed_borrow_limit_open, + attributed_borrow_limit_close, }, } } @@ -667,7 +682,8 @@ impl LendingInstruction { let (asset_type, rest) = Self::unpack_u8(rest)?; let (max_liquidation_bonus, rest) = Self::unpack_u8(rest)?; let (max_liquidation_threshold, rest) = Self::unpack_u8(rest)?; - let (attributed_borrow_limit, rest) = Self::unpack_u64(rest)?; + let (attributed_borrow_limit_open, rest) = Self::unpack_u64(rest)?; + let (attributed_borrow_limit_close, rest) = Self::unpack_u64(rest)?; let (window_duration, rest) = Self::unpack_u64(rest)?; let (max_outflow, _rest) = Self::unpack_u64(rest)?; @@ -696,7 +712,8 @@ impl LendingInstruction { protocol_take_rate, added_borrow_weight_bps, reserve_type: ReserveType::from_u8(asset_type).unwrap(), - attributed_borrow_limit, + attributed_borrow_limit_open, + attributed_borrow_limit_close, }, rate_limiter_config: RateLimiterConfig { window_duration, @@ -727,6 +744,15 @@ impl LendingInstruction { } 22 => Self::UpdateMarketMetadata, 23 => Self::ResizeReserve, + 24 => { + let (closeable, _rest) = match Self::unpack_u8(rest)? { + (0, rest) => (false, rest), + (1, rest) => (true, rest), + _ => return Err(LendingError::InstructionUnpackError.into()), + }; + + Self::SetObligationCloseabilityStatus { closeable } + } _ => { msg!("Instruction cannot be unpacked {:?} {:?}", tag, rest); return Err(LendingError::InstructionUnpackError.into()); @@ -847,7 +873,8 @@ impl LendingInstruction { protocol_take_rate, added_borrow_weight_bps: borrow_weight_bps, reserve_type: asset_type, - attributed_borrow_limit, + attributed_borrow_limit_open, + attributed_borrow_limit_close, }, } => { buf.push(2); @@ -873,7 +900,8 @@ impl LendingInstruction { buf.extend_from_slice(&(asset_type as u8).to_le_bytes()); buf.extend_from_slice(&max_liquidation_bonus.to_le_bytes()); buf.extend_from_slice(&max_liquidation_threshold.to_le_bytes()); - buf.extend_from_slice(&attributed_borrow_limit.to_le_bytes()); + buf.extend_from_slice(&attributed_borrow_limit_open.to_le_bytes()); + buf.extend_from_slice(&attributed_borrow_limit_close.to_le_bytes()); } Self::RefreshReserve => { buf.push(3); @@ -950,7 +978,8 @@ impl LendingInstruction { buf.extend_from_slice(&(config.reserve_type as u8).to_le_bytes()); buf.extend_from_slice(&config.max_liquidation_bonus.to_le_bytes()); buf.extend_from_slice(&config.max_liquidation_threshold.to_le_bytes()); - buf.extend_from_slice(&config.attributed_borrow_limit.to_le_bytes()); + buf.extend_from_slice(&config.attributed_borrow_limit_open.to_le_bytes()); + buf.extend_from_slice(&config.attributed_borrow_limit_close.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()); } @@ -982,6 +1011,10 @@ impl LendingInstruction { Self::ResizeReserve => { buf.push(23); } + Self::SetObligationCloseabilityStatus { closeable } => { + buf.push(24); + buf.extend_from_slice(&(closeable as u8).to_le_bytes()); + } } buf } @@ -1746,6 +1779,27 @@ pub fn resize_reserve(program_id: Pubkey, reserve_pubkey: Pubkey, signer: Pubkey } } +/// Creates a `MarkObligationAsClosable` instruction +pub fn set_obligation_closeability_status( + program_id: Pubkey, + obligation_pubkey: Pubkey, + reserve_pubkey: Pubkey, + lending_market_pubkey: Pubkey, + risk_authority: Pubkey, + closeable: bool, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(obligation_pubkey, false), + AccountMeta::new_readonly(lending_market_pubkey, false), + AccountMeta::new_readonly(reserve_pubkey, false), + AccountMeta::new_readonly(risk_authority, true), + ], + data: LendingInstruction::SetObligationCloseabilityStatus { closeable }.pack(), + } +} + #[cfg(test)] mod test { use super::*; @@ -1815,7 +1869,8 @@ mod test { protocol_take_rate: rng.gen::(), added_borrow_weight_bps: rng.gen::(), reserve_type: ReserveType::from_u8(rng.gen::() % 2).unwrap(), - attributed_borrow_limit: rng.gen(), + attributed_borrow_limit_open: rng.gen(), + attributed_borrow_limit_close: rng.gen(), }, }; @@ -1976,7 +2031,8 @@ mod test { protocol_take_rate: rng.gen::(), added_borrow_weight_bps: rng.gen::(), reserve_type: ReserveType::from_u8(rng.gen::() % 2).unwrap(), - attributed_borrow_limit: rng.gen(), + attributed_borrow_limit_open: rng.gen(), + attributed_borrow_limit_close: rng.gen(), }, rate_limiter_config: RateLimiterConfig { window_duration: rng.gen::(), @@ -2043,6 +2099,26 @@ mod test { let unpacked = LendingInstruction::unpack(&packed).unwrap(); assert_eq!(instruction, unpacked); } + + // resize reserve + { + let instruction = LendingInstruction::ResizeReserve {}; + + let packed = instruction.pack(); + let unpacked = LendingInstruction::unpack(&packed).unwrap(); + assert_eq!(instruction, unpacked); + } + + // MarkObligationAsClosable + { + let instruction = LendingInstruction::SetObligationCloseabilityStatus { + closeable: rng.gen(), + }; + + let packed = instruction.pack(); + let unpacked = LendingInstruction::unpack(&packed).unwrap(); + assert_eq!(instruction, unpacked); + } } } } diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index 6fea396778c..ab833798e19 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -61,8 +61,8 @@ pub struct Obligation { pub super_unhealthy_borrow_value: Decimal, /// True if the obligation is currently borrowing an isolated tier asset pub borrowing_isolated_asset: bool, - /// Updated borrow attribution after upgrade. initially false when upgrading to v2.0.3 - pub updated_borrow_attribution_after_upgrade: bool, + /// Obligation can be marked as closeable + pub closeable: bool, } impl Obligation { @@ -439,7 +439,7 @@ impl Pack for Obligation { borrowing_isolated_asset, super_unhealthy_borrow_value, unweighted_borrowed_value, - updated_borrow_attribution_after_upgrade, + closeable, _padding, deposits_len, borrows_len, @@ -483,10 +483,7 @@ impl Pack for Obligation { super_unhealthy_borrow_value, ); pack_decimal(self.unweighted_borrowed_value, unweighted_borrowed_value); - pack_bool( - self.updated_borrow_attribution_after_upgrade, - updated_borrow_attribution_after_upgrade, - ); + pack_bool(self.closeable, closeable); *deposits_len = u8::try_from(self.deposits.len()).unwrap().to_le_bytes(); *borrows_len = u8::try_from(self.borrows.len()).unwrap().to_le_bytes(); @@ -551,7 +548,7 @@ impl Pack for Obligation { borrowing_isolated_asset, super_unhealthy_borrow_value, unweighted_borrowed_value, - updated_borrow_attribution_after_upgrade, + closeable, _padding, deposits_len, borrows_len, @@ -645,9 +642,7 @@ impl Pack for Obligation { unhealthy_borrow_value: unpack_decimal(unhealthy_borrow_value), super_unhealthy_borrow_value: unpack_decimal(super_unhealthy_borrow_value), borrowing_isolated_asset: unpack_bool(borrowing_isolated_asset)?, - updated_borrow_attribution_after_upgrade: unpack_bool( - updated_borrow_attribution_after_upgrade, - )?, + closeable: unpack_bool(closeable)?, }) } } @@ -698,7 +693,7 @@ mod test { unhealthy_borrow_value: rand_decimal(), super_unhealthy_borrow_value: rand_decimal(), borrowing_isolated_asset: rng.gen(), - updated_borrow_attribution_after_upgrade: rng.gen(), + closeable: rng.gen(), }; let mut packed = [0u8; OBLIGATION_LEN]; diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index f1cc90a643a..e9743213985 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -338,8 +338,15 @@ impl Reserve { /// Calculate bonus as a percentage /// the value will be in range [0, MAX_BONUS_PCT] - pub fn calculate_bonus(&self, obligation: &Obligation) -> Result { + pub fn calculate_bonus(&self, obligation: &Obligation) -> Result { if obligation.borrowed_value < obligation.unhealthy_borrow_value { + if obligation.closeable { + return Ok(Bonus { + total_bonus: Decimal::zero(), + protocol_liquidation_fee: Decimal::zero(), + }); + } + msg!("Obligation is healthy so a liquidation bonus can't be calculated"); return Err(LendingError::ObligationHealthy.into()); } @@ -351,10 +358,13 @@ impl Reserve { // could also return the average of liquidation bonus and max liquidation bonus here, but // i don't think it matters if obligation.unhealthy_borrow_value == obligation.super_unhealthy_borrow_value { - return Ok(min( - liquidation_bonus.try_add(protocol_liquidation_fee)?, - Decimal::from_percent(MAX_BONUS_PCT), - )); + return Ok(Bonus { + total_bonus: min( + liquidation_bonus.try_add(protocol_liquidation_fee)?, + Decimal::from_percent(MAX_BONUS_PCT), + ), + protocol_liquidation_fee, + }); } // safety: @@ -383,7 +393,10 @@ impl Reserve { .try_add(weight.try_mul(max_liquidation_bonus.try_sub(liquidation_bonus)?)?)? .try_add(protocol_liquidation_fee)?; - Ok(min(bonus, Decimal::from_percent(MAX_BONUS_PCT))) + Ok(Bonus { + total_bonus: min(bonus, Decimal::from_percent(MAX_BONUS_PCT)), + protocol_liquidation_fee, + }) } /// Liquidate some or all of an unhealthy obligation @@ -393,8 +406,14 @@ impl Reserve { obligation: &Obligation, liquidity: &ObligationLiquidity, collateral: &ObligationCollateral, + bonus: &Bonus, ) -> Result { - let bonus_rate = self.calculate_bonus(obligation)?.try_add(Decimal::one())?; + if bonus.total_bonus > Decimal::from_percent(MAX_BONUS_PCT) { + msg!("Bonus rate cannot exceed maximum bonus rate"); + return Err(LendingError::InvalidAmount.into()); + } + + let bonus_rate = bonus.total_bonus.try_add(Decimal::one())?; let max_amount = if amount_to_liquidate == u64::MAX { liquidity.borrowed_amount_wads @@ -485,29 +504,33 @@ impl Reserve { settle_amount, repay_amount, withdraw_amount, - bonus_rate, }) } /// Calculate protocol cut of liquidation bonus always at least 1 lamport - /// the bonus rate is always >=1 and includes both liquidator bonus and protocol fee. + /// the bonus rate is always <= MAX_BONUS_PCT /// the bonus rate has to be passed into this function because bonus calculations are dynamic /// and can't be recalculated after liquidation. pub fn calculate_protocol_liquidation_fee( &self, amount_liquidated: u64, - bonus_rate: Decimal, + bonus: &Bonus, ) -> Result { + if bonus.total_bonus > Decimal::from_percent(MAX_BONUS_PCT) { + msg!("Bonus rate cannot exceed maximum bonus rate"); + return Err(LendingError::InvalidAmount.into()); + } + let amount_liquidated_wads = Decimal::from(amount_liquidated); - let nonbonus_amount = amount_liquidated_wads.try_div(bonus_rate)?; - // After deploying must update all reserves to set liquidation fee then redeploy with this line instead of hardcode - let protocol_fee = std::cmp::max( + let nonbonus_amount = + amount_liquidated_wads.try_div(Decimal::one().try_add(bonus.total_bonus)?)?; + + Ok(std::cmp::max( nonbonus_amount - .try_mul(Decimal::from_deca_bps(self.config.protocol_liquidation_fee))? + .try_mul(bonus.protocol_liquidation_fee)? .try_ceil_u64()?, 1, - ); - Ok(protocol_fee) + )) } /// Calculate protocol fee redemption accounting for availible liquidity and accumulated fees @@ -569,9 +592,17 @@ pub struct CalculateLiquidationResult { pub repay_amount: u64, /// Amount of collateral to withdraw in exchange for repay amount pub withdraw_amount: u64, - /// Liquidator bonus as a percentage, including the protocol fee - /// always greater than or equal to 1. - pub bonus_rate: Decimal, +} + +/// Bonus +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bonus { + /// Total bonus (liquidator bonus + protocol liquidation fee). 0 <= x <= MAX_BONUS_PCT + /// eg if the total bonus is 5%, this value is 0.05 + pub total_bonus: Decimal, + /// protocol liquidation fee pct. 0 <= x <= reserve.config.protocol_liquidation_fee / 10 + /// eg if the protocol liquidation fee is 1%, this value is 0.01 + pub protocol_liquidation_fee: Decimal, } /// Reserve liquidity @@ -903,8 +934,10 @@ pub struct ReserveConfig { pub added_borrow_weight_bps: u64, /// Type of the reserve (Regular, Isolated) pub reserve_type: ReserveType, - /// Attributed Borrow limit in USD - pub attributed_borrow_limit: u64, + /// Open Attributed Borrow limit in USD + pub attributed_borrow_limit_open: u64, + /// Close Attributed Borrow limit in USD + pub attributed_borrow_limit_close: u64, } /// validates reserve configs @@ -992,6 +1025,11 @@ pub fn validate_reserve_config(config: ReserveConfig) -> ProgramResult { msg!("open/close LTV must be 0 for isolated reserves"); return Err(LendingError::InvalidConfig.into()); } + if config.attributed_borrow_limit_open > config.attributed_borrow_limit_close { + msg!("open attributed borrow limit must be <= close attributed borrow limit"); + return Err(LendingError::InvalidConfig.into()); + } + Ok(()) } @@ -1179,7 +1217,8 @@ impl Pack for Reserve { config_max_liquidation_bonus, config_max_liquidation_threshold, attributed_borrow_value, - config_attributed_borrow_limit, + config_attributed_borrow_limit_open, + config_attributed_borrow_limit_close, _padding, ) = mut_array_refs![ output, @@ -1225,7 +1264,8 @@ impl Pack for Reserve { 1, 16, 8, - 714 + 8, + 706 ]; // reserve @@ -1290,7 +1330,10 @@ impl Pack for Reserve { *config_added_borrow_weight_bps = self.config.added_borrow_weight_bps.to_le_bytes(); *config_max_liquidation_bonus = self.config.max_liquidation_bonus.to_le_bytes(); *config_max_liquidation_threshold = self.config.max_liquidation_threshold.to_le_bytes(); - *config_attributed_borrow_limit = self.config.attributed_borrow_limit.to_le_bytes(); + *config_attributed_borrow_limit_open = + self.config.attributed_borrow_limit_open.to_le_bytes(); + *config_attributed_borrow_limit_close = + self.config.attributed_borrow_limit_close.to_le_bytes(); pack_decimal(self.attributed_borrow_value, attributed_borrow_value); } @@ -1341,7 +1384,8 @@ impl Pack for Reserve { config_max_liquidation_bonus, config_max_liquidation_threshold, attributed_borrow_value, - config_attributed_borrow_limit, + config_attributed_borrow_limit_open, + config_attributed_borrow_limit_close, _padding, ) = array_refs![ input, @@ -1387,7 +1431,8 @@ impl Pack for Reserve { 1, 16, 8, - 714 + 8, + 706 ]; let version = u8::from_le_bytes(*version); @@ -1478,11 +1523,19 @@ impl Pack for Reserve { protocol_take_rate: u8::from_le_bytes(*config_protocol_take_rate), added_borrow_weight_bps: u64::from_le_bytes(*config_added_borrow_weight_bps), reserve_type: ReserveType::from_u8(config_asset_type[0]).unwrap(), - // this field is added in v2.0.3 and we will never set it to zero. only time it'll + // the following two fields are added in v2.0.3 and we will never set it to zero. only time they will // be zero is when we upgrade from v2.0.2 to v2.0.3. in that case, the correct // thing to do is set the value to u64::MAX. - attributed_borrow_limit: { - let value = u64::from_le_bytes(*config_attributed_borrow_limit); + attributed_borrow_limit_open: { + let value = u64::from_le_bytes(*config_attributed_borrow_limit_open); + if value == 0 { + u64::MAX + } else { + value + } + }, + attributed_borrow_limit_close: { + let value = u64::from_le_bytes(*config_attributed_borrow_limit_close); if value == 0 { u64::MAX } else { @@ -1567,7 +1620,8 @@ mod test { protocol_take_rate: rng.gen(), added_borrow_weight_bps: rng.gen(), reserve_type: ReserveType::from_u8(rng.gen::() % 2).unwrap(), - attributed_borrow_limit: rng.gen(), + attributed_borrow_limit_open: rng.gen(), + attributed_borrow_limit_close: rng.gen(), }, rate_limiter: rand_rate_limiter(), attributed_borrow_value: rand_decimal(), @@ -1970,7 +2024,7 @@ mod test { #[test] fn calculate_protocol_liquidation_fee() { - let mut reserve = Reserve { + let reserve = Reserve { config: ReserveConfig { protocol_liquidation_fee: 10, ..Default::default() @@ -1980,18 +2034,55 @@ mod test { assert_eq!( reserve - .calculate_protocol_liquidation_fee(105, Decimal::from_percent(105)) + .calculate_protocol_liquidation_fee( + 105, + &Bonus { + total_bonus: Decimal::from_percent(5), + protocol_liquidation_fee: Decimal::from_percent(1), + } + ) .unwrap(), 1 ); - reserve.config.protocol_liquidation_fee = 20; assert_eq!( reserve - .calculate_protocol_liquidation_fee(105, Decimal::from_percent(105)) + .calculate_protocol_liquidation_fee( + 105, + &Bonus { + total_bonus: Decimal::from_percent(5), + protocol_liquidation_fee: Decimal::from_percent(2), + } + ) .unwrap(), 2 ); + + assert_eq!( + reserve + .calculate_protocol_liquidation_fee( + 10000, + &Bonus { + total_bonus: Decimal::from_percent(5), + protocol_liquidation_fee: Decimal::from_percent(0), + } + ) + .unwrap(), + 1 + ); + + assert_eq!( + reserve + .calculate_protocol_liquidation_fee( + 10000, + &Bonus { + total_bonus: Decimal::from_percent(1), + protocol_liquidation_fee: Decimal::from_percent(1), + } + ) + .unwrap(), + 100 + ); } #[test] @@ -2123,6 +2214,22 @@ mod test { ..ReserveConfig::default() }, result: Err(LendingError::InvalidConfig.into()), + }), + Just(ReserveConfigTestCase { + config: ReserveConfig { + attributed_borrow_limit_open: 50, + attributed_borrow_limit_close: 51, + ..ReserveConfig::default() + }, + result: Ok(()) + }), + Just(ReserveConfigTestCase { + config: ReserveConfig { + attributed_borrow_limit_open: 51, + attributed_borrow_limit_close: 50, + ..ReserveConfig::default() + }, + result: Err(LendingError::InvalidConfig.into()), }) ] } @@ -2139,12 +2246,13 @@ mod test { borrowed_value: Decimal, unhealthy_borrow_value: Decimal, super_unhealthy_borrow_value: Decimal, + closeable: bool, liquidation_bonus: u8, max_liquidation_bonus: u8, protocol_liquidation_fee: u8, - result: Result, + result: Result, } fn calculate_bonus_test_cases() -> impl Strategy { @@ -2154,73 +2262,130 @@ mod test { borrowed_value: Decimal::from(100u64), unhealthy_borrow_value: Decimal::from(101u64), super_unhealthy_borrow_value: Decimal::from(150u64), + closeable: false, liquidation_bonus: 10, max_liquidation_bonus: 20, protocol_liquidation_fee: 10, result: Err(LendingError::ObligationHealthy.into()), }), + // healthy but closeable + Just(LiquidationBonusTestCase { + borrowed_value: Decimal::from(100u64), + unhealthy_borrow_value: Decimal::from(101u64), + super_unhealthy_borrow_value: Decimal::from(150u64), + closeable: true, + liquidation_bonus: 10, + max_liquidation_bonus: 20, + protocol_liquidation_fee: 10, + result: Ok(Bonus { + total_bonus: Decimal::zero(), + protocol_liquidation_fee: Decimal::zero() + }), + }), + // unhealthy and also closeable Just(LiquidationBonusTestCase { borrowed_value: Decimal::from(100u64), unhealthy_borrow_value: Decimal::from(100u64), super_unhealthy_borrow_value: Decimal::from(150u64), + closeable: true, liquidation_bonus: 10, max_liquidation_bonus: 20, protocol_liquidation_fee: 10, - result: Ok(Decimal::from_percent(11)) + result: Ok(Bonus { + total_bonus: Decimal::from_percent(11), + protocol_liquidation_fee: Decimal::from_percent(1) + }), + }), + Just(LiquidationBonusTestCase { + borrowed_value: Decimal::from(100u64), + unhealthy_borrow_value: Decimal::from(100u64), + super_unhealthy_borrow_value: Decimal::from(150u64), + closeable: false, + liquidation_bonus: 10, + max_liquidation_bonus: 20, + protocol_liquidation_fee: 10, + result: Ok(Bonus { + total_bonus: Decimal::from_percent(11), + protocol_liquidation_fee: Decimal::from_percent(1) + }), }), Just(LiquidationBonusTestCase { borrowed_value: Decimal::from(100u64), unhealthy_borrow_value: Decimal::from(50u64), super_unhealthy_borrow_value: Decimal::from(150u64), + closeable: false, liquidation_bonus: 10, max_liquidation_bonus: 20, protocol_liquidation_fee: 10, - result: Ok(Decimal::from_percent(16)) + result: Ok(Bonus { + total_bonus: Decimal::from_percent(16), + protocol_liquidation_fee: Decimal::from_percent(1) + }), }), Just(LiquidationBonusTestCase { borrowed_value: Decimal::from(100u64), unhealthy_borrow_value: Decimal::from(50u64), super_unhealthy_borrow_value: Decimal::from(100u64), + closeable: false, liquidation_bonus: 10, max_liquidation_bonus: 20, protocol_liquidation_fee: 10, - result: Ok(Decimal::from_percent(21)) + result: Ok(Bonus { + total_bonus: Decimal::from_percent(21), + protocol_liquidation_fee: Decimal::from_percent(1) + }), }), Just(LiquidationBonusTestCase { borrowed_value: Decimal::from(200u64), unhealthy_borrow_value: Decimal::from(50u64), super_unhealthy_borrow_value: Decimal::from(100u64), + closeable: false, liquidation_bonus: 10, max_liquidation_bonus: 20, protocol_liquidation_fee: 10, - result: Ok(Decimal::from_percent(21)) + result: Ok(Bonus { + total_bonus: Decimal::from_percent(21), + protocol_liquidation_fee: Decimal::from_percent(1) + }), }), Just(LiquidationBonusTestCase { borrowed_value: Decimal::from(60u64), unhealthy_borrow_value: Decimal::from(50u64), super_unhealthy_borrow_value: Decimal::from(50u64), + closeable: false, liquidation_bonus: 10, max_liquidation_bonus: 20, protocol_liquidation_fee: 10, - result: Ok(Decimal::from_percent(11)) + result: Ok(Bonus { + total_bonus: Decimal::from_percent(11), + protocol_liquidation_fee: Decimal::from_percent(1) + }), }), Just(LiquidationBonusTestCase { borrowed_value: Decimal::from(60u64), unhealthy_borrow_value: Decimal::from(40u64), super_unhealthy_borrow_value: Decimal::from(60u64), + closeable: false, liquidation_bonus: 10, max_liquidation_bonus: 30, protocol_liquidation_fee: 10, - result: Ok(Decimal::from_percent(25)) + result: Ok(Bonus { + total_bonus: Decimal::from_percent(25), + protocol_liquidation_fee: Decimal::from_percent(1) + }), }), Just(LiquidationBonusTestCase { borrowed_value: Decimal::from(60u64), unhealthy_borrow_value: Decimal::from(40u64), super_unhealthy_borrow_value: Decimal::from(60u64), + closeable: false, liquidation_bonus: 30, max_liquidation_bonus: 30, protocol_liquidation_fee: 30, - result: Ok(Decimal::from_percent(25)) + result: Ok(Bonus { + total_bonus: Decimal::from_percent(25), + protocol_liquidation_fee: Decimal::from_percent(3) + }), }), ] } @@ -2242,6 +2407,7 @@ mod test { borrowed_value: test_case.borrowed_value, unhealthy_borrow_value: test_case.unhealthy_borrow_value, super_unhealthy_borrow_value: test_case.super_unhealthy_borrow_value, + closeable: test_case.closeable, ..Obligation::default() }; @@ -2258,6 +2424,7 @@ mod test { deposit_market_value: Decimal, borrow_amount: u64, borrow_market_value: Decimal, + bonus: Bonus, liquidation_result: CalculateLiquidationResult, } @@ -2278,6 +2445,10 @@ mod test { deposit_market_value: Decimal::from(100u64), borrow_amount: 800, borrow_market_value: Decimal::from(80u64), + bonus: Bonus { + total_bonus: Decimal::from_percent(5), + protocol_liquidation_fee: Decimal::from_percent(1) + }, liquidation_result: CalculateLiquidationResult { settle_amount: close_factor.try_mul(Decimal::from(800u64)).unwrap(), repay_amount: close_factor @@ -2292,7 +2463,6 @@ mod test { .unwrap() .try_floor_u64() .unwrap(), - bonus_rate: liquidation_bonus }, }), // collateral market value == liquidation_value @@ -2303,12 +2473,15 @@ mod test { deposit_market_value: Decimal::from( (8000 * LIQUIDATION_CLOSE_FACTOR as u64) * 105 / 10000 ), + bonus: Bonus { + total_bonus: Decimal::from_percent(5), + protocol_liquidation_fee: Decimal::from_percent(1) + }, liquidation_result: CalculateLiquidationResult { settle_amount: Decimal::from((8000 * LIQUIDATION_CLOSE_FACTOR as u64) / 100), repay_amount: (8000 * LIQUIDATION_CLOSE_FACTOR as u64) / 100, withdraw_amount: (8000 * LIQUIDATION_CLOSE_FACTOR as u64) * 105 / 10000, - bonus_rate: liquidation_bonus }, }), // collateral market value < liquidation_value @@ -2321,6 +2494,10 @@ mod test { deposit_market_value: Decimal::from( (8000 * LIQUIDATION_CLOSE_FACTOR as u64) * 105 / 10000 / 2 ), + bonus: Bonus { + total_bonus: Decimal::from_percent(5), + protocol_liquidation_fee: Decimal::from_percent(1) + }, liquidation_result: CalculateLiquidationResult { settle_amount: Decimal::from( @@ -2328,7 +2505,6 @@ mod test { ), repay_amount: (8000 * LIQUIDATION_CLOSE_FACTOR as u64) / 100 / 2, withdraw_amount: (8000 * LIQUIDATION_CLOSE_FACTOR as u64) * 105 / 10000 / 2, - bonus_rate: liquidation_bonus }, }), // dust ObligationLiquidity where collateral market value > liquidation value @@ -2337,13 +2513,16 @@ mod test { borrow_market_value: Decimal::from_percent(50), deposit_amount: 100, deposit_market_value: Decimal::from(1u64), + bonus: Bonus { + total_bonus: Decimal::from_percent(5), + protocol_liquidation_fee: Decimal::from_percent(1) + }, liquidation_result: CalculateLiquidationResult { settle_amount: Decimal::from(100u64), repay_amount: 100, // $0.5 * 1.05 = $0.525 withdraw_amount: 52, - bonus_rate: liquidation_bonus }, }), // dust ObligationLiquidity where collateral market value == liquidation value @@ -2352,12 +2531,15 @@ mod test { borrow_market_value: Decimal::from(1u64), deposit_amount: 1000, deposit_market_value: Decimal::from_percent(105), + bonus: Bonus { + total_bonus: Decimal::from_percent(5), + protocol_liquidation_fee: Decimal::from_percent(1) + }, liquidation_result: CalculateLiquidationResult { settle_amount: Decimal::from(1u64), repay_amount: 1, withdraw_amount: 1000, - bonus_rate: liquidation_bonus }, }), // dust ObligationLiquidity where collateral market value < liquidation value @@ -2366,12 +2548,15 @@ mod test { borrow_market_value: Decimal::one(), deposit_amount: 10, deposit_market_value: Decimal::from_bps(5250), // $0.525 + bonus: Bonus { + total_bonus: Decimal::from_percent(5), + protocol_liquidation_fee: Decimal::from_percent(1) + }, liquidation_result: CalculateLiquidationResult { settle_amount: Decimal::from(5u64), repay_amount: 5, withdraw_amount: 10, - bonus_rate: liquidation_bonus }, }), // dust ObligationLiquidity where collateral market value > liquidation value and the @@ -2381,12 +2566,32 @@ mod test { borrow_market_value: Decimal::one(), deposit_amount: 1, deposit_market_value: Decimal::from(10u64), + bonus: Bonus { + total_bonus: Decimal::from_percent(5), + protocol_liquidation_fee: Decimal::from_percent(1) + }, liquidation_result: CalculateLiquidationResult { settle_amount: Decimal::from(1u64), repay_amount: 1, withdraw_amount: 1, - bonus_rate: liquidation_bonus + }, + }), + // zero bonus rate + Just(LiquidationTestCase { + borrow_amount: 100, + borrow_market_value: Decimal::from(100u64), + deposit_amount: 100, + deposit_market_value: Decimal::from(100u64), + bonus: Bonus { + total_bonus: Decimal::zero(), + protocol_liquidation_fee: Decimal::zero() + }, + + liquidation_result: CalculateLiquidationResult { + settle_amount: Decimal::from(20u64), + repay_amount: 20, + withdraw_amount: 20, }, }), ] @@ -2396,11 +2601,7 @@ mod test { #[test] fn calculate_liquidation(test_case in calculate_liquidation_test_cases()) { let reserve = Reserve { - config: ReserveConfig { - liquidation_bonus: 5, - max_liquidation_bonus: 5, - ..ReserveConfig::default() - }, + config: ReserveConfig::default(), ..Reserve::default() }; @@ -2425,7 +2626,12 @@ mod test { assert_eq!( reserve.calculate_liquidation( - u64::MAX, &obligation, &obligation.borrows[0], &obligation.deposits[0]).unwrap(), + u64::MAX, + &obligation, + &obligation.borrows[0], + &obligation.deposits[0], + &test_case.bonus, + ).unwrap(), test_case.liquidation_result); } }