From f61d7a89a6658e341d0d655ca29f9186441bbbdc Mon Sep 17 00:00:00 2001 From: Justin Starry Date: Thu, 28 Jan 2021 15:56:07 +0800 Subject: [PATCH] lending: Use fair obligation health factor calculation (#1119) * lending: Use fair health factor calulation and handle dust * ci: fix github action caching --- .github/workflows/pull-request.yml | 4 - ci/cargo-build-test.sh | 2 +- ci/solana-version.sh | 4 +- token-lending/program/src/dex_market.rs | 51 ++- token-lending/program/src/error.rs | 18 +- token-lending/program/src/processor.rs | 21 +- token-lending/program/src/state/mod.rs | 3 + token-lending/program/src/state/obligation.rs | 42 +- token-lending/program/src/state/reserve.rs | 411 +++++++++++------- token-lending/program/tests/liquidate.rs | 2 +- 10 files changed, 365 insertions(+), 193 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 97a84641..3c06ffb6 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -109,16 +109,12 @@ jobs: path: | ~/.cargo/bin/rustfilt key: cargo-bpf-bins-${{ runner.os }} - restore-keys: | - cargo-bpf-bins-${{ runner.os }}- - uses: actions/cache@v2 with: path: | ~/.cache key: solana-${{ env.SOLANA_VERSION }} - restore-keys: | - solana- - name: Install dependencies run: | diff --git a/ci/cargo-build-test.sh b/ci/cargo-build-test.sh index 1a591b94..3c628e64 100755 --- a/ci/cargo-build-test.sh +++ b/ci/cargo-build-test.sh @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." source ./ci/rust-version.sh stable -source ./ci/solana-version.sh install +source ./ci/solana-version.sh export RUSTFLAGS="-D warnings" export RUSTBACKTRACE=1 diff --git a/ci/solana-version.sh b/ci/solana-version.sh index 54d5332e..72c902a1 100755 --- a/ci/solana-version.sh +++ b/ci/solana-version.sh @@ -14,17 +14,17 @@ if [[ -n $SOLANA_VERSION ]]; then solana_version="$SOLANA_VERSION" else - solana_version=v1.5.0 + solana_version=v1.5.5 fi export solana_version="$solana_version" export solana_docker_image=solanalabs/solana:"$solana_version" +export PATH="$HOME"/.local/share/solana/install/active_release/bin:"$PATH" if [[ -n $1 ]]; then case $1 in install) sh -c "$(curl -sSfL https://release.solana.com/$solana_version/install)" - export PATH="$HOME"/.local/share/solana/install/active_release/bin:"$PATH" solana --version ;; *) diff --git a/token-lending/program/src/dex_market.rs b/token-lending/program/src/dex_market.rs index 68f7054e..b4095bfa 100644 --- a/token-lending/program/src/dex_market.rs +++ b/token-lending/program/src/dex_market.rs @@ -6,7 +6,7 @@ use crate::{ state::TokenConverter, }; use arrayref::{array_refs, mut_array_refs}; -use serum_dex::critbit::Slab; +use serum_dex::critbit::{Slab, SlabView}; use solana_program::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey}; use std::{cell::RefMut, convert::TryFrom}; @@ -59,6 +59,43 @@ pub struct TradeSimulator<'a> { } impl<'a> TokenConverter for TradeSimulator<'a> { + fn best_price(&mut self, token_mint: &Pubkey) -> Result { + let action = if token_mint == self.buy_token_mint { + TradeAction::Buy + } else { + TradeAction::Sell + }; + + let currency = if token_mint == self.quote_token_mint { + Currency::Quote + } else { + Currency::Base + }; + + let order_book_side = match (action, currency) { + (TradeAction::Buy, Currency::Base) => Side::Ask, + (TradeAction::Sell, Currency::Quote) => Side::Ask, + (TradeAction::Buy, Currency::Quote) => Side::Bid, + (TradeAction::Sell, Currency::Base) => Side::Bid, + }; + if order_book_side != self.orders_side { + return Err(LendingError::DexInvalidOrderBookSide.into()); + } + + let best_order_price = self + .orders + .best_order_price() + .ok_or(LendingError::TradeSimulationError)?; + + let input_token = Decimal::one().try_div(self.dex_market.get_lots(currency))?; + let output_token_price = if currency == Currency::Base { + input_token.try_mul(best_order_price) + } else { + input_token.try_div(best_order_price) + }?; + Ok(output_token_price.try_mul(self.dex_market.get_lots(currency.opposite()))?) + } + fn convert( self, from_amount: Decimal, @@ -200,6 +237,18 @@ impl<'a> DexMarketOrders<'a> { Ok(Self { heap, side }) } + + fn best_order_price(&mut self) -> Option { + let side = self.side; + self.heap.as_mut().and_then(|heap| { + let handle = match side { + Side::Bid => heap.find_max(), + Side::Ask => heap.find_min(), + }?; + + Some(heap.get_mut(handle)?.as_leaf_mut()?.price().get()) + }) + } } impl Iterator for DexMarketOrders<'_> { diff --git a/token-lending/program/src/error.rs b/token-lending/program/src/error.rs index 94e8710c..a4a419a8 100644 --- a/token-lending/program/src/error.rs +++ b/token-lending/program/src/error.rs @@ -7,6 +7,7 @@ use thiserror::Error; /// Errors that may be returned by the TokenLending program. #[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)] pub enum LendingError { + // 0 /// Invalid instruction data passed in. #[error("Failed to unpack instruction data")] InstructionUnpackError, @@ -22,6 +23,8 @@ pub enum LendingError { /// Expected a different market owner #[error("Market owner is invalid")] InvalidMarketOwner, + + // 5 /// The owner of the input isn't set to the program address generated by the program. #[error("Input account owner is not the program address")] InvalidAccountOwner, @@ -37,6 +40,8 @@ pub enum LendingError { /// Invalid amount, must be greater than zero #[error("Input amount is invalid")] InvalidAmount, + + // 10 /// Invalid config value #[error("Input config value is invalid")] InvalidConfig, @@ -53,6 +58,7 @@ pub enum LendingError { #[error("Interest rate is negative")] NegativeInterestRate, + // 15 /// Memory is too small #[error("Memory is too small")] MemoryTooSmall, @@ -68,6 +74,8 @@ pub enum LendingError { /// Insufficient liquidity available #[error("Insufficient liquidity available")] InsufficientLiquidity, + + // 20 /// This reserve's collateral cannot be used for borrows #[error("Input reserve has collateral disabled")] ReserveCollateralDisabled, @@ -77,12 +85,14 @@ pub enum LendingError { /// Input reserves cannot use the same liquidity mint #[error("Input reserves cannot use the same liquidity mint")] DuplicateReserveMint, - /// Obligation amount is too small to pay off - #[error("Obligation amount is too small to pay off")] - ObligationTooSmall, + /// Obligation amount is empty + #[error("Obligation amount is empty")] + ObligationEmpty, /// Cannot liquidate healthy obligations #[error("Cannot liquidate healthy obligations")] HealthyObligation, + + // 25 /// Borrow amount too small #[error("Borrow amount too small")] BorrowTooSmall, @@ -92,7 +102,6 @@ pub enum LendingError { /// Reserve state stale #[error("Reserve state needs to be updated for the current slot")] ReserveStale, - /// Trade simulation error #[error("Trade simulation error")] TradeSimulationError, @@ -100,6 +109,7 @@ pub enum LendingError { #[error("Invalid dex order book side")] DexInvalidOrderBookSide, + // 30 /// Token initialize mint failed #[error("Token initialize mint failed")] TokenInitializeMintFailed, diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 44761556..2ce8a73b 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -871,6 +871,9 @@ fn process_repay( msg!("Invalid withdraw reserve account"); return Err(LendingError::InvalidAccountInput.into()); } + if obligation.deposited_collateral_tokens == 0 { + return Err(LendingError::ObligationEmpty.into()); + } let obligation_mint = unpack_mint(&obligation_token_mint_info.data.borrow())?; if &obligation.token_mint != obligation_token_mint_info.key { @@ -1030,6 +1033,9 @@ fn process_liquidate( msg!("Invalid withdraw reserve account"); return Err(LendingError::InvalidAccountInput.into()); } + if obligation.deposited_collateral_tokens == 0 { + return Err(LendingError::ObligationEmpty.into()); + } let mut repay_reserve = Reserve::unpack(&repay_reserve_info.data.borrow())?; if repay_reserve_info.owner != program_id { @@ -1104,7 +1110,6 @@ fn process_liquidate( )?; let LiquidateResult { - bonus_amount, withdraw_amount, repay_amount, settle_amount, @@ -1118,7 +1123,7 @@ fn process_liquidate( repay_reserve.liquidity.repay(repay_amount, settle_amount)?; Reserve::pack(repay_reserve, &mut repay_reserve_info.data.borrow_mut())?; - obligation.liquidate(settle_amount, withdraw_amount, bonus_amount)?; + obligation.liquidate(settle_amount, withdraw_amount)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; let authority_signer_seeds = &[ @@ -1151,18 +1156,6 @@ fn process_liquidate( token_program: token_program_id.clone(), })?; - // pay bonus collateral - if bonus_amount > 0 { - spl_token_transfer(TokenTransferParams { - source: withdraw_reserve_collateral_supply_info.clone(), - destination: destination_collateral_info.clone(), - amount: bonus_amount, - authority: lending_market_authority_info.clone(), - authority_signer_seeds, - token_program: token_program_id.clone(), - })?; - } - Ok(()) } diff --git a/token-lending/program/src/state/mod.rs b/token-lending/program/src/state/mod.rs index 1086621c..832b8d71 100644 --- a/token-lending/program/src/state/mod.rs +++ b/token-lending/program/src/state/mod.rs @@ -34,6 +34,9 @@ pub const SLOTS_PER_YEAR: u64 = /// Token converter pub trait TokenConverter { + /// Return best price for specified token + fn best_price(&mut self, token_mint: &Pubkey) -> Result; + /// Convert between two different tokens fn convert( self, diff --git a/token-lending/program/src/state/obligation.rs b/token-lending/program/src/state/obligation.rs index f70fe17d..aee04385 100644 --- a/token-lending/program/src/state/obligation.rs +++ b/token-lending/program/src/state/obligation.rs @@ -52,6 +52,37 @@ impl Obligation { } } + /// Maximum amount of loan that can be closed out by a liquidator due + /// to the remaining balance being too small to be liquidated normally. + pub fn max_closeable_amount(&self) -> Result { + if self.borrowed_liquidity_wads < Decimal::from(CLOSEABLE_AMOUNT) { + self.borrowed_liquidity_wads.try_ceil_u64() + } else { + Ok(0) + } + } + + /// Maximum amount of loan that can be repaid by liquidators + pub fn max_liquidation_amount(&self) -> Result { + Ok(self + .borrowed_liquidity_wads + .try_mul(Rate::from_percent(LIQUIDATION_CLOSE_FACTOR))? + .try_floor_u64()?) + } + + /// Ratio of loan balance to collateral value + pub fn loan_to_value( + &self, + collateral_exchange_rate: CollateralExchangeRate, + borrow_token_price: Decimal, + ) -> Result { + let loan = self.borrowed_liquidity_wads; + let collateral_value = collateral_exchange_rate + .decimal_collateral_to_liquidity(self.deposited_collateral_tokens.into())? + .try_div(borrow_token_price)?; + loan.try_div(collateral_value) + } + /// Accrue interest pub fn accrue_interest(&mut self, cumulative_borrow_rate: Decimal) -> ProgramResult { if cumulative_borrow_rate < self.cumulative_borrow_rate_wads { @@ -72,15 +103,10 @@ impl Obligation { } /// Liquidate part of obligation - pub fn liquidate( - &mut self, - settle_amount: Decimal, - withdraw_amount: u64, - bonus_amount: u64, - ) -> ProgramResult { + pub fn liquidate(&mut self, settle_amount: Decimal, withdraw_amount: u64) -> ProgramResult { self.borrowed_liquidity_wads = self.borrowed_liquidity_wads.try_sub(settle_amount)?; self.deposited_collateral_tokens - .checked_sub(withdraw_amount + bonus_amount) + .checked_sub(withdraw_amount) .ok_or(LendingError::MathOverflow)?; Ok(()) } @@ -95,7 +121,7 @@ impl Obligation { Decimal::from(liquidity_amount).min(self.borrowed_liquidity_wads); let integer_repay_amount = decimal_repay_amount.try_ceil_u64()?; if integer_repay_amount == 0 { - return Err(LendingError::ObligationTooSmall.into()); + return Err(LendingError::ObligationEmpty.into()); } let repay_pct: Decimal = decimal_repay_amount.try_div(self.borrowed_liquidity_wads)?; diff --git a/token-lending/program/src/state/reserve.rs b/token-lending/program/src/state/reserve.rs index eec7855a..025dbb4f 100644 --- a/token-lending/program/src/state/reserve.rs +++ b/token-lending/program/src/state/reserve.rs @@ -18,6 +18,9 @@ use std::convert::{TryFrom, TryInto}; /// Percentage of an obligation that can be repaid during each liquidation call pub const LIQUIDATION_CLOSE_FACTOR: u8 = 50; +/// Loan amount that is small enough to close out +pub const CLOSEABLE_AMOUNT: u64 = 2; + /// Lending market reserve state #[derive(Clone, Debug, Default, PartialEq)] pub struct Reserve { @@ -93,79 +96,88 @@ impl Reserve { pub fn liquidate_obligation( &self, obligation: &Obligation, - liquidity_amount: u64, + liquidate_amount: u64, liquidity_token_mint: &Pubkey, token_converter: impl TokenConverter, ) -> Result { - // calculate the amount of liquidity that will be repaid - let max_repayable = obligation - .borrowed_liquidity_wads - .try_mul(Rate::from_percent(LIQUIDATION_CLOSE_FACTOR))? - .try_floor_u64()?; - let integer_repay_amount = liquidity_amount.min(max_repayable); - if integer_repay_amount == 0 { - return Err(LendingError::ObligationTooSmall.into()); - } + Self::_liquidate_obligation( + obligation, + liquidate_amount, + liquidity_token_mint, + self.collateral_exchange_rate()?, + &self.config, + token_converter, + ) + } - // calculate the amount of collateral that will be received - let decimal_repay_amount = Decimal::from(integer_repay_amount); - let withdraw_amount_in_liquidity = - token_converter.convert(decimal_repay_amount, liquidity_token_mint)?; - let withdraw_amount = self - .collateral_exchange_rate()? - .decimal_liquidity_to_collateral(withdraw_amount_in_liquidity)?; - if withdraw_amount == Decimal::zero() { - return Err(LendingError::LiquidationTooSmall.into()); - } - - // When the value of the loan (obligation.borrowed_liquidity_wads) divided by the value of - // the collateral (collateral_token_price * obligation.deposited_collateral_tokens) is less - // than the liquidation threshold, the loan is healthy and cannot be liquidated. - let collateral_token_price = decimal_repay_amount.try_div(withdraw_amount)?; - let obligation_loan_to_value = obligation - .borrowed_liquidity_wads - .try_div(collateral_token_price)? - .try_div(obligation.deposited_collateral_tokens)?; - let liquidation_threshold = Rate::from_percent(self.config.liquidation_threshold); - if obligation_loan_to_value <= liquidation_threshold.into() { + fn _liquidate_obligation( + obligation: &Obligation, + liquidity_amount: u64, + liquidity_token_mint: &Pubkey, + collateral_exchange_rate: CollateralExchangeRate, + collateral_reserve_config: &ReserveConfig, + mut token_converter: impl TokenConverter, + ) -> Result { + // Check obligation health + let borrow_token_price = token_converter.best_price(liquidity_token_mint)?; + let liquidation_threshold = + Rate::from_percent(collateral_reserve_config.liquidation_threshold); + let obligation_loan_to_value = + obligation.loan_to_value(collateral_exchange_rate, borrow_token_price)?; + if obligation_loan_to_value < liquidation_threshold.into() { return Err(LendingError::HealthyObligation.into()); } - // Don't pay out bonus if withdraw amount covers full collateral balance - let (withdraw_amount, bonus_amount, remaining_amount) = - if withdraw_amount >= obligation.deposited_collateral_tokens.into() { - (obligation.deposited_collateral_tokens, 0, 0) - } else { - let withdraw_amount = withdraw_amount.try_floor_u64()?; - let liquidation_bonus_rate = Rate::from_percent(self.config.liquidation_bonus); - let bonus_amount = Decimal::from(liquidation_bonus_rate) - .try_mul(withdraw_amount)? - .try_floor_u64()?; - let remaining_amount = obligation.deposited_collateral_tokens - withdraw_amount; - if bonus_amount >= remaining_amount { - (withdraw_amount, remaining_amount, 0) - } else { - ( - withdraw_amount, - bonus_amount, - remaining_amount - bonus_amount, - ) - } - }; + // Special handling for small, closeable obligations + let max_closeable_amount = obligation.max_closeable_amount()?; + let close_amount = liquidity_amount.min(max_closeable_amount); + if close_amount > 0 { + return Ok(LiquidateResult { + withdraw_amount: obligation.deposited_collateral_tokens, + repay_amount: close_amount, + settle_amount: obligation.borrowed_liquidity_wads, + }); + } - // Determine if the loan has defaulted - let settle_amount = if remaining_amount == 0 { - obligation.borrowed_liquidity_wads - } else { - decimal_repay_amount + // Calculate the amount of liquidity that will be repaid + let max_liquidation_amount = obligation.max_liquidation_amount()?; + let repay_amount = liquidity_amount.min(max_liquidation_amount); + let decimal_repay_amount = Decimal::from(repay_amount); + + // Calculate the amount of collateral that will be received + let withdraw_amount = { + let receive_liquidity_amount = + token_converter.convert(decimal_repay_amount, liquidity_token_mint)?; + let collateral_amount = collateral_exchange_rate + .decimal_liquidity_to_collateral(receive_liquidity_amount)?; + let bonus_rate = Rate::from_percent(collateral_reserve_config.liquidation_bonus); + let bonus_amount = collateral_amount.try_mul(bonus_rate)?; + let withdraw_amount = collateral_amount.try_add(bonus_amount)?; + let withdraw_amount = + withdraw_amount.min(obligation.deposited_collateral_tokens.into()); + if repay_amount == max_liquidation_amount { + withdraw_amount.try_ceil_u64()? + } else { + withdraw_amount.try_floor_u64()? + } }; - Ok(LiquidateResult { - bonus_amount, - withdraw_amount, - repay_amount: integer_repay_amount, - settle_amount, - }) + if withdraw_amount > 0 { + // TODO: charge less liquidity if withdraw value exceeds loan collateral + let settle_amount = if withdraw_amount == obligation.deposited_collateral_tokens { + obligation.borrowed_liquidity_wads + } else { + decimal_repay_amount + }; + + Ok(LiquidateResult { + withdraw_amount, + repay_amount, + settle_amount, + }) + } else { + Err(LendingError::LiquidationTooSmall.into()) + } } /// Create new loan @@ -355,8 +367,6 @@ pub struct LoanResult { /// Liquidate obligation result #[derive(Debug)] pub struct LiquidateResult { - /// Amount of collateral to receive as bonus - pub bonus_amount: u64, /// Amount of collateral to withdraw in exchange for repay amount pub withdraw_amount: u64, /// Amount of liquidity that is settled from the obligation. It includes @@ -742,6 +752,10 @@ mod test { struct MockConverter(Decimal); impl TokenConverter for MockConverter { + fn best_price(&mut self, _token_mint: &Pubkey) -> Result { + Ok(self.0) + } + fn convert( self, from_amount: Decimal, @@ -751,19 +765,6 @@ mod test { } } - /// Loan to value ratio - fn loan_to_value_ratio( - obligation: &Obligation, - borrow_token_price: Decimal, - collateral_exchange_rate: CollateralExchangeRate, - ) -> Result { - let borrow_value = borrow_token_price.try_mul(obligation.borrowed_liquidity_wads)?; - let collateral_value = collateral_exchange_rate.decimal_collateral_to_liquidity( - Decimal::from(obligation.deposited_collateral_tokens), - )?; - borrow_value.try_div(collateral_value)?.try_into() - } - /// Convert reserve liquidity tokens to the collateral tokens of another reserve fn liquidity_in_other_collateral( liquidity_amount: u64, @@ -786,87 +787,110 @@ mod test { } } + // Creates rates (threshold, ltv) where 2 <= threshold <= 100 and threshold <= ltv <= 1,000% + prop_compose! { + fn unhealthy_rates()(threshold in 2..=100u8)( + ltv_rate in threshold as u64..=1000u64, + threshold in Just(threshold), + ) -> (Decimal, u8) { + (Decimal::from_scaled_val(ltv_rate as u128 * PERCENT_SCALER as u128), threshold) + } + } + + // Creates a range of reasonable token conversion rates + prop_compose! { + fn token_conversion_rate()( + conversion_rate in 1..=u16::MAX, + invert_conversion_rate: bool, + ) -> Decimal { + let conversion_rate = Decimal::from(conversion_rate as u64); + if invert_conversion_rate { + Decimal::one().try_div(conversion_rate).unwrap() + } else { + conversion_rate + } + } + } + + // Creates a range of reasonable collateral exchange rates + prop_compose! { + fn collateral_exchange_rate_range()(percent in 1..=500u64) -> CollateralExchangeRate { + CollateralExchangeRate(Rate::from_scaled_val(percent * PERCENT_SCALER)) + } + } + proptest! { #[test] - fn liquidate_obligation( - obligation_loan in 0..=u64::MAX, - obligation_collateral in 0..=u64::MAX, - liquidate_amount in 0..=u64::MAX, - collateral_exchange_rate in PERCENT_SCALER..=5 * WAD, - token_conversion_rate in 0..=u64::MAX as u128, - liquidation_bonus in 0..=100u8, - liquidation_threshold in 2..=100u8, + fn unhealthy_obligations_can_be_liquidated( + obligation_collateral in 1..=u64::MAX, + (obligation_ltv, liquidation_threshold) in unhealthy_rates(), + collateral_exchange_rate in collateral_exchange_rate_range(), + token_conversion_rate in token_conversion_rate(), ) { - let borrowed_liquidity_wads = Decimal::from(obligation_loan); + let collateral_reserve_config = &ReserveConfig { + liquidation_threshold, + ..ReserveConfig::default() + }; + + // Create unhealthy obligation at target LTV + let collateral_value = collateral_exchange_rate + .decimal_collateral_to_liquidity(Decimal::from(obligation_collateral as u64))? + .try_div(token_conversion_rate)?; + + // Ensure that collateral value fits in u64 + prop_assume!(collateral_value.try_round_u64().is_ok()); + + let borrowed_liquidity_wads = collateral_value + .try_mul(obligation_ltv)? + .try_add(Decimal::from_scaled_val(1u128))? // ensure loan is unhealthy + .max(Decimal::from(2u64)); // avoid dust account closure + + // Ensure that borrow value fits in u64 + prop_assume!(borrowed_liquidity_wads.try_round_u64().is_ok()); + let obligation = Obligation { - deposited_collateral_tokens: obligation_collateral, + deposited_collateral_tokens: obligation_collateral as u64, borrowed_liquidity_wads, ..Obligation::default() }; - let total_liquidity = 1_000_000; - let collateral_token_supply = Decimal::from(total_liquidity) - .try_mul(Rate::from_scaled_val(collateral_exchange_rate))? - .try_floor_u64()?; - let reserve = Reserve { - collateral: ReserveCollateral { - mint_total_supply: collateral_token_supply, - ..ReserveCollateral::default() - }, - liquidity: ReserveLiquidity { - available_amount: total_liquidity, - ..ReserveLiquidity::default() - }, - config: ReserveConfig { - liquidation_threshold, - liquidation_bonus, - ..ReserveConfig::default() - }, - ..Reserve::default() - }; - - let conversion_rate = Decimal::from_scaled_val(token_conversion_rate); - let liquidate_result = reserve.liquidate_obligation( + // Liquidate with max amount to ensure obligation can be liquidated + let liquidate_result = Reserve::_liquidate_obligation( &obligation, - liquidate_amount, + u64::MAX, &Pubkey::default(), - MockConverter(conversion_rate) + collateral_exchange_rate, + collateral_reserve_config, + MockConverter(token_conversion_rate) ); - let collateral_exchange_rate = reserve.collateral_exchange_rate()?; - let obligation_ltv = loan_to_value_ratio(&obligation, conversion_rate, collateral_exchange_rate)?; - if obligation_ltv <= Rate::from_percent(liquidation_threshold) { - assert_eq!( - liquidate_result.unwrap_err(), - LendingError::HealthyObligation.into() - ); - } else { - let liquidate_result = liquidate_result.unwrap(); - assert!( - Decimal::from(liquidate_result.withdraw_amount) <= - liquidity_in_other_collateral( - liquidate_result.repay_amount, - collateral_exchange_rate, - conversion_rate, - )? - ); - assert!( - Decimal::from(liquidate_result.repay_amount) <= - obligation.borrowed_liquidity_wads.try_mul(Rate::from_percent(LIQUIDATION_CLOSE_FACTOR))? - ); - assert!( - Decimal::from(liquidate_result.bonus_amount) <= - Decimal::from(liquidate_result.withdraw_amount).try_mul(Rate::from_percent(liquidation_bonus))? - ); + let liquidate_result = liquidate_result.unwrap(); + let expected_withdraw_amount = liquidity_in_other_collateral( + liquidate_result.repay_amount, + collateral_exchange_rate, + token_conversion_rate, + )?.min(obligation.deposited_collateral_tokens.into()); - let total_withdraw = liquidate_result.withdraw_amount + liquidate_result.bonus_amount; - let defaulted = total_withdraw == obligation.deposited_collateral_tokens; - if defaulted { - assert_eq!(liquidate_result.settle_amount, borrowed_liquidity_wads); - } else { - assert_eq!(liquidate_result.settle_amount.try_ceil_u64()?, liquidate_result.repay_amount); - assert!(total_withdraw < obligation.deposited_collateral_tokens); - } + assert!(liquidate_result.repay_amount > 0); + assert!(liquidate_result.withdraw_amount > 0); + + let min_withdraw_amount = expected_withdraw_amount.try_floor_u64()?; + let max_withdraw_amount = expected_withdraw_amount.try_ceil_u64()?; + let max_repay_amount = obligation.borrowed_liquidity_wads + .try_mul(Rate::from_percent(LIQUIDATION_CLOSE_FACTOR))? + .try_ceil_u64()?; + + assert!(liquidate_result.withdraw_amount >= min_withdraw_amount); + assert!(liquidate_result.withdraw_amount <= max_withdraw_amount); + assert!(liquidate_result.repay_amount <= max_repay_amount); + + let defaulted = liquidate_result.withdraw_amount == obligation.deposited_collateral_tokens; + if defaulted { + assert_eq!(liquidate_result.settle_amount, borrowed_liquidity_wads); + assert!(liquidate_result.repay_amount < liquidate_result.settle_amount.try_floor_u64()?); + } else { + assert_eq!(liquidate_result.settle_amount.try_ceil_u64()?, liquidate_result.repay_amount); + assert!(liquidate_result.withdraw_amount < obligation.deposited_collateral_tokens); } } @@ -922,14 +946,13 @@ mod test { #[test] fn allowed_borrow_for_collateral( collateral_amount in 0..=u32::MAX as u64, - collateral_exchange_rate in PERCENT_SCALER..=5 * WAD, - token_conversion_rate in 0..=u64::MAX as u128, + collateral_exchange_rate in collateral_exchange_rate_range(), + token_conversion_rate in 1..=u64::MAX, loan_to_value_ratio in 1..100u8, ) { let total_liquidity = 1_000_000; - let collateral_token_supply = Decimal::from(total_liquidity) - .try_mul(Rate::from_scaled_val(collateral_exchange_rate))? - .try_round_u64()?; + let collateral_token_supply = collateral_exchange_rate + .liquidity_to_collateral(total_liquidity)?; let reserve = Reserve { collateral: ReserveCollateral { mint_total_supply: collateral_token_supply, @@ -946,7 +969,7 @@ mod test { ..Reserve::default() }; - let conversion_rate = Decimal::from_scaled_val(token_conversion_rate); + let conversion_rate = Decimal::from_scaled_val(token_conversion_rate as u128); let borrow_amount = reserve.allowed_borrow_for_collateral( collateral_amount, MockConverter(conversion_rate) @@ -980,14 +1003,13 @@ mod test { #[test] fn required_collateral_for_borrow( borrow_amount in 0..=u32::MAX as u64, - collateral_exchange_rate in PERCENT_SCALER..=5 * WAD, - token_conversion_rate in 0..=u64::MAX as u128, + collateral_exchange_rate in collateral_exchange_rate_range(), + token_conversion_rate in 1..=u64::MAX, loan_to_value_ratio in 1..=100u8, ) { let total_liquidity = 1_000_000; - let collateral_token_supply = Decimal::from(total_liquidity) - .try_mul(Rate::from_scaled_val(collateral_exchange_rate))? - .try_round_u64()?; + let collateral_token_supply = collateral_exchange_rate + .liquidity_to_collateral(total_liquidity)?; let reserve = Reserve { collateral: ReserveCollateral { mint_total_supply: collateral_token_supply, @@ -1004,7 +1026,7 @@ mod test { ..Reserve::default() }; - let conversion_rate = Decimal::from_scaled_val(token_conversion_rate); + let conversion_rate = Decimal::from_scaled_val(token_conversion_rate as u128); let collateral_amount = reserve.required_collateral_for_borrow( borrow_amount, &Pubkey::default(), @@ -1179,6 +1201,79 @@ mod test { } } + #[test] + fn liquidate_amount_too_small() { + let conversion_rate = Decimal::from_scaled_val(PERCENT_SCALER as u128); // 1% + let collateral_exchange_rate = CollateralExchangeRate(Rate::one()); + let collateral_reserve_config = &ReserveConfig { + liquidation_threshold: 80u8, + liquidation_bonus: 5u8, + ..ReserveConfig::default() + }; + + let obligation = Obligation { + deposited_collateral_tokens: 1, + borrowed_liquidity_wads: Decimal::from(100u64), + ..Obligation::default() + }; + + let liquidate_result = Reserve::_liquidate_obligation( + &obligation, + 1u64, // converts to 0.01 collateral + &Pubkey::default(), + collateral_exchange_rate, + collateral_reserve_config, + MockConverter(conversion_rate), + ); + + assert_eq!( + liquidate_result.unwrap_err(), + LendingError::LiquidationTooSmall.into() + ); + } + + #[test] + fn liquidate_dust_obligation() { + let conversion_rate = Decimal::one(); + let collateral_exchange_rate = CollateralExchangeRate(Rate::one()); + let collateral_reserve_config = &ReserveConfig { + liquidation_threshold: 80u8, + liquidation_bonus: 5u8, + ..ReserveConfig::default() + }; + + let obligation = Obligation { + deposited_collateral_tokens: 1, + borrowed_liquidity_wads: Decimal::one() + .try_add(Decimal::from_scaled_val(1u128)) + .unwrap(), + ..Obligation::default() + }; + + let liquidate_result = Reserve::_liquidate_obligation( + &obligation, + 2, + &Pubkey::default(), + collateral_exchange_rate, + collateral_reserve_config, + MockConverter(conversion_rate), + ) + .unwrap(); + + assert_eq!( + liquidate_result.repay_amount, + obligation.borrowed_liquidity_wads.try_ceil_u64().unwrap() + ); + assert_eq!( + liquidate_result.withdraw_amount, + obligation.deposited_collateral_tokens + ); + assert_eq!( + liquidate_result.settle_amount, + obligation.borrowed_liquidity_wads + ); + } + #[test] fn borrow_fee_calculation_min_host() { let fees = ReserveFees { diff --git a/token-lending/program/tests/liquidate.rs b/token-lending/program/tests/liquidate.rs index d09aa54e..96f90200 100644 --- a/token-lending/program/tests/liquidate.rs +++ b/token-lending/program/tests/liquidate.rs @@ -24,7 +24,7 @@ async fn test_success() { ); // limit to track compute unit increase - test.set_bpf_compute_max_units(101_000); + test.set_bpf_compute_max_units(97_000); // set loan values to about 90% of collateral value so that it gets liquidated const USDC_LOAN: u64 = 2 * FRACTIONAL_TO_USDC;