use std::ops::{Deref, DerefMut}; use near_sdk::{env, json_types::U64, near, AccountId}; use crate::{ accumulator::{AccumulationRecord, Accumulator}, asset::{BorrowAsset, BorrowAssetAmount, CollateralAssetAmount}, asset_op, event::MarketEvent, market::Market, number::Decimal, price::PricePair, MS_IN_A_YEAR, }; /// This struct can only be constructed after accumulating interest on a /// borrow position. This serves as proof that the interest has accrued, so it /// is safe to perform certain other operations. #[derive(Clone, Copy)] pub struct InterestAccumulationProof(()); #[cfg(test)] impl InterestAccumulationProof { pub fn test() -> Self { Self(()) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[near(serializers = [borsh, json])] pub enum BorrowStatus { Healthy, Liquidation(LiquidationReason), } impl BorrowStatus { pub fn is_healthy(&self) -> bool { matches!(self, Self::Healthy) } pub fn is_liquidation(&self) -> bool { matches!(self, Self::Liquidation(..)) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[near(serializers = [borsh, json])] pub enum LiquidationReason { Undercollateralization, Expiration, } #[derive(Clone, Debug, PartialEq, Eq)] #[near(serializers = [borsh, json])] pub struct BorrowPosition { pub started_at_block_timestamp_ms: Option, pub collateral_asset_deposit: CollateralAssetAmount, borrow_asset_principal: BorrowAssetAmount, pub borrow_asset_fees: Accumulator, pub temporary_lock: BorrowAssetAmount, pub is_liquidation_locked: bool, } impl BorrowPosition { pub fn new(current_snapshot_index: u32) -> Self { Self { started_at_block_timestamp_ms: None, collateral_asset_deposit: 0.into(), borrow_asset_principal: 0.into(), // Start from current (not next) snapshot to avoid the possibility // of borrowing "for free". e.g. if TimeChunk units are epochs (12 // hours), this prevents someone from getting 11 hours of free // borrowing if they create the borrow 1 hour into the epoch. borrow_asset_fees: Accumulator::new(current_snapshot_index), temporary_lock: 0.into(), is_liquidation_locked: false, } } pub(crate) fn full_liquidation(&mut self, current_snapshot_index: u32) { self.is_liquidation_locked = false; self.started_at_block_timestamp_ms = None; self.collateral_asset_deposit = 0.into(); self.borrow_asset_principal = 0.into(); self.borrow_asset_fees.clear(current_snapshot_index); } pub fn get_borrow_asset_principal(&self) -> BorrowAssetAmount { self.borrow_asset_principal } pub fn get_total_borrow_asset_liability(&self) -> BorrowAssetAmount { let mut total = BorrowAssetAmount::zero(); asset_op! { total += self.borrow_asset_principal; total += self.borrow_asset_fees.get_total(); total += self.temporary_lock; }; total } pub fn can_be_removed(&self) -> bool { !self.is_liquidation_locked && !self.exists() } pub fn exists(&self) -> bool { !self.collateral_asset_deposit.is_zero() || !self.get_total_borrow_asset_liability().is_zero() } pub(crate) fn increase_collateral_asset_deposit( &mut self, amount: CollateralAssetAmount, ) -> Option<()> { self.collateral_asset_deposit.join(amount) } pub(crate) fn decrease_collateral_asset_deposit( &mut self, amount: CollateralAssetAmount, ) -> Option { self.collateral_asset_deposit.split(amount) } /// Interest accumulation MUST be applied before calling this function. pub(crate) fn increase_borrow_asset_principal( &mut self, _proof: InterestAccumulationProof, amount: BorrowAssetAmount, block_timestamp_ms: u64, ) -> Option<()> { if self.started_at_block_timestamp_ms.is_none() || self.get_total_borrow_asset_liability().is_zero() { self.started_at_block_timestamp_ms = Some(block_timestamp_ms.into()); } self.borrow_asset_principal.join(amount) } /// Interest accumulation MUST be applied before calling this function. pub(crate) fn reduce_borrow_asset_liability( &mut self, _proof: InterestAccumulationProof, mut amount: BorrowAssetAmount, minimum_amount: BorrowAssetAmount, ) -> Result { if self.is_liquidation_locked { return Err(error::LiquidationLockError); } // No bounds checks necessary here: the min() call prevents underflow. let amount_to_fees = self.borrow_asset_fees.get_total().min(amount); asset_op! { @msg("Invariant violation: min() precludes underflow") amount -= amount_to_fees; }; self.borrow_asset_fees.remove(amount_to_fees); let amount_to_principal = { let minimum_amount = u128::from(minimum_amount); let amount_remaining = u128::from(self.borrow_asset_principal).saturating_sub(u128::from(amount)); if amount_remaining > 0 && amount_remaining < minimum_amount { u128::from(self.borrow_asset_principal) .saturating_sub(minimum_amount) .into() } else { self.borrow_asset_principal.min(amount) } }; asset_op! { @msg("Invariant violation: amount_to_principal > amount") amount -= amount_to_principal; @msg("Invariant violation: amount_to_principal > borrow_asset_principal") self.borrow_asset_principal -= amount_to_principal; }; if self.borrow_asset_principal.is_zero() { // fully paid off self.started_at_block_timestamp_ms = None; } Ok(LiabilityReduction { amount_to_fees, amount_to_principal, amount_refund: amount, }) } } pub struct LiabilityReduction { pub amount_to_fees: BorrowAssetAmount, pub amount_to_principal: BorrowAssetAmount, pub amount_refund: BorrowAssetAmount, } pub mod error { use thiserror::Error; #[derive(Error, Debug)] #[error("This position is currently being liquidated.")] pub struct LiquidationLockError; } pub struct BorrowPositionRef { market: M, account_id: AccountId, position: BorrowPosition, } impl BorrowPositionRef { pub fn new(market: M, account_id: AccountId, position: BorrowPosition) -> Self { Self { market, account_id, position, } } pub fn account_id(&self) -> &AccountId { &self.account_id } pub fn inner(&self) -> &BorrowPosition { &self.position } } impl> BorrowPositionRef { pub fn estimate_current_snapshot_interest(&self) -> BorrowAssetAmount { let prev_end_timestamp_ms = self.market.get_last_finalized_snapshot().end_timestamp_ms.0; let interest_in_current_snapshot = self.market.current_snapshot.interest_rate() * (env::block_timestamp_ms().saturating_sub(prev_end_timestamp_ms)) * Decimal::from(self.position.get_borrow_asset_principal()) / *MS_IN_A_YEAR; #[allow(clippy::unwrap_used, reason = "Interest rate guaranteed <= APY_LIMIT")] interest_in_current_snapshot.to_u128_ceil().unwrap().into() } pub fn with_pending_interest(&mut self) { let mut pending_estimate = self.calculate_interest(u32::MAX).get_amount(); asset_op!(pending_estimate += self.estimate_current_snapshot_interest()); self.position.borrow_asset_fees.pending_estimate = pending_estimate; } pub(crate) fn calculate_interest( &self, snapshot_limit: u32, ) -> AccumulationRecord { let principal: Decimal = self.position.get_borrow_asset_principal().into(); let mut next_snapshot_index = self.position.borrow_asset_fees.get_next_snapshot_index(); let mut accumulated = Decimal::ZERO; #[allow(clippy::unwrap_used, reason = "1 finalized snapshot guaranteed")] let mut prev_end_timestamp_ms = self .market .finalized_snapshots .get(next_snapshot_index.checked_sub(1).unwrap()) .unwrap() .end_timestamp_ms .0; #[allow( clippy::cast_possible_truncation, reason = "Assume # of snapshots will never be > u32::MAX" )] for (i, snapshot) in self .market .finalized_snapshots .iter() .enumerate() .skip(next_snapshot_index as usize) .take(snapshot_limit as usize) { let duration_ms = Decimal::from( snapshot .end_timestamp_ms .0 .checked_sub(prev_end_timestamp_ms) .unwrap_or_else(|| { env::panic_str(&format!( "Invariant violation: Snapshot timestamp decrease at time chunk #{}.", u64::from(snapshot.time_chunk.0), )) }), ); accumulated += principal * snapshot.interest_rate() * duration_ms / *MS_IN_A_YEAR; prev_end_timestamp_ms = snapshot.end_timestamp_ms.0; next_snapshot_index = i as u32 + 1; } AccumulationRecord { #[allow( clippy::unwrap_used, reason = "Assume accumulated interest will never exceed u128::MAX" )] amount: accumulated.to_u128_floor().unwrap().into(), fraction_as_u128_dividend: accumulated.fractional_part_as_u128_dividend(), next_snapshot_index, } } pub fn is_eligible_for_liquidation( &self, price_pair: &PricePair, block_timestamp_ms: u64, ) -> bool { self.market .configuration .borrow_status(&self.position, price_pair, block_timestamp_ms) .is_liquidation() } pub fn satisfies_mcr_maintenance(&self, price_pair: &PricePair) -> bool { self.market .configuration .satisfies_mcr_maintenance(&self.position, price_pair) } pub fn satisfies_mcr_liquidation(&self, price_pair: &PricePair) -> bool { self.market .configuration .satisfies_mcr_liquidation(&self.position, price_pair) } pub fn minimum_acceptable_liquidation_amount( &self, price_pair: &PricePair, ) -> Option { self.market .configuration .minimum_acceptable_liquidation_amount( self.position.collateral_asset_deposit, price_pair, ) } } pub struct BorrowPositionGuard<'a>(BorrowPositionRef<&'a mut Market>); impl Drop for BorrowPositionGuard<'_> { fn drop(&mut self) { self.0 .market .borrow_positions .insert(&self.0.account_id, &self.0.position); } } impl<'a> Deref for BorrowPositionGuard<'a> { type Target = BorrowPositionRef<&'a mut Market>; fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for BorrowPositionGuard<'_> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl<'a> BorrowPositionGuard<'a> { pub fn new(market: &'a mut Market, account_id: AccountId, position: BorrowPosition) -> Self { Self(BorrowPositionRef::new(market, account_id, position)) } pub fn record_collateral_asset_deposit( &mut self, _proof: InterestAccumulationProof, amount: CollateralAssetAmount, ) { self.position .increase_collateral_asset_deposit(amount) .unwrap_or_else(|| env::panic_str("Borrow position collateral asset overflow")); asset_op!(self.market.collateral_asset_deposited += amount); MarketEvent::CollateralDeposited { account_id: self.account_id.clone(), collateral_asset_amount: amount, } .emit(); } pub fn record_collateral_asset_withdrawal( &mut self, _proof: InterestAccumulationProof, amount: CollateralAssetAmount, ) { self.position .decrease_collateral_asset_deposit(amount) .unwrap_or_else(|| env::panic_str("Borrow position collateral asset underflow")); asset_op!(self.market.collateral_asset_deposited -= amount); MarketEvent::CollateralWithdrawn { account_id: self.account_id.clone(), collateral_asset_amount: amount, } .emit(); } pub fn record_borrow_asset_in_flight_start( &mut self, _proof: InterestAccumulationProof, amount: BorrowAssetAmount, fees: BorrowAssetAmount, ) { asset_op! { self.market.borrow_asset_in_flight += amount; self.position.temporary_lock += amount; self.position.temporary_lock += fees; }; } pub fn record_borrow_asset_in_flight_end( &mut self, _proof: InterestAccumulationProof, amount: BorrowAssetAmount, fees: BorrowAssetAmount, ) { // This should never panic, because a given amount of in-flight borrow // asset should always be added before it is removed. asset_op! { self.market.borrow_asset_in_flight -= amount; self.position.temporary_lock -= amount; self.position.temporary_lock -= fees; }; } pub fn record_borrow_asset_withdrawal( &mut self, proof: InterestAccumulationProof, amount: BorrowAssetAmount, fees: BorrowAssetAmount, ) { self.position.borrow_asset_fees.add_once(fees); self.position .increase_borrow_asset_principal(proof, amount, env::block_timestamp_ms()) .unwrap_or_else(|| env::panic_str("Increase borrow asset principal overflow")); asset_op!(self.market.borrow_asset_borrowed += amount); self.market.snapshot(); MarketEvent::BorrowWithdrawn { account_id: self.account_id.clone(), borrow_asset_amount: amount, } .emit(); } /// Returns the amount that is left over after repaying the whole /// position. That is, the return value is the number of tokens that may /// be returned to the owner of the borrow position. pub fn record_repay( &mut self, proof: InterestAccumulationProof, amount: BorrowAssetAmount, ) -> BorrowAssetAmount { let current_snapshot_interest = self.estimate_current_snapshot_interest(); // Amortize current snapshot fees so that when the current snapshot is // finalized, the fees are not doubled. self.position .borrow_asset_fees .amortize(current_snapshot_interest); let borrow_minimum_amount = self.market.configuration.borrow_range.minimum; let liability_reduction = self .position .reduce_borrow_asset_liability(proof, amount, borrow_minimum_amount) .unwrap_or_else(|e| env::panic_str(&e.to_string())); self.market .record_borrow_asset_yield_distribution(liability_reduction.amount_to_fees); // SAFETY: It should be impossible to panic here, since assets that // have not yet been borrowed cannot be repaid. asset_op!(self.market.borrow_asset_borrowed -= liability_reduction.amount_to_principal); self.market.snapshot(); MarketEvent::BorrowRepaid { account_id: self.account_id.clone(), borrow_asset_fees_repaid: liability_reduction.amount_to_fees, borrow_asset_principal_repaid: liability_reduction.amount_to_principal, borrow_asset_principal_remaining: self.position.get_borrow_asset_principal(), } .emit(); liability_reduction.amount_refund } pub fn accumulate_interest_partial(&mut self, snapshot_limit: u32) { self.market.snapshot(); let accumulation_record = self.calculate_interest(snapshot_limit); if !accumulation_record.amount.is_zero() { MarketEvent::InterestAccumulated { account_id: self.account_id.clone(), borrow_asset_amount: accumulation_record.amount, } .emit(); } self.position .borrow_asset_fees .accumulate(accumulation_record); } pub fn accumulate_interest(&mut self) -> InterestAccumulationProof { self.accumulate_interest_partial(u32::MAX); InterestAccumulationProof(()) } pub fn liquidation_lock(&mut self) { if self.position.is_liquidation_locked { env::panic_str("Position is already liquidation locked"); } self.position.is_liquidation_locked = true; } pub fn liquidation_unlock(&mut self) { if !self.position.is_liquidation_locked { env::panic_str("Position is not liquidation locked"); } self.position.is_liquidation_locked = false; } pub fn record_full_liquidation( &mut self, liquidator_id: AccountId, mut recovered_amount: BorrowAssetAmount, ) { let principal = self.position.get_borrow_asset_principal(); let collateral_asset_liquidated = self.position.collateral_asset_deposit; MarketEvent::FullLiquidation { liquidator_id, account_id: self.account_id.clone(), borrow_asset_principal: principal, borrow_asset_recovered: recovered_amount, collateral_asset_liquidated, } .emit(); let snapshot_index = self.market.snapshot(); self.position.full_liquidation(snapshot_index); asset_op! { @msg("Invariant violation: market borrow_asset_borrowed > position principal") self.market.borrow_asset_borrowed -= principal; }; if recovered_amount.split(principal).is_some() { // Distribute yield. // record_borrow_asset_yield_distribution will take snapshot, no need to do it. self.market .record_borrow_asset_yield_distribution(recovered_amount); } else { // Took a loss on liquidation. // This can be detected from the event (borrow_asset_principal > borrow_asset_recovered?). // Deficit should be covered by protocol insurance. // No need for additional action. } } }