lending: Fixes and tests for borrow and liquidate (#1104)

* lending: Move borrow calculation to reserve

* lending: Test and fix borrow and liquidate

* fix: allow obligations to be disabled

* Test cleanup
This commit is contained in:
Justin Starry 2021-01-22 09:56:19 +08:00 committed by GitHub
parent 62e6a67eb2
commit d6571949a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 551 additions and 231 deletions

View File

@ -2,14 +2,13 @@
use crate::{
error::LendingError,
instruction::BorrowAmountType,
math::{Decimal, Rate, TryAdd, TryDiv, TryMul, TrySub},
state::Reserve,
math::{Decimal, TryAdd, TryDiv, TryMul, TrySub},
state::TokenConverter,
};
use arrayref::{array_refs, mut_array_refs};
use serum_dex::critbit::Slab;
use solana_program::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey};
use std::{cell::RefMut, collections::VecDeque, convert::TryFrom};
use std::{cell::RefMut, convert::TryFrom};
/// Side of the dex market order book
#[derive(Clone, Copy, PartialEq)]
@ -49,58 +48,30 @@ struct Order {
quantity: u64,
}
/// Dex market orders used for simulating trades
enum Orders<'a> {
DexMarket(DexMarketOrders<'a>),
Cached(VecDeque<Order>),
None,
}
impl Orders<'_> {
// BPF rust version does not support matches!
#[allow(clippy::match_like_matches_macro)]
fn is_cacheable(&self) -> bool {
match &self {
Self::DexMarket(_) => true,
_ => false,
}
}
}
impl Iterator for Orders<'_> {
type Item = Order;
fn next(&mut self) -> Option<Order> {
match self {
Orders::DexMarket(dex_market_orders) => {
let leaf_node = match dex_market_orders.side {
Side::Bid => dex_market_orders
.heap
.as_mut()
.and_then(|heap| heap.remove_max()),
Side::Ask => dex_market_orders
.heap
.as_mut()
.and_then(|heap| heap.remove_min()),
}?;
Some(Order {
price: leaf_node.price().get(),
quantity: leaf_node.quantity(),
})
}
Orders::Cached(orders) => orders.pop_front(),
_ => None,
}
}
}
/// Trade simulator
pub struct TradeSimulator<'a> {
dex_market: DexMarket,
orders: Orders<'a>,
orders: DexMarketOrders<'a>,
orders_side: Side,
quote_token_mint: &'a Pubkey,
buy_token_mint: &'a Pubkey,
sell_token_mint: &'a Pubkey,
}
impl<'a> TokenConverter for TradeSimulator<'a> {
fn convert(
self,
from_amount: Decimal,
from_token_mint: &Pubkey,
) -> Result<Decimal, ProgramError> {
let action = if from_token_mint == self.buy_token_mint {
TradeAction::Buy
} else {
TradeAction::Sell
};
self.simulate_trade(action, from_amount)
}
}
impl<'a> TradeSimulator<'a> {
@ -110,27 +81,34 @@ impl<'a> TradeSimulator<'a> {
dex_market_orders: &AccountInfo,
memory: &'a AccountInfo,
quote_token_mint: &'a Pubkey,
buy_token_mint: &'a Pubkey,
sell_token_mint: &'a Pubkey,
) -> Result<Self, ProgramError> {
let dex_market = DexMarket::new(dex_market_info);
let dex_market_orders = DexMarketOrders::new(&dex_market, dex_market_orders, memory)?;
let orders_side = dex_market_orders.side;
let orders = DexMarketOrders::new(&dex_market, dex_market_orders, memory)?;
let orders_side = orders.side;
Ok(Self {
dex_market,
orders: Orders::DexMarket(dex_market_orders),
orders,
orders_side,
quote_token_mint,
buy_token_mint,
sell_token_mint,
})
}
/// Simulate a trade
pub fn simulate_trade(
&mut self,
mut self,
action: TradeAction,
quantity: Decimal,
token_mint: &Pubkey,
cache_orders: bool,
) -> Result<Decimal, ProgramError> {
let token_mint = match action {
TradeAction::Buy => self.buy_token_mint,
TradeAction::Sell => self.sell_token_mint,
};
let currency = if token_mint == self.quote_token_mint {
Currency::Quote
} else {
@ -149,8 +127,7 @@ impl<'a> TradeSimulator<'a> {
}
let input_quantity: Decimal = quantity.try_div(self.dex_market.get_lots(currency))?;
let output_quantity =
self.exchange_with_order_book(input_quantity, currency, cache_orders)?;
let output_quantity = self.exchange_with_order_book(input_quantity, currency)?;
Ok(output_quantity.try_mul(self.dex_market.get_lots(currency.opposite()))?)
}
@ -159,14 +136,8 @@ impl<'a> TradeSimulator<'a> {
&mut self,
mut input_quantity: Decimal,
currency: Currency,
cache_orders: bool,
) -> Result<Decimal, ProgramError> {
let mut output_quantity = Decimal::zero();
let mut order_cache = VecDeque::new();
if cache_orders && !self.orders.is_cacheable() {
return Err(LendingError::TradeSimulationError.into());
}
let zero = Decimal::zero();
while input_quantity > zero {
@ -189,86 +160,10 @@ impl<'a> TradeSimulator<'a> {
input_quantity = input_quantity.try_sub(filled)?;
output_quantity = output_quantity.try_add(output)?;
if cache_orders {
order_cache.push_back(next_order);
}
}
if cache_orders {
self.orders = Orders::Cached(order_cache)
} else {
self.orders = Orders::None
}
Ok(output_quantity)
}
/// Calculate amount borrowed and collateral deposited for the given reserves
/// based on the market state and calculation type
pub fn calculate_borrow_amounts(
&mut self,
deposit_reserve: &Reserve,
borrow_reserve: &Reserve,
amount_type: BorrowAmountType,
amount: u64,
) -> Result<(u64, u64), ProgramError> {
let deposit_reserve_collateral_exchange_rate =
deposit_reserve.collateral_exchange_rate()?;
match amount_type {
BorrowAmountType::LiquidityBorrowAmount => {
let borrow_amount = amount;
// Simulate buying `borrow_amount` of borrow reserve underlying tokens
// to determine how much collateral is needed
let loan_in_deposit_underlying = self.simulate_trade(
TradeAction::Buy,
Decimal::from(borrow_amount),
&borrow_reserve.liquidity.mint_pubkey,
false,
)?;
let loan_in_deposit_collateral = deposit_reserve_collateral_exchange_rate
.decimal_liquidity_to_collateral(loan_in_deposit_underlying)?;
let required_deposit_collateral: Decimal = loan_in_deposit_collateral.try_div(
Rate::from_percent(deposit_reserve.config.loan_to_value_ratio),
)?;
let collateral_deposit_amount = required_deposit_collateral.try_round_u64()?;
if collateral_deposit_amount == 0 {
return Err(LendingError::InvalidAmount.into());
}
Ok((borrow_amount, collateral_deposit_amount))
}
BorrowAmountType::CollateralDepositAmount => {
let collateral_deposit_amount = amount;
let loan_in_deposit_collateral: Decimal = Decimal::from(collateral_deposit_amount)
.try_mul(Rate::from_percent(
deposit_reserve.config.loan_to_value_ratio,
))?;
let loan_in_deposit_underlying = deposit_reserve_collateral_exchange_rate
.decimal_collateral_to_liquidity(loan_in_deposit_collateral)?;
// Simulate selling `loan_in_deposit_underlying` amount of deposit reserve underlying
// tokens to determine how much to lend to the user
let borrow_amount = self.simulate_trade(
TradeAction::Sell,
loan_in_deposit_underlying,
&deposit_reserve.liquidity.mint_pubkey,
false,
)?;
let borrow_amount = borrow_amount.try_round_u64()?;
if borrow_amount == 0 {
return Err(LendingError::InvalidAmount.into());
}
Ok((borrow_amount, collateral_deposit_amount))
}
}
}
}
/// Dex market order account info
@ -307,6 +202,22 @@ impl<'a> DexMarketOrders<'a> {
}
}
impl Iterator for DexMarketOrders<'_> {
type Item = Order;
fn next(&mut self) -> Option<Order> {
let leaf_node = match self.side {
Side::Bid => self.heap.as_mut().and_then(|heap| heap.remove_max()),
Side::Ask => self.heap.as_mut().and_then(|heap| heap.remove_min()),
}?;
Some(Order {
price: leaf_node.price().get(),
quantity: leaf_node.quantity(),
})
}
}
/// Offset for dex market base mint
pub const BASE_MINT_OFFSET: usize = 6;
/// Offset for dex market quote mint

View File

@ -86,6 +86,9 @@ pub enum LendingError {
/// Borrow amount too small
#[error("Borrow amount too small")]
BorrowTooSmall,
/// Liquidation amount too small
#[error("Liquidation amount too small to receive collateral")]
LiquidationTooSmall,
/// Reserve state stale
#[error("Reserve state needs to be updated for the current slot")]
ReserveStale,

View File

@ -1,13 +1,13 @@
//! Program state processor
use crate::{
dex_market::{DexMarket, TradeAction, TradeSimulator, BASE_MINT_OFFSET, QUOTE_MINT_OFFSET},
dex_market::{DexMarket, TradeSimulator, BASE_MINT_OFFSET, QUOTE_MINT_OFFSET},
error::LendingError,
instruction::{BorrowAmountType, LendingInstruction},
math::{Decimal, Rate, TryAdd, TryMul, TrySub, WAD},
math::{Decimal, TryAdd, WAD},
state::{
LendingMarket, NewObligationParams, NewReserveParams, Obligation, RepayResult, Reserve,
ReserveCollateral, ReserveConfig, ReserveLiquidity, PROGRAM_VERSION,
LendingMarket, LiquidateResult, NewObligationParams, NewReserveParams, Obligation,
RepayResult, Reserve, ReserveCollateral, ReserveConfig, ReserveLiquidity, PROGRAM_VERSION,
},
};
use num_traits::FromPrimitive;
@ -123,16 +123,18 @@ fn process_init_reserve(
msg!("Optimal utilization rate must be in range [0, 100]");
return Err(LendingError::InvalidConfig.into());
}
if config.loan_to_value_ratio > 90 {
msg!("Loan to value ratio must be in range [0, 90]");
if config.loan_to_value_ratio >= 100 {
msg!("Loan to value ratio must be in range [0, 100)");
return Err(LendingError::InvalidConfig.into());
}
if config.liquidation_bonus > 100 {
msg!("Liquidation bonus must be in range [0, 100]");
return Err(LendingError::InvalidConfig.into());
}
if config.liquidation_threshold > 100 {
msg!("Liquidation threshold must be in range [0, 100]");
if config.liquidation_threshold <= config.loan_to_value_ratio
|| config.liquidation_threshold > 100
{
msg!("Liquidation threshold must be in range (LTV, 100]");
return Err(LendingError::InvalidConfig.into());
}
if config.optimal_borrow_rate < config.min_borrow_rate {
@ -588,11 +590,11 @@ fn process_withdraw(
#[inline(never)] // avoid stack frame limit
fn process_borrow(
program_id: &Pubkey,
amount: u64,
amount_type: BorrowAmountType,
token_amount: u64,
token_amount_type: BorrowAmountType,
accounts: &[AccountInfo],
) -> ProgramResult {
if amount == 0 {
if token_amount == 0 {
return Err(LendingError::InvalidAmount.into());
}
@ -727,33 +729,27 @@ fn process_borrow(
assert_last_update_slot(&deposit_reserve, clock.slot)?;
obligation.accrue_interest(borrow_reserve.cumulative_borrow_rate_wads)?;
let mut trade_simulator = TradeSimulator::new(
let trade_simulator = TradeSimulator::new(
dex_market_info,
dex_market_orders_info,
memory,
&lending_market.quote_token_mint,
&borrow_reserve.liquidity.mint_pubkey,
&deposit_reserve.liquidity.mint_pubkey,
)?;
let (borrow_amount, mut collateral_deposit_amount) = trade_simulator.calculate_borrow_amounts(
&deposit_reserve,
&borrow_reserve,
amount_type,
amount,
let loan = deposit_reserve.create_loan(
token_amount,
token_amount_type,
trade_simulator,
&borrow_reserve.liquidity.mint_pubkey,
)?;
let (mut borrow_fee, host_fee) = deposit_reserve
.config
.fees
.calculate_borrow_fees(collateral_deposit_amount)?;
// update amount actually deposited
collateral_deposit_amount -= borrow_fee;
borrow_reserve.liquidity.borrow(borrow_amount)?;
borrow_reserve.liquidity.borrow(loan.borrow_amount)?;
obligation.borrowed_liquidity_wads = obligation
.borrowed_liquidity_wads
.try_add(Decimal::from(borrow_amount))?;
obligation.deposited_collateral_tokens += collateral_deposit_amount;
.try_add(Decimal::from(loan.borrow_amount))?;
obligation.deposited_collateral_tokens += loan.collateral_amount;
Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?;
Reserve::pack(borrow_reserve, &mut borrow_reserve_info.data.borrow_mut())?;
@ -772,20 +768,21 @@ fn process_borrow(
spl_token_transfer(TokenTransferParams {
source: source_collateral_info.clone(),
destination: deposit_reserve_collateral_supply_info.clone(),
amount: collateral_deposit_amount,
amount: loan.collateral_amount,
authority: user_transfer_authority_info.clone(),
authority_signer_seeds: &[],
token_program: token_program_id.clone(),
})?;
// transfer host fees if host is specified
let mut owner_fee = loan.origination_fee;
if let Ok(host_fee_recipient) = next_account_info(account_info_iter) {
if host_fee > 0 {
borrow_fee -= host_fee;
if loan.host_fee > 0 {
owner_fee -= loan.host_fee;
spl_token_transfer(TokenTransferParams {
source: source_collateral_info.clone(),
destination: host_fee_recipient.clone(),
amount: host_fee,
amount: loan.host_fee,
authority: user_transfer_authority_info.clone(),
authority_signer_seeds: &[],
token_program: token_program_id.clone(),
@ -794,11 +791,11 @@ fn process_borrow(
}
// transfer remaining fees to owner
if borrow_fee > 0 {
if owner_fee > 0 {
spl_token_transfer(TokenTransferParams {
source: source_collateral_info.clone(),
destination: deposit_reserve_collateral_fees_receiver_info.clone(),
amount: borrow_fee,
amount: owner_fee,
authority: user_transfer_authority_info.clone(),
authority_signer_seeds: &[],
token_program: token_program_id.clone(),
@ -809,7 +806,7 @@ fn process_borrow(
spl_token_transfer(TokenTransferParams {
source: borrow_reserve_liquidity_supply_info.clone(),
destination: destination_liquidity_info.clone(),
amount: borrow_amount,
amount: loan.borrow_amount,
authority: lending_market_authority_info.clone(),
authority_signer_seeds,
token_program: token_program_id.clone(),
@ -819,7 +816,7 @@ fn process_borrow(
spl_token_mint_to(TokenMintToParams {
mint: obligation_token_mint_info.clone(),
destination: obligation_token_output_info.clone(),
amount: collateral_deposit_amount,
amount: loan.collateral_amount,
authority: lending_market_authority_info.clone(),
authority_signer_seeds,
token_program: token_program_id.clone(),
@ -1097,66 +1094,33 @@ fn process_liquidate(
assert_last_update_slot(&withdraw_reserve, clock.slot)?;
obligation.accrue_interest(repay_reserve.cumulative_borrow_rate_wads)?;
let mut trade_simulator = TradeSimulator::new(
let trade_simulator = TradeSimulator::new(
dex_market_info,
dex_market_orders_info,
memory,
&lending_market.quote_token_mint,
&withdraw_reserve.liquidity.mint_pubkey,
&repay_reserve.liquidity.mint_pubkey,
)?;
// calculate obligation health
let withdraw_reserve_collateral_exchange_rate = withdraw_reserve.collateral_exchange_rate()?;
let borrow_amount_as_collateral = withdraw_reserve_collateral_exchange_rate
.decimal_liquidity_to_collateral(trade_simulator.simulate_trade(
TradeAction::Sell,
obligation.borrowed_liquidity_wads,
&repay_reserve.liquidity.mint_pubkey,
true,
)?)?
.try_round_u64()?;
if 100 * borrow_amount_as_collateral / obligation.deposited_collateral_tokens
< withdraw_reserve.config.liquidation_threshold as u64
{
return Err(LendingError::HealthyObligation.into());
}
// calculate the amount of liquidity that will be repaid
let close_factor = Rate::from_percent(50);
let decimal_repay_amount = Decimal::from(liquidity_amount)
.min(obligation.borrowed_liquidity_wads.try_mul(close_factor)?);
let integer_repay_amount = decimal_repay_amount.try_round_u64()?;
if integer_repay_amount == 0 {
return Err(LendingError::ObligationTooSmall.into());
}
let LiquidateResult {
bonus_amount,
withdraw_amount,
integer_repay_amount,
decimal_repay_amount,
} = withdraw_reserve.liquidate_obligation(
&obligation,
liquidity_amount,
&repay_reserve.liquidity.mint_pubkey,
trade_simulator,
)?;
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,
decimal_repay_amount,
&repay_reserve.liquidity.mint_pubkey,
false,
)?;
let withdraw_amount_as_collateral = withdraw_reserve_collateral_exchange_rate
.decimal_liquidity_to_collateral(withdraw_liquidity_amount)?
.try_round_u64()?;
let liquidation_bonus_amount =
withdraw_amount_as_collateral * (withdraw_reserve.config.liquidation_bonus as u64) / 100;
let collateral_withdraw_amount = obligation
.deposited_collateral_tokens
.min(withdraw_amount_as_collateral + liquidation_bonus_amount);
Reserve::pack(repay_reserve, &mut repay_reserve_info.data.borrow_mut())?;
obligation.borrowed_liquidity_wads = obligation
.borrowed_liquidity_wads
.try_sub(decimal_repay_amount)?;
obligation.deposited_collateral_tokens -= collateral_withdraw_amount;
obligation.liquidate(decimal_repay_amount, withdraw_amount + bonus_amount)?;
Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?;
let authority_signer_seeds = &[
@ -1183,12 +1147,24 @@ fn process_liquidate(
spl_token_transfer(TokenTransferParams {
source: withdraw_reserve_collateral_supply_info.clone(),
destination: destination_collateral_info.clone(),
amount: collateral_withdraw_amount,
amount: withdraw_amount,
authority: lending_market_authority_info.clone(),
authority_signer_seeds,
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(())
}

View File

@ -32,6 +32,16 @@ pub const UNINITIALIZED_VERSION: u8 = 0;
pub const SLOTS_PER_YEAR: u64 =
DEFAULT_TICKS_PER_SECOND / DEFAULT_TICKS_PER_SLOT * SECONDS_PER_DAY * 365;
/// Token converter
pub trait TokenConverter {
/// Convert between two different tokens
fn convert(
self,
from_amount: Decimal,
from_token_mint: &Pubkey,
) -> Result<Decimal, ProgramError>;
}
// Helpers
fn pack_coption_key(src: &COption<Pubkey>, dst: &mut [u8; 36]) {
let (tag, body) = mut_array_refs![dst, 4, 32];

View File

@ -70,6 +70,19 @@ impl Obligation {
Ok(())
}
/// Liquidate part of obligation
pub fn liquidate(
&mut self,
repay_amount: Decimal,
withdraw_amount: u64,
) -> Result<(), ProgramError> {
self.borrowed_liquidity_wads = self.borrowed_liquidity_wads.try_sub(repay_amount)?;
self.deposited_collateral_tokens
.checked_sub(withdraw_amount)
.ok_or(LendingError::MathOverflow)?;
Ok(())
}
/// Repay borrowed tokens
pub fn repay(
&mut self,

View File

@ -1,6 +1,7 @@
use super::*;
use crate::{
error::LendingError,
instruction::BorrowAmountType,
math::{Decimal, Rate, TryAdd, TryDiv, TryMul, TrySub},
};
use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs};
@ -14,6 +15,9 @@ use solana_program::{
};
use std::convert::{TryFrom, TryInto};
/// Percentage of an obligation that can be repaid during each liquidation call
pub const LIQUIDATION_CLOSE_FACTOR: u8 = 50;
/// Lending market reserve state
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Reserve {
@ -85,6 +89,155 @@ impl Reserve {
}
}
/// Liquidate part of an unhealthy obligation
pub fn liquidate_obligation(
&self,
obligation: &Obligation,
liquidity_amount: u64,
liquidity_token_mint: &Pubkey,
token_converter: impl TokenConverter,
) -> Result<LiquidateResult, ProgramError> {
// 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());
}
// 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() {
return Err(LendingError::HealthyObligation.into());
}
// Don't pay out bonus if withdraw amount covers full collateral balance
let (withdraw_amount, bonus_amount) =
if withdraw_amount >= obligation.deposited_collateral_tokens.into() {
(obligation.deposited_collateral_tokens, 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_collateral = obligation.deposited_collateral_tokens - withdraw_amount;
let bonus_amount = bonus_amount.min(remaining_collateral);
(withdraw_amount, bonus_amount)
};
Ok(LiquidateResult {
bonus_amount,
withdraw_amount,
integer_repay_amount,
decimal_repay_amount,
})
}
/// Create new loan
pub fn create_loan(
&self,
token_amount: u64,
token_amount_type: BorrowAmountType,
token_converter: impl TokenConverter,
borrow_amount_token_mint: &Pubkey,
) -> Result<LoanResult, ProgramError> {
let (borrow_amount, mut collateral_amount) = match token_amount_type {
BorrowAmountType::CollateralDepositAmount => {
let collateral_amount = token_amount;
let borrow_amount =
self.allowed_borrow_for_collateral(collateral_amount, token_converter)?;
if borrow_amount == 0 {
return Err(LendingError::InvalidAmount.into());
}
(borrow_amount, collateral_amount)
}
BorrowAmountType::LiquidityBorrowAmount => {
let borrow_amount = token_amount;
let collateral_amount = self.required_collateral_for_borrow(
borrow_amount,
borrow_amount_token_mint,
token_converter,
)?;
if collateral_amount == 0 {
return Err(LendingError::InvalidAmount.into());
}
(borrow_amount, collateral_amount)
}
};
let (origination_fee, host_fee) =
self.config.fees.calculate_borrow_fees(collateral_amount)?;
collateral_amount = collateral_amount
.checked_sub(origination_fee)
.ok_or(LendingError::MathOverflow)?;
Ok(LoanResult {
borrow_amount,
collateral_amount,
origination_fee,
host_fee,
})
}
/// Calculate allowed borrow for collateral
pub fn allowed_borrow_for_collateral(
&self,
collateral_amount: u64,
converter: impl TokenConverter,
) -> Result<u64, ProgramError> {
let collateral_exchange_rate = self.collateral_exchange_rate()?;
let collateral_amount = Decimal::from(collateral_amount)
.try_mul(Rate::from_percent(self.config.loan_to_value_ratio))?;
let liquidity_amount =
collateral_exchange_rate.decimal_collateral_to_liquidity(collateral_amount)?;
let borrow_amount = converter
.convert(liquidity_amount, &self.liquidity.mint_pubkey)?
.try_floor_u64()?;
Ok(borrow_amount)
}
/// Calculate required collateral for borrow
pub fn required_collateral_for_borrow(
&self,
borrow_amount: u64,
borrow_amount_token_mint: &Pubkey,
converter: impl TokenConverter,
) -> Result<u64, ProgramError> {
let collateral_exchange_rate = self.collateral_exchange_rate()?;
let liquidity_amount =
converter.convert(Decimal::from(borrow_amount), borrow_amount_token_mint)?;
let collateral_amount = collateral_exchange_rate
.decimal_liquidity_to_collateral(liquidity_amount)?
.try_div(Rate::from_percent(self.config.loan_to_value_ratio))?
.try_ceil_u64()?;
Ok(collateral_amount)
}
/// Record deposited liquidity and return amount of collateral tokens to mint
pub fn deposit_liquidity(&mut self, liquidity_amount: u64) -> Result<u64, ProgramError> {
let collateral_exchange_rate = self.collateral_exchange_rate()?;
@ -173,6 +326,31 @@ pub struct NewReserveParams {
pub config: ReserveConfig,
}
/// Create loan result
pub struct LoanResult {
/// Approved borrow amount
pub borrow_amount: u64,
/// Required collateral amount
pub collateral_amount: u64,
/// Loan origination fee
pub origination_fee: u64,
/// Host fee portion of origination fee
pub host_fee: u64,
}
/// 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 that will be repaid as precise decimal
pub decimal_repay_amount: Decimal,
/// Amount that will be repaid as u64
pub integer_repay_amount: u64,
}
/// Reserve liquidity
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ReserveLiquidity {
@ -284,6 +462,7 @@ impl ReserveCollateral {
}
/// Collateral exchange rate
#[derive(Clone, Copy, Debug)]
pub struct CollateralExchangeRate(Rate);
impl CollateralExchangeRate {
@ -544,12 +723,47 @@ impl Pack for Reserve {
#[cfg(test)]
mod test {
use super::*;
use crate::math::WAD;
use crate::math::{PERCENT_SCALER, WAD};
use proptest::prelude::*;
use std::cmp::Ordering;
const MAX_LIQUIDITY: u64 = u64::MAX / 5;
struct MockConverter(Decimal);
impl TokenConverter for MockConverter {
fn convert(
self,
from_amount: Decimal,
_from_token_mint: &Pubkey,
) -> Result<Decimal, ProgramError> {
from_amount.try_mul(self.0)
}
}
/// Loan to value ratio
fn loan_to_value_ratio(
obligation: &Obligation,
borrow_token_price: Decimal,
collateral_exchange_rate: CollateralExchangeRate,
) -> Result<Rate, ProgramError> {
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,
collateral_exchange_rate: CollateralExchangeRate,
conversion_rate: Decimal,
) -> Result<Decimal, ProgramError> {
collateral_exchange_rate.decimal_liquidity_to_collateral(
Decimal::from(liquidity_amount).try_mul(conversion_rate)?,
)
}
// Creates rates (min, opt, max) where 0 <= min <= opt <= max <= MAX
prop_compose! {
fn borrow_rates()(optimal_rate in 0..=u8::MAX)(
@ -562,6 +776,84 @@ mod test {
}
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,
) {
let borrowed_liquidity_wads = Decimal::from(obligation_loan);
let obligation = Obligation {
deposited_collateral_tokens: obligation_collateral,
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(
&obligation,
liquidate_amount,
&Pubkey::default(),
MockConverter(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.integer_repay_amount,
collateral_exchange_rate,
conversion_rate,
)?
);
assert!(
Decimal::from(liquidate_result.integer_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))?
);
assert!(
liquidate_result.withdraw_amount + liquidate_result.bonus_amount <=
obligation.deposited_collateral_tokens
);
}
}
#[test]
fn current_borrow_rate(
total_liquidity in 0..=MAX_LIQUIDITY,
@ -611,6 +903,121 @@ 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,
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 reserve = Reserve {
collateral: ReserveCollateral {
mint_total_supply: collateral_token_supply,
..ReserveCollateral::default()
},
liquidity: ReserveLiquidity {
available_amount: total_liquidity,
..ReserveLiquidity::default()
},
config: ReserveConfig {
loan_to_value_ratio,
..ReserveConfig::default()
},
..Reserve::default()
};
let conversion_rate = Decimal::from_scaled_val(token_conversion_rate);
let borrow_amount = reserve.allowed_borrow_for_collateral(
collateral_amount,
MockConverter(conversion_rate)
)?;
// Allowed borrow should be conservatively low and therefore slightly less or equal
// to the precise equivalent for the given collateral. When it is converted back
// to collateral, the returned value should be equal or lower than the original
// collateral amount.
let collateral_amount_lower = reserve.required_collateral_for_borrow(
borrow_amount,
&Pubkey::default(),
MockConverter(Decimal::one().try_div(conversion_rate)?)
)?;
// After incrementing the allowed borrow, its value should be slightly more or equal
// to the precise equivalent of the original collateral. When it is converted back
// to collateral, the returned value should be equal or higher than the original
// collateral amount since required collateral should be conservatively high.
let collateral_amount_upper = reserve.required_collateral_for_borrow(
borrow_amount + 1,
&Pubkey::default(),
MockConverter(Decimal::one().try_div(conversion_rate)?)
)?;
// Assert that reversing the calculation returns approx original amount
assert!(collateral_amount >= collateral_amount_lower);
assert!(collateral_amount <= collateral_amount_upper);
}
#[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,
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 reserve = Reserve {
collateral: ReserveCollateral {
mint_total_supply: collateral_token_supply,
..ReserveCollateral::default()
},
liquidity: ReserveLiquidity {
available_amount: total_liquidity,
..ReserveLiquidity::default()
},
config: ReserveConfig {
loan_to_value_ratio,
..ReserveConfig::default()
},
..Reserve::default()
};
let conversion_rate = Decimal::from_scaled_val(token_conversion_rate);
let collateral_amount = reserve.required_collateral_for_borrow(
borrow_amount,
&Pubkey::default(),
MockConverter(conversion_rate)
)?;
// Required collateral should be conservatively high and therefore slightly more or equal
// to the precise equivalent for the desired borrow amount. When it is converted back
// to borrow, the returned value should be equal or higher than the original
// borrow amount.
let borrow_amount_upper = reserve.allowed_borrow_for_collateral(
collateral_amount,
MockConverter(Decimal::one().try_div(conversion_rate)?)
)?;
// After decrementing the required collateral, its value should be slightly less or equal
// to the precise equivalent of the original borrow. When it is converted back
// to borrow, the returned value should be equal or lower than the original
// borrow amount since allowed borrow should be conservatively low.
let borrow_amount_lower = reserve.allowed_borrow_for_collateral(
collateral_amount.saturating_sub(1),
MockConverter(Decimal::one().try_div(conversion_rate)?)
)?;
// Assert that reversing the calculation returns approx original amount
assert!(borrow_amount >= borrow_amount_lower);
assert!(borrow_amount <= borrow_amount_upper);
}
#[test]
fn current_utilization_rate(
total_liquidity in 0..=MAX_LIQUIDITY,

View File

@ -33,7 +33,7 @@ pub const TEST_RESERVE_CONFIG: ReserveConfig = ReserveConfig {
optimal_utilization_rate: 80,
loan_to_value_ratio: 50,
liquidation_bonus: 5,
liquidation_threshold: 50,
liquidation_threshold: 55,
min_borrow_rate: 0,
optimal_borrow_rate: 4,
max_borrow_rate: 30,

View File

@ -24,7 +24,7 @@ async fn test_success() {
);
// limit to track compute unit increase
test.set_bpf_compute_max_units(90_000);
test.set_bpf_compute_max_units(101_000);
// set loan values to about 90% of collateral value so that it gets liquidated
const USDC_LOAN: u64 = 2 * FRACTIONAL_TO_USDC;
@ -142,7 +142,7 @@ async fn test_success() {
usdc_liquidated,
usdc_loan_state
.borrowed_liquidity_wads
.try_round_u64()
.try_floor_u64()
.unwrap()
);
@ -155,7 +155,7 @@ async fn test_success() {
sol_liquidated,
sol_loan_state
.borrowed_liquidity_wads
.try_round_u64()
.try_floor_u64()
.unwrap()
);
}