diff --git a/token-lending/program/src/math/decimal.rs b/token-lending/program/src/math/decimal.rs index c19e1661..0c4509ae 100644 --- a/token-lending/program/src/math/decimal.rs +++ b/token-lending/program/src/math/decimal.rs @@ -73,6 +73,27 @@ impl Decimal { .ok_or(LendingError::MathOverflow)?; Ok(u64::try_from(rounded_val).map_err(|_| LendingError::MathOverflow)?) } + + /// Ceiling scaled decimal to u64 + pub fn try_ceil_u64(&self) -> Result { + let ceil_val = Self::wad() + .checked_sub(U192::from(1u64)) + .ok_or(LendingError::MathOverflow)? + .checked_add(self.0) + .ok_or(LendingError::MathOverflow)? + .checked_div(Self::wad()) + .ok_or(LendingError::MathOverflow)?; + Ok(u64::try_from(ceil_val).map_err(|_| LendingError::MathOverflow)?) + } + + /// Floor scaled decimal to u64 + pub fn try_floor_u64(&self) -> Result { + let ceil_val = self + .0 + .checked_div(Self::wad()) + .ok_or(LendingError::MathOverflow)?; + Ok(u64::try_from(ceil_val).map_err(|_| LendingError::MathOverflow)?) + } } impl fmt::Display for Decimal { diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index deb5b07e..a8c51401 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -4,10 +4,10 @@ use crate::{ dex_market::{DexMarket, TradeAction, TradeSimulator, BASE_MINT_OFFSET, QUOTE_MINT_OFFSET}, error::LendingError, instruction::{BorrowAmountType, LendingInstruction}, - math::{Decimal, Rate, TryAdd, TryDiv, TryMul, TrySub, WAD}, + math::{Decimal, Rate, TryAdd, TryMul, TrySub, WAD}, state::{ - LendingMarket, NewReserveParams, Obligation, Reserve, ReserveCollateral, ReserveConfig, - ReserveLiquidity, PROGRAM_VERSION, + LendingMarket, NewReserveParams, Obligation, RepayResult, Reserve, ReserveCollateral, + ReserveConfig, ReserveLiquidity, PROGRAM_VERSION, }, }; use num_traits::FromPrimitive; @@ -789,10 +789,6 @@ fn process_repay( if obligation_info.owner != program_id { return Err(LendingError::InvalidAccountOwner.into()); } - if &obligation.token_mint != obligation_token_mint_info.key { - msg!("Invalid obligation token mint account"); - return Err(LendingError::InvalidAccountInput.into()); - } if &obligation.borrow_reserve != repay_reserve_info.key { msg!("Invalid repay reserve account"); return Err(LendingError::InvalidAccountInput.into()); @@ -802,6 +798,12 @@ fn process_repay( return Err(LendingError::InvalidAccountInput.into()); } + let obligation_mint = unpack_mint(&obligation_token_mint_info.data.borrow())?; + if &obligation.token_mint != obligation_token_mint_info.key { + msg!("Invalid obligation token mint account"); + return Err(LendingError::InvalidAccountInput.into()); + } + let mut repay_reserve = Reserve::unpack(&repay_reserve_info.data.borrow())?; if repay_reserve_info.owner != program_id { return Err(LendingError::InvalidAccountOwner.into()); @@ -846,25 +848,17 @@ fn process_repay( repay_reserve.accrue_interest(clock.slot)?; obligation.accrue_interest(repay_reserve.cumulative_borrow_rate_wads)?; - let repay_amount = Decimal::from(liquidity_amount).min(obligation.borrowed_liquidity_wads); - let rounded_repay_amount = repay_reserve.liquidity.repay(repay_amount)?; + let RepayResult { + integer_repay_amount, + decimal_repay_amount, + collateral_withdraw_amount, + obligation_token_amount, + } = obligation.repay(liquidity_amount, obligation_mint.supply)?; + repay_reserve + .liquidity + .repay(integer_repay_amount, decimal_repay_amount)?; + Reserve::pack(repay_reserve, &mut repay_reserve_info.data.borrow_mut())?; - - let repay_pct: Decimal = repay_amount.try_div(obligation.borrowed_liquidity_wads)?; - let collateral_withdraw_amount = { - let withdraw_amount: Decimal = repay_pct.try_mul(obligation.deposited_collateral_tokens)?; - withdraw_amount.try_round_u64()? - }; - - let obligation_token_amount = { - let obligation_mint = &unpack_mint(&obligation_token_mint_info.data.borrow())?; - let token_amount: Decimal = repay_pct.try_mul(obligation_mint.supply)?; - token_amount.try_round_u64()? - }; - - obligation.borrowed_liquidity_wads = - obligation.borrowed_liquidity_wads.try_sub(repay_amount)?; - obligation.deposited_collateral_tokens -= collateral_withdraw_amount; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; let authority_signer_seeds = &[ @@ -877,11 +871,21 @@ fn process_repay( return Err(LendingError::InvalidMarketAuthority.into()); } + // burn obligation tokens + spl_token_burn(TokenBurnParams { + mint: obligation_token_mint_info.clone(), + source: obligation_token_input_info.clone(), + amount: obligation_token_amount, + authority: user_transfer_authority_info.clone(), + authority_signer_seeds: &[], + token_program: token_program_id.clone(), + })?; + // deposit repaid liquidity spl_token_transfer(TokenTransferParams { source: source_liquidity_info.clone(), destination: repay_reserve_liquidity_supply_info.clone(), - amount: rounded_repay_amount, + amount: integer_repay_amount, authority: user_transfer_authority_info.clone(), authority_signer_seeds: &[], token_program: token_program_id.clone(), @@ -897,16 +901,6 @@ fn process_repay( token_program: token_program_id.clone(), })?; - // burn obligation tokens - spl_token_burn(TokenBurnParams { - mint: obligation_token_mint_info.clone(), - source: obligation_token_input_info.clone(), - amount: obligation_token_amount, - authority: user_transfer_authority_info.clone(), - authority_signer_seeds: &[], - token_program: token_program_id.clone(), - })?; - Ok(()) } @@ -1043,16 +1037,22 @@ fn process_liquidate( // calculate the amount of liquidity that will be repaid let close_factor = Rate::from_percent(50); - let repay_amount = Decimal::from(liquidity_amount) + let decimal_repay_amount = Decimal::from(liquidity_amount) .min(obligation.borrowed_liquidity_wads.try_mul(close_factor)?); - let rounded_repay_amount = repay_reserve.liquidity.repay(repay_amount)?; + let integer_repay_amount = decimal_repay_amount.try_round_u64()?; + if integer_repay_amount == 0 { + return Err(LendingError::ObligationTooSmall.into()); + } + repay_reserve + .liquidity + .repay(integer_repay_amount, decimal_repay_amount)?; // TODO: check math precision // calculate the amount of collateral that will be withdrawn let withdraw_liquidity_amount = trade_simulator.simulate_trade( TradeAction::Sell, - repay_amount, + decimal_repay_amount, &repay_reserve.liquidity.mint_pubkey, false, )?; @@ -1071,8 +1071,9 @@ fn process_liquidate( &mut withdraw_reserve_info.data.borrow_mut(), )?; - obligation.borrowed_liquidity_wads = - obligation.borrowed_liquidity_wads.try_sub(repay_amount)?; + obligation.borrowed_liquidity_wads = obligation + .borrowed_liquidity_wads + .try_sub(decimal_repay_amount)?; obligation.deposited_collateral_tokens -= collateral_withdraw_amount; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; @@ -1090,7 +1091,7 @@ fn process_liquidate( spl_token_transfer(TokenTransferParams { source: source_liquidity_info.clone(), destination: repay_reserve_liquidity_supply_info.clone(), - amount: rounded_repay_amount, + amount: integer_repay_amount, authority: user_transfer_authority_info.clone(), authority_signer_seeds: &[], token_program: token_program_id.clone(), diff --git a/token-lending/program/src/state/obligation.rs b/token-lending/program/src/state/obligation.rs index 2a4ccf46..f7ec95ab 100644 --- a/token-lending/program/src/state/obligation.rs +++ b/token-lending/program/src/state/obligation.rs @@ -1,7 +1,7 @@ use super::*; use crate::{ error::LendingError, - math::{Decimal, Rate, TryDiv, TryMul}, + math::{Decimal, Rate, TryDiv, TryMul, TrySub}, }; use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; use solana_program::{ @@ -49,6 +49,59 @@ impl Obligation { Ok(()) } + + /// Repay borrowed tokens + pub fn repay( + &mut self, + liquidity_amount: u64, + obligation_token_supply: u64, + ) -> Result { + let decimal_repay_amount = + 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()); + } + + let repay_pct: Decimal = decimal_repay_amount.try_div(self.borrowed_liquidity_wads)?; + let collateral_withdraw_amount = { + let withdraw_amount: Decimal = repay_pct.try_mul(self.deposited_collateral_tokens)?; + withdraw_amount.try_floor_u64()? + }; + + let obligation_token_amount = { + let withdraw_pct = Decimal::from(collateral_withdraw_amount) + .try_div(self.deposited_collateral_tokens)?; + let token_amount: Decimal = withdraw_pct.try_mul(obligation_token_supply)?; + token_amount.try_floor_u64()? + }; + + self.borrowed_liquidity_wads = + self.borrowed_liquidity_wads.try_sub(decimal_repay_amount)?; + self.deposited_collateral_tokens = self + .deposited_collateral_tokens + .checked_sub(collateral_withdraw_amount) + .ok_or(LendingError::MathOverflow)?; + + Ok(RepayResult { + decimal_repay_amount, + integer_repay_amount, + collateral_withdraw_amount, + obligation_token_amount, + }) + } +} + +/// Obligation repay result +pub struct RepayResult { + /// Amount of collateral to withdraw + pub collateral_withdraw_amount: u64, + /// Amount of obligation tokens to burn + pub obligation_token_amount: u64, + /// Amount that will be repaid as precise decimal + pub decimal_repay_amount: Decimal, + /// Amount that will be repaid as u64 + pub integer_repay_amount: u64, } impl Sealed for Obligation {} @@ -150,17 +203,96 @@ mod test { } // Creates rates (r1, r2) where 0 < r1 <= r2 <= 100*r1 - fn cumulative_rates() -> impl Strategy { - prop::num::u128::ANY.prop_flat_map(|rate| { - let current_rate = rate.max(1); - let max_new_rate = current_rate.saturating_mul(MAX_COMPOUNDED_INTEREST as u128); - (Just(current_rate), current_rate..=max_new_rate) - }) + prop_compose! { + fn cumulative_rates()(rate in 1..=u128::MAX)( + current_rate in Just(rate), + max_new_rate in rate..=rate.saturating_mul(MAX_COMPOUNDED_INTEREST as u128) + ) -> (u128, u128) { + (current_rate, max_new_rate) + } + } + + const MAX_BORROWED: u128 = u64::MAX as u128 * WAD as u128; + + // Creates liquidity amounts (repay, borrow) where repay < borrow + prop_compose! { + fn repay_partial_amounts()(repay in 1..=u64::MAX)( + liquidity_amount in Just(repay), + borrowed_liquidity in (WAD as u128 * repay as u128 + 1)..=MAX_BORROWED + ) -> (u64, u128) { + (liquidity_amount, borrowed_liquidity) + } + } + + // Creates liquidity amounts (repay, borrow) where repay >= borrow + prop_compose! { + fn repay_full_amounts()(repay in 1..=u64::MAX)( + liquidity_amount in Just(repay), + borrowed_liquidity in 0..=(WAD as u128 * repay as u128) + ) -> (u64, u128) { + (liquidity_amount, borrowed_liquidity) + } + } + + // Creates collateral amounts (collateral, obligation tokens) where c <= ot + prop_compose! { + fn collateral_amounts()(collateral in 1..=u64::MAX)( + deposited_collateral_tokens in Just(collateral), + obligation_tokens in collateral..=u64::MAX + ) -> (u64, u64) { + (deposited_collateral_tokens, obligation_tokens) + } } proptest! { #[test] - fn obligation_accrue_interest( + fn repay_partial( + (liquidity_amount, borrowed_liquidity) in repay_partial_amounts(), + (deposited_collateral_tokens, obligation_tokens) in collateral_amounts(), + ) { + let borrowed_liquidity_wads = Decimal::from_scaled_val(borrowed_liquidity); + let mut state = Obligation { + borrowed_liquidity_wads, + deposited_collateral_tokens, + ..Obligation::default() + }; + + let repay_result = state.repay(liquidity_amount, obligation_tokens)?; + assert!(repay_result.decimal_repay_amount <= Decimal::from(repay_result.integer_repay_amount)); + assert!(repay_result.collateral_withdraw_amount < deposited_collateral_tokens); + assert!(repay_result.obligation_token_amount < obligation_tokens); + assert!(state.borrowed_liquidity_wads < borrowed_liquidity_wads); + assert!(state.borrowed_liquidity_wads > Decimal::zero()); + assert!(state.deposited_collateral_tokens > 0); + + let obligation_token_rate = Decimal::from(repay_result.obligation_token_amount).try_div(Decimal::from(obligation_tokens))?; + let collateral_withdraw_rate = Decimal::from(repay_result.collateral_withdraw_amount).try_div(Decimal::from(deposited_collateral_tokens))?; + assert!(obligation_token_rate <= collateral_withdraw_rate); + } + + #[test] + fn repay_full( + (liquidity_amount, borrowed_liquidity) in repay_full_amounts(), + (deposited_collateral_tokens, obligation_tokens) in collateral_amounts(), + ) { + let borrowed_liquidity_wads = Decimal::from_scaled_val(borrowed_liquidity); + let mut state = Obligation { + borrowed_liquidity_wads, + deposited_collateral_tokens, + ..Obligation::default() + }; + + let repay_result = state.repay(liquidity_amount, obligation_tokens)?; + assert!(repay_result.decimal_repay_amount <= Decimal::from(repay_result.integer_repay_amount)); + assert_eq!(repay_result.collateral_withdraw_amount, deposited_collateral_tokens); + assert_eq!(repay_result.obligation_token_amount, obligation_tokens); + assert_eq!(repay_result.decimal_repay_amount, borrowed_liquidity_wads); + assert_eq!(state.borrowed_liquidity_wads, Decimal::zero()); + assert_eq!(state.deposited_collateral_tokens, 0); + } + + #[test] + fn accrue_interest( borrowed_liquidity in 0..=u64::MAX, (current_borrow_rate, new_borrow_rate) in cumulative_rates(), ) { diff --git a/token-lending/program/src/state/reserve.rs b/token-lending/program/src/state/reserve.rs index ec6892e3..09b58e8c 100644 --- a/token-lending/program/src/state/reserve.rs +++ b/token-lending/program/src/state/reserve.rs @@ -218,20 +218,19 @@ impl ReserveLiquidity { Ok(()) } - /// Subtract repay amount from total borrows and return rounded repay value - pub fn repay(&mut self, repay_amount: Decimal) -> Result { - let rounded_repay_amount = repay_amount.try_round_u64()?; - if rounded_repay_amount == 0 { - return Err(LendingError::ObligationTooSmall.into()); - } - + /// Subtract repay amount from total borrows and add to available liquidity + pub fn repay( + &mut self, + integer_amount: u64, + decimal_amount: Decimal, + ) -> Result<(), ProgramError> { self.available_amount = self .available_amount - .checked_add(rounded_repay_amount) + .checked_add(integer_amount) .ok_or(LendingError::MathOverflow)?; - self.borrowed_amount_wads = self.borrowed_amount_wads.try_sub(repay_amount)?; + self.borrowed_amount_wads = self.borrowed_amount_wads.try_sub(decimal_amount)?; - Ok(rounded_repay_amount) + Ok(()) } /// Calculate the liquidity utilization rate of the reserve diff --git a/token-lending/program/tests/repay.rs b/token-lending/program/tests/repay.rs index e2ed39e7..6cf54b4b 100644 --- a/token-lending/program/tests/repay.rs +++ b/token-lending/program/tests/repay.rs @@ -29,7 +29,7 @@ async fn test_success() { ); // limit to track compute unit increase - test.set_bpf_compute_max_units(85_000); + test.set_bpf_compute_max_units(87_000); const INITIAL_SOL_RESERVE_SUPPLY_LAMPORTS: u64 = 100 * LAMPORTS_TO_SOL; const INITIAL_USDC_RESERVE_SUPPLY_FRACTIONAL: u64 = 100 * FRACTIONAL_TO_USDC; @@ -179,7 +179,7 @@ async fn test_success() { let expected_collateral_received = expected_obligation_repaid_percent .try_mul(OBLIGATION_COLLATERAL) .unwrap() - .try_round_u64() + .try_floor_u64() .unwrap(); assert_eq!(collateral_received, expected_collateral_received);