diff --git a/common/src/accumulator.rs b/common/src/accumulator.rs index 491a2986..6ebb3042 100644 --- a/common/src/accumulator.rs +++ b/common/src/accumulator.rs @@ -5,8 +5,11 @@ use crate::asset::{AssetClass, FungibleAssetAmount}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[near(serializers = [borsh, json])] pub struct Accumulator { - pub(crate) total: FungibleAssetAmount, - pub(crate) next_snapshot_index: u32, + total: FungibleAssetAmount, + next_snapshot_index: u32, + #[borsh(skip)] + #[serde(default, skip_serializing_if = "FungibleAssetAmount::is_zero")] + pub pending_estimate: FungibleAssetAmount, } impl Accumulator { @@ -14,6 +17,7 @@ impl Accumulator { Self { total: 0.into(), next_snapshot_index, + pending_estimate: 0.into(), } } @@ -25,6 +29,11 @@ impl Accumulator { self.total } + pub fn clear(&mut self, next_snapshot_index: u32) { + self.total = 0.into(); + self.next_snapshot_index = next_snapshot_index; + } + pub fn remove(&mut self, amount: FungibleAssetAmount) -> Option> { self.total.split(amount) } diff --git a/common/src/asset.rs b/common/src/asset.rs index 5cfafe75..f144bc4c 100644 --- a/common/src/asset.rs +++ b/common/src/asset.rs @@ -102,12 +102,12 @@ mod sealed { } pub trait AssetClass: sealed::Sealed + Copy + Clone {} -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[near(serializers = [borsh, json])] pub struct CollateralAsset; impl sealed::Sealed for CollateralAsset {} impl AssetClass for CollateralAsset {} -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[near(serializers = [borsh, json])] pub struct BorrowAsset; impl sealed::Sealed for BorrowAsset {} diff --git a/common/src/borrow.rs b/common/src/borrow.rs index ac12f4ba..7a8ca349 100644 --- a/common/src/borrow.rs +++ b/common/src/borrow.rs @@ -1,8 +1,17 @@ -use near_sdk::{json_types::U64, near}; +use std::{ + borrow::{Borrow, BorrowMut}, + ops::{Deref, DerefMut}, +}; + +use near_sdk::{env, json_types::U64, near, AccountId}; use crate::{ - accumulator::Accumulator, + accumulator::{AccumulationRecord, Accumulator}, asset::{BorrowAsset, BorrowAssetAmount, CollateralAssetAmount}, + event::MarketEvent, + market::{Market, PricePair}, + number::Decimal, + MS_IN_A_YEAR, }; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -29,7 +38,7 @@ pub enum LiquidationReason { Expiration, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] #[near(serializers = [borsh, json])] pub struct BorrowPosition { pub started_at_block_timestamp_ms: Option, @@ -38,9 +47,6 @@ pub struct BorrowPosition { pub borrow_asset_fees: Accumulator, pub temporary_lock: BorrowAssetAmount, pub liquidation_lock: bool, - #[borsh(skip)] - #[serde(default, skip_serializing_if = "BorrowAssetAmount::is_zero")] - pub pending_fee_estimate: BorrowAssetAmount, } impl BorrowPosition { @@ -56,17 +62,15 @@ impl BorrowPosition { borrow_asset_fees: Accumulator::new(current_snapshot_index), temporary_lock: 0.into(), liquidation_lock: false, - pending_fee_estimate: BorrowAssetAmount::zero(), } } - pub fn full_liquidation(&mut self, current_snapshot_index: u32) { + pub(crate) fn full_liquidation(&mut self, current_snapshot_index: u32) { self.liquidation_lock = false; self.started_at_block_timestamp_ms = None; self.collateral_asset_deposit = 0.into(); self.borrow_asset_principal = 0.into(); - self.borrow_asset_fees.total = 0.into(); - self.borrow_asset_fees.next_snapshot_index = current_snapshot_index; + self.borrow_asset_fees.clear(current_snapshot_index); } pub fn get_borrow_asset_principal(&self) -> BorrowAssetAmount { @@ -76,9 +80,8 @@ impl BorrowPosition { pub fn get_total_borrow_asset_liability(&self) -> BorrowAssetAmount { let mut total = BorrowAssetAmount::zero(); total.join(self.borrow_asset_principal); - total.join(self.borrow_asset_fees.total); + total.join(self.borrow_asset_fees.get_total()); total.join(self.temporary_lock); - total.join(self.pending_fee_estimate); total } @@ -87,21 +90,21 @@ impl BorrowPosition { || !self.get_total_borrow_asset_liability().is_zero() } - pub fn increase_collateral_asset_deposit( + pub(crate) fn increase_collateral_asset_deposit( &mut self, amount: CollateralAssetAmount, ) -> Option<()> { self.collateral_asset_deposit.join(amount) } - pub fn decrease_collateral_asset_deposit( + pub(crate) fn decrease_collateral_asset_deposit( &mut self, amount: CollateralAssetAmount, ) -> Option { self.collateral_asset_deposit.split(amount) } - pub fn increase_borrow_asset_principal( + pub(crate) fn increase_borrow_asset_principal( &mut self, amount: BorrowAssetAmount, block_timestamp_ms: u64, @@ -124,9 +127,9 @@ impl BorrowPosition { // No bounds checks necessary here: the min() call prevents underflow. - let amount_to_fees = self.borrow_asset_fees.total.min(amount); + let amount_to_fees = self.borrow_asset_fees.get_total().min(amount); amount.split(amount_to_fees); - self.borrow_asset_fees.total.split(amount_to_fees); + self.borrow_asset_fees.remove(amount_to_fees); let amount_to_principal = self.borrow_asset_principal.min(amount); amount.split(amount_to_principal); @@ -158,3 +161,374 @@ pub mod error { #[error("This position is currently being liquidated.")] pub struct LiquidationLockError; } + +pub struct LinkedBorrowPosition { + market: M, + account_id: AccountId, + position: BorrowPosition, +} + +impl LinkedBorrowPosition { + 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> LinkedBorrowPosition { + pub fn with_pending_interest(&mut self) { + self.position.borrow_asset_fees.pending_estimate = + self.calculate_interest(u32::MAX).get_amount(); + self.position + .borrow_asset_fees + .pending_estimate + .join(self.calculate_last_snapshot_interest()); + } + + pub(crate) fn calculate_last_snapshot_interest(&self) -> BorrowAssetAmount { + let market = self.market.borrow(); + let last_snapshot = market.get_last_snapshot(); + let interest_rate = market.get_interest_rate_for_snapshot(last_snapshot); + let duration_ms = Decimal::from(env::block_timestamp_ms() - last_snapshot.timestamp_ms.0); + let ms_in_a_year = Decimal::from(MS_IN_A_YEAR); + let interest_rate_part = interest_rate * duration_ms / ms_in_a_year; + let interest = interest_rate_part + * Decimal::from(self.position.get_borrow_asset_principal().as_u128()); + + interest.to_u128_ceil().unwrap().into() + } + + pub(crate) fn calculate_interest(&self, limit: u32) -> AccumulationRecord { + let principal = Decimal::from(self.position.get_borrow_asset_principal().as_u128()); + let mut next_snapshot_index = self.position.borrow_asset_fees.get_next_snapshot_index(); + + let mut accumulated = Decimal::ZERO; + + // Assume # of snapshots will never be > u32::MAX. + #[allow(clippy::cast_possible_truncation)] + let mut it = self + .market + .borrow() + .snapshots + .iter() + .enumerate() + .skip(next_snapshot_index as usize) + .take(limit as usize) + .map(|(i, s)| (i as u32, s)) + .peekable(); + + let ms_in_a_year = Decimal::from(MS_IN_A_YEAR); + + while let Some((i, snapshot)) = it.next() { + let Some(end_timestamp_ms) = it + .peek() + .map(|(_, next_snapshot)| next_snapshot.timestamp_ms.0) + else { + // Cannot calculate duration. + break; + }; + + let total_borrowed = Decimal::from(snapshot.borrowed.as_u128()); + let total_deposited = Decimal::from(snapshot.deposited.as_u128()); + let utilization_ratio = total_borrowed / total_deposited; + let interest_rate_per_year = self + .market + .borrow() + .configuration + .borrow_interest_rate_strategy + .at(utilization_ratio); + let duration_ms: Decimal = end_timestamp_ms + .checked_sub(snapshot.timestamp_ms.0) + .unwrap_or_else(|| { + env::panic_str(&format!( + "Invariant violation: Snapshot timestamp decrease at #{}.", + i + 1, + )) + }) + .into(); + + let interest = principal * interest_rate_per_year * duration_ms / ms_in_a_year; + + accumulated += interest; + + next_snapshot_index = i + 1; + } + + AccumulationRecord { + amount: accumulated.to_u128_ceil().unwrap().into(), + next_snapshot_index, + } + } + + pub fn can_be_liquidated(&self, price_pair: &PricePair, block_timestamp_ms: u64) -> bool { + self.market + .borrow() + .configuration + .borrow_status(&self.position, price_pair, block_timestamp_ms) + .is_liquidation() + } + + pub fn is_within_minimum_initial_collateral_ratio(&self, price_pair: &PricePair) -> bool { + self.market + .borrow() + .configuration + .is_within_minimum_initial_collateral_ratio(&self.position, price_pair) + } + + pub fn is_within_minimum_collateral_ratio(&self, price_pair: &PricePair) -> bool { + self.market + .borrow() + .configuration + .is_within_minimum_collateral_ratio(&self.position, price_pair) + } + + pub fn minimum_acceptable_liquidation_amount( + &self, + price_pair: &PricePair, + ) -> BorrowAssetAmount { + self.market + .borrow() + .configuration + .minimum_acceptable_liquidation_amount( + self.position.collateral_asset_deposit, + price_pair, + ) + } +} + +pub struct LinkedBorrowPositionMut>(LinkedBorrowPosition); + +impl> Drop for LinkedBorrowPositionMut { + fn drop(&mut self) { + self.0 + .market + .borrow_mut() + .borrow_positions + .insert(&self.0.account_id, &self.0.position); + } +} + +impl> Deref for LinkedBorrowPositionMut { + type Target = LinkedBorrowPosition; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl> DerefMut for LinkedBorrowPositionMut { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl> LinkedBorrowPositionMut { + pub fn new(market: M, account_id: AccountId, position: BorrowPosition) -> Self { + Self(LinkedBorrowPosition::new(market, account_id, position)) + } + + pub fn record_collateral_asset_deposit(&mut self, amount: CollateralAssetAmount) { + self.accumulate_interest(); + + self.position + .increase_collateral_asset_deposit(amount) + .unwrap_or_else(|| env::panic_str("Borrow position collateral asset overflow")); + + MarketEvent::CollateralDeposited { + account_id: self.account_id.clone(), + collateral_asset_amount: amount, + } + .emit(); + } + + pub fn record_collateral_asset_withdrawal(&mut self, amount: CollateralAssetAmount) { + self.accumulate_interest(); + + self.position + .decrease_collateral_asset_deposit(amount) + .unwrap_or_else(|| env::panic_str("Borrow position collateral asset underflow")); + + MarketEvent::CollateralWithdrawn { + account_id: self.account_id.clone(), + collateral_asset_amount: amount, + } + .emit(); + } + + pub fn record_borrow_asset_in_flight_start( + &mut self, + amount: BorrowAssetAmount, + fees: BorrowAssetAmount, + ) { + self.accumulate_interest(); + + self.market + .borrow_mut() + .borrow_asset_in_flight + .join(amount) + .unwrap_or_else(|| env::panic_str("Borrow asset in flight amount overflow")); + self.position + .temporary_lock + .join(amount) + .and_then(|()| self.position.temporary_lock.join(fees)) + .unwrap_or_else(|| env::panic_str("Borrow position in flight amount overflow")); + } + + pub fn record_borrow_asset_in_flight_end( + &mut self, + amount: BorrowAssetAmount, + fees: BorrowAssetAmount, + ) { + self.accumulate_interest(); + + // This should never panic, because a given amount of in-flight borrow + // asset should always be added before it is removed. + self.market + .borrow_mut() + .borrow_asset_in_flight + .split(amount) + .unwrap_or_else(|| env::panic_str("Borrow asset in flight amount underflow")); + self.position + .temporary_lock + .split(amount) + .and_then(|_| self.position.temporary_lock.split(fees)) + .unwrap_or_else(|| env::panic_str("Borrow position in flight amount underflow")); + } + + pub fn record_borrow_asset_withdrawal( + &mut self, + amount: BorrowAssetAmount, + fees: BorrowAssetAmount, + ) { + self.accumulate_interest(); + + self.position.borrow_asset_fees.add_once(fees); + self.position + .increase_borrow_asset_principal(amount, env::block_timestamp_ms()) + .unwrap_or_else(|| env::panic_str("Increase borrow asset principal overflow")); + + self.market + .borrow_mut() + .borrow_asset_borrowed + .join(amount) + .unwrap_or_else(|| env::panic_str("Borrow asset borrowed overflow")); + self.market.borrow_mut().snapshot(); + + MarketEvent::BorrowWithdrawn { + account_id: self.account_id.clone(), + borrow_asset_amount: amount, + } + .emit(); + } + + pub fn record_repay(&mut self, amount: BorrowAssetAmount) -> BorrowAssetAmount { + self.accumulate_interest(); + + let liability_reduction = self + .position + .reduce_borrow_asset_liability(amount) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + + self.market + .borrow_mut() + .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. + self.market + .borrow_mut() + .borrow_asset_borrowed + .split(liability_reduction.amount_to_principal) + .unwrap_or_else(|| env::panic_str("Borrow asset borrowed underflow")); + + self.market.borrow_mut().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_remaining + } + + pub fn accumulate_interest(&mut self) { + self.market.borrow_mut().snapshot(); + + let accumulation_record = self.calculate_interest(u32::MAX); + + 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 liquidation_lock(&mut self) { + self.position.liquidation_lock = true; + } + + pub fn liquidation_unlock(&mut self) { + self.position.liquidation_lock = 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.borrow_mut().snapshot(); + self.position.full_liquidation(snapshot_index); + + self.market + .borrow_mut() + .borrow_asset_borrowed + .split(principal); + + // TODO: Is it correct to only care about the original principal here? + if recovered_amount.split(principal).is_some() { + // distribute yield + // record_borrow_asset_yield_distribution will take snapshot, no need to do it. + self.market + .borrow_mut() + .record_borrow_asset_yield_distribution(recovered_amount); + } else { + // we took a loss + // TODO: some sort of recovery for suppliers + // + // Might look something like this: + // self.borrow_asset_deposited.split(principal); + // (?) + todo!("Took a loss during liquidation"); + } + } +} diff --git a/common/src/event.rs b/common/src/event.rs new file mode 100644 index 00000000..0941fa06 --- /dev/null +++ b/common/src/event.rs @@ -0,0 +1,70 @@ +use near_sdk::{near, AccountId}; + +use crate::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + snapshot::Snapshot, +}; + +#[near(event_json(standard = "templar-market"))] +pub enum MarketEvent { + #[event_version("1.0.0")] + SnapshotFinalized { + index: u32, + #[serde(flatten)] + snapshot: Snapshot, + }, + #[event_version("1.0.0")] + GlobalYieldDistributed { + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + YieldAccumulated { + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + InterestAccumulated { + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + SupplyDeposited { + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + SupplyWithdrawn { + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + CollateralDeposited { + account_id: AccountId, + collateral_asset_amount: CollateralAssetAmount, + }, + #[event_version("1.0.0")] + CollateralWithdrawn { + account_id: AccountId, + collateral_asset_amount: CollateralAssetAmount, + }, + #[event_version("1.0.0")] + BorrowWithdrawn { + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + BorrowRepaid { + account_id: AccountId, + borrow_asset_fees_repaid: BorrowAssetAmount, + borrow_asset_principal_repaid: BorrowAssetAmount, + borrow_asset_principal_remaining: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + FullLiquidation { + liquidator_id: AccountId, + account_id: AccountId, + borrow_asset_principal: BorrowAssetAmount, + borrow_asset_recovered: BorrowAssetAmount, + collateral_asset_liquidated: CollateralAssetAmount, + }, +} diff --git a/common/src/lib.rs b/common/src/lib.rs index f1eafb00..ad1d53ab 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -2,6 +2,7 @@ pub mod accumulator; pub mod asset; pub mod borrow; pub mod chain_time; +pub mod event; pub mod fee; pub mod interest_rate_strategy; pub mod market; @@ -12,3 +13,5 @@ pub mod static_yield; pub mod supply; pub mod util; pub mod withdrawal_queue; + +pub const MS_IN_A_YEAR: u128 = 31_556_952_000; // 1000 * 60 * 60 * 24 * 365.2425 diff --git a/common/src/market/balance_oracle_configuration.rs b/common/src/market/balance_oracle_configuration.rs index 61f8a593..ad691d88 100644 --- a/common/src/market/balance_oracle_configuration.rs +++ b/common/src/market/balance_oracle_configuration.rs @@ -78,6 +78,7 @@ mod error { ConfidenceIntervalTooLarge, } } + impl TryFrom for Price { type Error = error::PriceDataError; diff --git a/common/src/market/impl.rs b/common/src/market/impl.rs index 1fe6bb09..6e454c21 100644 --- a/common/src/market/impl.rs +++ b/common/src/market/impl.rs @@ -6,22 +6,18 @@ use near_sdk::{ }; use crate::{ - accumulator::AccumulationRecord, - asset::{BorrowAsset, BorrowAssetAmount, CollateralAssetAmount}, - borrow::BorrowPosition, + asset::BorrowAssetAmount, + borrow::{BorrowPosition, LinkedBorrowPosition, LinkedBorrowPositionMut}, chain_time::ChainTime, + event::MarketEvent, market::MarketConfiguration, number::Decimal, snapshot::Snapshot, static_yield::StaticYieldRecord, - supply::SupplyPosition, + supply::{LinkedSupplyPosition, LinkedSupplyPositionMut, SupplyPosition}, withdrawal_queue::{error::WithdrawalQueueLockError, WithdrawalQueue}, }; -use super::PricePair; - -pub const MS_IN_A_YEAR: u128 = 31_556_952_000; // 1000 * 60 * 60 * 24 * 365.2425 - #[derive(BorshStorageKey)] #[near] enum StorageKey { @@ -39,8 +35,8 @@ pub struct Market { pub borrow_asset_deposited: BorrowAssetAmount, pub borrow_asset_in_flight: BorrowAssetAmount, pub borrow_asset_borrowed: BorrowAssetAmount, - pub supply_positions: UnorderedMap, - pub borrow_positions: UnorderedMap, + pub(crate) supply_positions: UnorderedMap, + pub(crate) borrow_positions: UnorderedMap, pub snapshots: Vector, pub withdrawal_queue: WithdrawalQueue, pub static_yield: LookupMap, @@ -126,6 +122,15 @@ impl Market { yield_distribution, }; self.snapshots.push(new_snapshot); + if let Some(previous_snapshot_index) = index.checked_sub(1) { + if let Some(previous_snapshot) = self.snapshots.get(previous_snapshot_index) { + MarketEvent::SnapshotFinalized { + index: previous_snapshot_index, + snapshot: previous_snapshot.clone(), + } + .emit(); + } + } index } } @@ -162,6 +167,74 @@ impl Market { .at(usage_ratio) } + pub fn iter_supply_account_ids(&self) -> impl Iterator + '_ { + self.supply_positions.keys() + } + + pub fn get_linked_supply_position( + &self, + account_id: AccountId, + ) -> Option> { + self.supply_positions + .get(&account_id) + .map(|position| LinkedSupplyPosition::new(self, account_id, position)) + } + + pub fn get_linked_supply_position_mut( + &mut self, + account_id: AccountId, + ) -> Option> { + self.supply_positions + .get(&account_id) + .map(|position| LinkedSupplyPositionMut::new(self, account_id, position)) + } + + pub fn get_or_create_linked_supply_position_mut( + &mut self, + account_id: AccountId, + ) -> LinkedSupplyPositionMut<&mut Self> { + let position = self + .supply_positions + .get(&account_id) + .unwrap_or_else(|| SupplyPosition::new(self.snapshot())); + + LinkedSupplyPositionMut::new(self, account_id, position) + } + + pub fn iter_borrow_account_ids(&self) -> impl Iterator + '_ { + self.borrow_positions.keys() + } + + pub fn get_linked_borrow_position( + &self, + account_id: AccountId, + ) -> Option> { + self.borrow_positions + .get(&account_id) + .map(|position| LinkedBorrowPosition::new(self, account_id, position)) + } + + pub fn get_linked_borrow_position_mut( + &mut self, + account_id: AccountId, + ) -> Option> { + self.borrow_positions + .get(&account_id) + .map(|position| LinkedBorrowPositionMut::new(self, account_id, position)) + } + + pub fn get_or_create_linked_borrow_position_mut( + &mut self, + account_id: AccountId, + ) -> LinkedBorrowPositionMut<&mut Self> { + let position = self + .borrow_positions + .get(&account_id) + .unwrap_or_else(|| BorrowPosition::new(self.snapshot())); + + LinkedBorrowPositionMut::new(self, account_id, position) + } + /// # Errors /// - If the withdrawal queue is already locked. /// - If the withdrawal queue is empty. @@ -170,21 +243,21 @@ impl Market { ) -> Result, WithdrawalQueueLockError> { let (account_id, requested_amount) = self.withdrawal_queue.try_lock()?; - let Some((amount, mut supply_position)) = - self.supply_positions - .get(&account_id) - .and_then(|supply_position| { - // Cap withdrawal amount to deposit amount at most. - let amount = supply_position - .get_borrow_asset_deposit() - .min(requested_amount); - - if amount.is_zero() { - None - } else { - Some((amount, supply_position)) - } - }) + let Some((amount, mut supply_position)) = self + .get_linked_supply_position_mut(account_id.clone()) + .and_then(|supply_position| { + // Cap withdrawal amount to deposit amount at most. + let amount = supply_position + .inner() + .get_borrow_asset_deposit() + .min(requested_amount); + + if amount.is_zero() { + None + } else { + Some((amount, supply_position)) + } + }) else { // The amount that the entry is eligible to withdraw is zero, so skip it. self.withdrawal_queue @@ -193,19 +266,22 @@ impl Market { return Ok(None); }; - self.record_supply_position_borrow_asset_withdrawal(&mut supply_position, amount); - - self.supply_positions.insert(&account_id, &supply_position); + supply_position.record_withdrawal(amount); Ok(Some((account_id, amount))) } - fn record_borrow_asset_yield_distribution(&mut self, mut amount: BorrowAssetAmount) { + pub(crate) fn record_borrow_asset_yield_distribution(&mut self, mut amount: BorrowAssetAmount) { // Sanity. if amount.is_zero() { return; } + MarketEvent::GlobalYieldDistributed { + borrow_asset_amount: amount, + } + .emit(); + // First, static yield. let total_weight = u128::from(u16::from(self.configuration.yield_weights.total_weight())); @@ -251,415 +327,4 @@ impl Market { // Next, dynamic (supply-based) yield. self.snapshot_with_yield_distribution(amount); } - - pub fn record_supply_position_borrow_asset_deposit( - &mut self, - supply_position: &mut SupplyPosition, - amount: BorrowAssetAmount, - ) { - self.accumulate_supply_position_yield(supply_position); - supply_position - .increase_borrow_asset_deposit(amount) - .unwrap_or_else(|| env::panic_str("Supply position borrow asset overflow")); - - self.borrow_asset_deposited - .join(amount) - .unwrap_or_else(|| env::panic_str("Borrow asset deposited overflow")); - - self.snapshot(); - } - - pub fn record_supply_position_borrow_asset_withdrawal( - &mut self, - supply_position: &mut SupplyPosition, - amount: BorrowAssetAmount, - ) -> BorrowAssetAmount { - self.accumulate_supply_position_yield(supply_position); - let withdrawn = supply_position - .decrease_borrow_asset_deposit(amount) - .unwrap_or_else(|| env::panic_str("Supply position borrow asset underflow")); - - self.borrow_asset_deposited - .split(amount) - .unwrap_or_else(|| env::panic_str("Borrow asset deposited underflow")); - - self.snapshot(); - - withdrawn - } - - pub fn record_borrow_position_collateral_asset_deposit( - &mut self, - borrow_position: &mut BorrowPosition, - amount: CollateralAssetAmount, - ) { - self.accumulate_borrow_position_interest(borrow_position); - borrow_position - .increase_collateral_asset_deposit(amount) - .unwrap_or_else(|| env::panic_str("Borrow position collateral asset overflow")); - } - - pub fn record_borrow_position_collateral_asset_withdrawal( - &mut self, - borrow_position: &mut BorrowPosition, - amount: CollateralAssetAmount, - ) { - self.accumulate_borrow_position_interest(borrow_position); - borrow_position - .decrease_collateral_asset_deposit(amount) - .unwrap_or_else(|| env::panic_str("Borrow position collateral asset underflow")); - } - - pub fn record_borrow_position_borrow_asset_in_flight_start( - &mut self, - borrow_position: &mut BorrowPosition, - amount: BorrowAssetAmount, - fees: BorrowAssetAmount, - ) { - self.accumulate_borrow_position_interest(borrow_position); - - self.borrow_asset_in_flight - .join(amount) - .unwrap_or_else(|| env::panic_str("Borrow asset in flight amount overflow")); - borrow_position - .temporary_lock - .join(amount) - .and_then(|()| borrow_position.temporary_lock.join(fees)) - .unwrap_or_else(|| env::panic_str("Borrow position in flight amount overflow")); - } - - pub fn record_borrow_position_borrow_asset_in_flight_end( - &mut self, - borrow_position: &mut BorrowPosition, - amount: BorrowAssetAmount, - fees: BorrowAssetAmount, - ) { - self.accumulate_borrow_position_interest(borrow_position); - - // This should never panic, because a given amount of in-flight borrow - // asset should always be added before it is removed. - self.borrow_asset_in_flight - .split(amount) - .unwrap_or_else(|| env::panic_str("Borrow asset in flight amount underflow")); - borrow_position - .temporary_lock - .split(amount) - .and_then(|_| borrow_position.temporary_lock.split(fees)) - .unwrap_or_else(|| env::panic_str("Borrow position in flight amount underflow")); - } - - pub fn record_borrow_position_borrow_asset_withdrawal( - &mut self, - borrow_position: &mut BorrowPosition, - amount: BorrowAssetAmount, - fee: BorrowAssetAmount, - ) { - self.accumulate_borrow_position_interest(borrow_position); - - borrow_position.borrow_asset_fees.add_once(fee); - borrow_position - .increase_borrow_asset_principal(amount, env::block_timestamp_ms()) - .unwrap_or_else(|| env::panic_str("Increase borrow asset principal overflow")); - - self.borrow_asset_borrowed - .join(amount) - .unwrap_or_else(|| env::panic_str("Borrow asset borrowed overflow")); - self.snapshot(); - } - - pub fn record_borrow_position_borrow_asset_repay( - &mut self, - borrow_position: &mut BorrowPosition, - amount: BorrowAssetAmount, - ) -> BorrowAssetAmount { - self.accumulate_borrow_position_interest(borrow_position); - - let liability_reduction = borrow_position - .reduce_borrow_asset_liability(amount) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); - - self.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. - self.borrow_asset_borrowed - .split(liability_reduction.amount_to_principal) - .unwrap_or_else(|| env::panic_str("Borrow asset borrowed underflow")); - - self.snapshot(); - - liability_reduction.amount_remaining - } - - pub fn accumulate_borrow_position_interest(&mut self, borrow_position: &mut BorrowPosition) { - self.snapshot(); - - let accumulation_record = self.calculate_borrow_position_interest( - borrow_position.get_borrow_asset_principal(), - borrow_position.borrow_asset_fees.next_snapshot_index, - u32::MAX, - ); - - borrow_position - .borrow_asset_fees - .accumulate(accumulation_record); - } - - #[must_use] - pub fn calculate_borrow_position_instantaneous_pending_interest( - &self, - borrow_position: &BorrowPosition, - ) -> BorrowAssetAmount { - let mut amount = self - .calculate_borrow_position_interest( - borrow_position.get_borrow_asset_principal(), - borrow_position.borrow_asset_fees.get_next_snapshot_index(), - u32::MAX, - ) - .get_amount(); - - // Add the amount representing the "in-progress" snapshot. - let last_snapshot_part = - self.calculate_borrow_position_last_snapshot_interest(borrow_position); - - amount.join(last_snapshot_part); - - amount - } - - pub(crate) fn calculate_borrow_position_last_snapshot_interest( - &self, - borrow_position: &BorrowPosition, - ) -> BorrowAssetAmount { - let last_snapshot = self.get_last_snapshot(); - let interest_rate = self.get_interest_rate_for_snapshot(last_snapshot); - let duration_ms = Decimal::from(env::block_timestamp_ms() - last_snapshot.timestamp_ms.0); - let ms_in_a_year = Decimal::from(MS_IN_A_YEAR); - let interest_rate_part = interest_rate * duration_ms / ms_in_a_year; - let interest = interest_rate_part - * Decimal::from(borrow_position.get_borrow_asset_principal().as_u128()); - - interest.to_u128_ceil().unwrap().into() - } - - pub(crate) fn calculate_borrow_position_interest( - &self, - principal_in_span: BorrowAssetAmount, - mut next_snapshot_index: u32, - limit: u32, - ) -> AccumulationRecord { - let principal = Decimal::from(principal_in_span.as_u128()); - - let mut accumulated = Decimal::ZERO; - - // Assume # of snapshots will never be > u32::MAX. - #[allow(clippy::cast_possible_truncation)] - let mut it = self - .snapshots - .iter() - .enumerate() - .skip(next_snapshot_index as usize) - .take(limit as usize) - .map(|(i, s)| (i as u32, s)) - .peekable(); - - let ms_in_a_year = Decimal::from(MS_IN_A_YEAR); - - while let Some((i, snapshot)) = it.next() { - let Some(end_timestamp_ms) = it - .peek() - .map(|(_, next_snapshot)| next_snapshot.timestamp_ms.0) - else { - // Cannot calculate duration. - break; - }; - - let total_borrowed = Decimal::from(snapshot.borrowed.as_u128()); - let total_deposited = Decimal::from(snapshot.deposited.as_u128()); - let utilization_ratio = total_borrowed / total_deposited; - let interest_rate_per_year = self - .configuration - .borrow_interest_rate_strategy - .at(utilization_ratio); - let duration_ms: Decimal = end_timestamp_ms - .checked_sub(snapshot.timestamp_ms.0) - .unwrap_or_else(|| { - env::panic_str(&format!( - "Invariant violation: Snapshot timestamp decrease at #{}.", - i + 1, - )) - }) - .into(); - - let interest = principal * interest_rate_per_year * duration_ms / ms_in_a_year; - - accumulated += interest; - - next_snapshot_index = i + 1; - } - - AccumulationRecord { - amount: accumulated.to_u128_ceil().unwrap().into(), - next_snapshot_index, - } - } - - /// In order for yield calculations to be accurate, this function MUST - /// BE CALLED every time a supply position's deposit changes. This - /// requirement is largely met by virtue of the fact that - /// `SupplyPosition->borrow_asset_deposit` is a private field and can only - /// be modified via `Self::record_supply_position_*` methods. - pub fn accumulate_supply_position_yield(&mut self, supply_position: &mut SupplyPosition) { - self.snapshot(); - - let accumulation_record = self.calculate_supply_position_yield( - supply_position.get_borrow_asset_deposit(), - supply_position.borrow_asset_yield.next_snapshot_index, - ); - - supply_position - .borrow_asset_yield - .accumulate(accumulation_record); - } - - /// This function must only be used to estimate interest for the purpose of account monitoring. - #[must_use] - pub fn calculate_supply_position_instantaneous_pending_yield( - &self, - supply_position: &SupplyPosition, - ) -> BorrowAssetAmount { - let mut amount = self - .calculate_supply_position_yield( - supply_position.get_borrow_asset_deposit(), - supply_position.borrow_asset_yield.next_snapshot_index, - ) - .get_amount(); - - // Calculate the amount representing the "in-progress" snapshot. - let current_snapshot_part = - self.calculate_supply_position_last_snapshot_yield(supply_position); - - amount.join(current_snapshot_part); - - amount - } - - #[allow(clippy::missing_panics_doc)] - pub fn calculate_supply_position_last_snapshot_yield( - &self, - supply_position: &SupplyPosition, - ) -> BorrowAssetAmount { - let deposit = Decimal::from(supply_position.get_borrow_asset_deposit().as_u128()); - if deposit.is_zero() { - return BorrowAssetAmount::zero(); - } - - let last_snapshot = self.get_last_snapshot(); - let total_deposited = Decimal::from(last_snapshot.deposited.as_u128()); - if total_deposited.is_zero() { - // divzero safety - return BorrowAssetAmount::zero(); - } - let supply_weight = Decimal::from(self.configuration.yield_weights.supply.get()); - // This is guaranteed to be nonzero, so no divzero issue. - let total_weight = Decimal::from(self.configuration.yield_weights.total_weight().get()); - let total_yield_distribution = Decimal::from(last_snapshot.yield_distribution.as_u128()); - let estimate_current_snapshot = - total_yield_distribution * deposit * supply_weight / total_deposited / total_weight; - - // We know that total_yield_distribution fits in u128. - // Also: supply_weight <= total_weight, deposit <= total_deposited. - // Therefore, estimate_current_snapshot cannot exceed u128. - #[allow(clippy::unwrap_used)] - estimate_current_snapshot.to_u128_floor().unwrap().into() - } - - #[allow(clippy::missing_panics_doc)] - pub fn calculate_supply_position_yield( - &self, - amount_deposited_during_interval: BorrowAssetAmount, - mut next_snapshot_index: u32, - ) -> AccumulationRecord { - if self.snapshots.is_empty() { - return AccumulationRecord::empty(next_snapshot_index); - } - - let amount = Decimal::from(amount_deposited_during_interval.as_u128()); - - let mut accumulated = Decimal::ZERO; - - let mut it = self.snapshots.iter(); - // Skip the last snapshot, which may be incomplete. - it.next_back(); - - for (i, snapshot) in it.enumerate().skip(next_snapshot_index as usize).map( - // Assume # of snapshots is never >u32::MAX. - #[allow(clippy::cast_possible_truncation)] - |(i, s)| (i as u32, s), - ) { - let deposited = Decimal::from(snapshot.deposited.as_u128()); - let distributed = Decimal::from(snapshot.yield_distribution.as_u128()); - let share = amount * distributed / deposited; - accumulated += share; - - next_snapshot_index = i + 1; - } - - AccumulationRecord { - amount: accumulated.to_u128_floor().unwrap().into(), - next_snapshot_index, - } - } - - pub fn can_borrow_position_be_liquidated( - &self, - account_id: &AccountId, - oracle_price_proof: &PricePair, - ) -> bool { - let Some(borrow_position) = self.borrow_positions.get(account_id) else { - return false; - }; - - self.configuration - .borrow_status( - &borrow_position, - oracle_price_proof, - env::block_timestamp_ms(), - ) - .is_liquidation() - } - - pub fn record_liquidation_lock(&mut self, borrow_position: &mut BorrowPosition) { - borrow_position.liquidation_lock = true; - } - - pub fn record_liquidation_unlock(&mut self, borrow_position: &mut BorrowPosition) { - borrow_position.liquidation_lock = false; - } - - pub fn record_full_liquidation( - &mut self, - borrow_position: &mut BorrowPosition, - mut recovered_amount: BorrowAssetAmount, - ) { - let principal = borrow_position.get_borrow_asset_principal(); - borrow_position.full_liquidation(self.snapshot()); - - self.borrow_asset_borrowed.split(principal); - - // TODO: Is it correct to only care about the original principal here? - if recovered_amount.split(principal).is_some() { - // distribute yield - // record_borrow_asset_yield_distribution will take snapshot, no need to do it. - self.record_borrow_asset_yield_distribution(recovered_amount); - } else { - // we took a loss - // TODO: some sort of recovery for suppliers - // - // Might look something like this: - // self.borrow_asset_deposited.split(principal); - // (?) - todo!("Took a loss during liquidation"); - } - } } diff --git a/common/src/supply.rs b/common/src/supply.rs index eb124bf8..68e2ec36 100644 --- a/common/src/supply.rs +++ b/common/src/supply.rs @@ -1,18 +1,23 @@ -use near_sdk::near; +use std::{ + borrow::{Borrow, BorrowMut}, + ops::{Deref, DerefMut}, +}; + +use near_sdk::{env, near, AccountId}; use crate::{ - accumulator::Accumulator, + accumulator::{AccumulationRecord, Accumulator}, asset::{BorrowAsset, BorrowAssetAmount}, + event::MarketEvent, + market::Market, + number::Decimal, }; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] #[near(serializers = [json, borsh])] pub struct SupplyPosition { borrow_asset_deposit: BorrowAssetAmount, pub borrow_asset_yield: Accumulator, - #[borsh(skip)] - #[serde(default, skip_serializing_if = "BorrowAssetAmount::is_zero")] - pub pending_yield_estimate: BorrowAssetAmount, } impl SupplyPosition { @@ -23,7 +28,6 @@ impl SupplyPosition { // accumulating yield from the _next_ log (since they were not // necessarily supplying for all of the current log). borrow_asset_yield: Accumulator::new(current_snapshot_index + 1), - pending_yield_estimate: BorrowAssetAmount::zero(), } } @@ -32,7 +36,7 @@ impl SupplyPosition { } pub fn exists(&self) -> bool { - !self.borrow_asset_deposit.is_zero() || !self.borrow_asset_yield.total.is_zero() + !self.borrow_asset_deposit.is_zero() || !self.borrow_asset_yield.get_total().is_zero() } /// MUST always be paired with a yield recalculation! @@ -51,3 +55,249 @@ impl SupplyPosition { self.borrow_asset_deposit.split(amount) } } + +pub struct LinkedSupplyPosition { + market: M, + account_id: AccountId, + position: SupplyPosition, +} + +impl LinkedSupplyPosition { + pub fn new(market: M, account_id: AccountId, position: SupplyPosition) -> Self { + Self { + market, + account_id, + position, + } + } + + pub fn account_id(&self) -> &AccountId { + &self.account_id + } + + pub fn inner(&self) -> &SupplyPosition { + &self.position + } +} + +impl> LinkedSupplyPosition { + pub fn with_pending_yield_estimate(&mut self) { + self.position.borrow_asset_yield.pending_estimate = self.calculate_yield().get_amount(); + self.position + .borrow_asset_yield + .pending_estimate + .join(self.calculate_last_snapshot_yield()); + } + + pub fn calculate_last_snapshot_yield(&self) -> BorrowAssetAmount { + let deposit = Decimal::from(self.position.get_borrow_asset_deposit().as_u128()); + if deposit.is_zero() { + return BorrowAssetAmount::zero(); + } + + let last_snapshot = self.market.borrow().get_last_snapshot(); + let total_deposited = Decimal::from(last_snapshot.deposited.as_u128()); + if total_deposited.is_zero() { + // divzero safety + return BorrowAssetAmount::zero(); + } + let supply_weight = Decimal::from( + self.market + .borrow() + .configuration + .yield_weights + .supply + .get(), + ); + // This is guaranteed to be nonzero, so no divzero issue. + let total_weight = Decimal::from( + self.market + .borrow() + .configuration + .yield_weights + .total_weight() + .get(), + ); + let total_yield_distribution = Decimal::from(last_snapshot.yield_distribution.as_u128()); + let estimate_current_snapshot = + total_yield_distribution * deposit * supply_weight / total_deposited / total_weight; + + estimate_current_snapshot.to_u128_floor().unwrap().into() + } + + pub fn calculate_yield(&self) -> AccumulationRecord { + let mut next_snapshot_index = self.position.borrow_asset_yield.get_next_snapshot_index(); + + if self.market.borrow().snapshots.is_empty() { + return AccumulationRecord::empty(next_snapshot_index); + } + + let amount = Decimal::from(self.position.get_borrow_asset_deposit().as_u128()); + + let mut accumulated = Decimal::ZERO; + + let mut it = self.market.borrow().snapshots.iter(); + // Skip the last snapshot, which may be incomplete. + it.next_back(); + + for (i, snapshot) in it.enumerate().skip(next_snapshot_index as usize).map( + // Assume # of snapshots is never >u32::MAX. + #[allow(clippy::cast_possible_truncation)] + |(i, s)| (i as u32, s), + ) { + let deposited = Decimal::from(snapshot.deposited.as_u128()); + let distributed = Decimal::from(snapshot.yield_distribution.as_u128()); + let share = amount * distributed / deposited; + accumulated += share; + + next_snapshot_index = i + 1; + } + + AccumulationRecord { + amount: accumulated.to_u128_floor().unwrap().into(), + next_snapshot_index, + } + } +} + +pub struct LinkedSupplyPositionMut>(LinkedSupplyPosition); + +impl> Drop for LinkedSupplyPositionMut { + fn drop(&mut self) { + self.0 + .market + .borrow_mut() + .supply_positions + .insert(&self.0.account_id, &self.0.position); + } +} + +impl> Deref for LinkedSupplyPositionMut { + type Target = LinkedSupplyPosition; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl> DerefMut for LinkedSupplyPositionMut { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl> LinkedSupplyPositionMut { + pub fn new(market: M, account_id: AccountId, position: SupplyPosition) -> Self { + Self(LinkedSupplyPosition::new(market, account_id, position)) + } + + /// In order for yield calculations to be accurate, this function MUST + /// BE CALLED every time a supply position's deposit changes. This + /// requirement is largely met by virtue of the fact that + /// `SupplyPosition->borrow_asset_deposit` is a private field and can only + /// be modified via methods on this type. + pub fn accumulate_yield(&mut self) { + self.market.borrow_mut().snapshot(); + + let accumulation_record = self.calculate_yield(); + + MarketEvent::YieldAccumulated { + account_id: self.account_id.clone(), + borrow_asset_amount: accumulation_record.amount, + } + .emit(); + + self.position + .borrow_asset_yield + .accumulate(accumulation_record); + } + + #[allow(clippy::missing_panics_doc)] + pub fn calculate_yield(&self) -> AccumulationRecord { + let mut next_snapshot_index = self.position.borrow_asset_yield.get_next_snapshot_index(); + + if self.market.borrow().snapshots.is_empty() { + return AccumulationRecord::empty(next_snapshot_index); + } + + let amount = Decimal::from(self.position.borrow_asset_deposit.as_u128()); + + let mut accumulated = Decimal::ZERO; + + let mut it = self.market.borrow().snapshots.iter(); + // Skip the last snapshot, which may be incomplete. + it.next_back(); + + for (i, snapshot) in it + .enumerate() + .skip(next_snapshot_index as usize) + .map(|(i, s)| (i as u32, s)) + { + let deposited = Decimal::from(snapshot.deposited.as_u128()); + let distributed = Decimal::from(snapshot.yield_distribution.as_u128()); + let share = amount * distributed / deposited; + accumulated += share; + + next_snapshot_index = i + 1; + } + + AccumulationRecord { + amount: accumulated.to_u128_floor().unwrap().into(), + next_snapshot_index, + } + } + + pub fn record_withdrawal(&mut self, amount: BorrowAssetAmount) -> BorrowAssetAmount { + self.accumulate_yield(); + + let withdrawn = self + .position + .decrease_borrow_asset_deposit(amount) + .unwrap_or_else(|| env::panic_str("Supply position borrow asset underflow")); + + self.market + .borrow_mut() + .borrow_asset_deposited + .split(amount) + .unwrap_or_else(|| env::panic_str("Borrow asset deposited underflow")); + + self.market.borrow_mut().snapshot(); + + MarketEvent::SupplyWithdrawn { + account_id: self.account_id.clone(), + borrow_asset_amount: amount, + } + .emit(); + + withdrawn + } + + pub fn record_deposit(&mut self, amount: BorrowAssetAmount) { + self.accumulate_yield(); + + self.position + .increase_borrow_asset_deposit(amount) + .unwrap_or_else(|| env::panic_str("Supply position borrow asset overflow")); + + self.market + .borrow_mut() + .borrow_asset_deposited + .join(amount) + .unwrap_or_else(|| env::panic_str("Borrow asset deposited overflow")); + + self.market.borrow_mut().snapshot(); + + MarketEvent::SupplyDeposited { + account_id: self.account_id.clone(), + borrow_asset_amount: amount, + } + .emit(); + } + + pub fn record_yield_withdrawal( + &mut self, + amount: BorrowAssetAmount, + ) -> Option { + self.0.position.borrow_asset_yield.remove(amount) + } +} diff --git a/contract/market/src/impl_ft_receiver.rs b/contract/market/src/impl_ft_receiver.rs index 96e6bfee..27127535 100644 --- a/contract/market/src/impl_ft_receiver.rs +++ b/contract/market/src/impl_ft_receiver.rs @@ -40,21 +40,21 @@ impl FungibleTokenReceiver for Contract { Nep141MarketDepositMessage::Supply => { let amount = use_borrow_asset(); - self.execute_supply(&sender_id, amount); + self.execute_supply(sender_id, amount); PromiseOrValue::Value(U128(0)) } Nep141MarketDepositMessage::Collateralize => { let amount = use_collateral_asset(); - self.execute_collateralize(&sender_id, amount); + self.execute_collateralize(sender_id, amount); PromiseOrValue::Value(U128(0)) } Nep141MarketDepositMessage::Repay => { let amount = use_borrow_asset(); - let refund = self.execute_repay(&sender_id, amount); + let refund = self.execute_repay(sender_id, amount); PromiseOrValue::Value(refund.into()) } diff --git a/contract/market/src/impl_helper.rs b/contract/market/src/impl_helper.rs index d1e3a865..34d038d8 100644 --- a/contract/market/src/impl_helper.rs +++ b/contract/market/src/impl_helper.rs @@ -4,34 +4,21 @@ use near_sdk::{ }; use templar_common::{ asset::{BorrowAssetAmount, CollateralAssetAmount}, - borrow::BorrowPosition, market::PricePair, oracle::pyth::OracleResponse, snapshot::Snapshot, - supply::SupplyPosition, }; use crate::{Contract, ContractExt}; /// Internal helpers. impl Contract { - pub fn execute_supply(&mut self, account_id: &AccountId, amount: BorrowAssetAmount) { - let mut supply_position = self - .supply_positions - .get(account_id) - .unwrap_or_else(|| SupplyPosition::new(self.snapshot())); - - self.record_supply_position_borrow_asset_deposit(&mut supply_position, amount); - - self.supply_positions.insert(account_id, &supply_position); + pub fn execute_supply(&mut self, account_id: AccountId, amount: BorrowAssetAmount) { + let mut supply_position = self.get_or_create_linked_supply_position_mut(account_id); + supply_position.record_deposit(amount); } - pub fn execute_collateralize(&mut self, account_id: &AccountId, amount: CollateralAssetAmount) { - let mut borrow_position = self - .borrow_positions - .get(account_id) - .unwrap_or_else(|| BorrowPosition::new(self.snapshot())); - + pub fn execute_collateralize(&mut self, account_id: AccountId, amount: CollateralAssetAmount) { // TODO: This creates a borrow record implicitly. If we // require a discrete "sign-up" step, we will need to add // checks before this function call. @@ -39,30 +26,25 @@ impl Contract { // The sign-up step would only be NFT gating or something of // that sort, which is just an additional pre condition check. // -- https://github.com/Templar-Protocol/contract-mvp/pull/6#discussion_r1923871982 - self.record_borrow_position_collateral_asset_deposit(&mut borrow_position, amount); - - self.borrow_positions.insert(account_id, &borrow_position); + let mut borrow_position = self.get_or_create_linked_borrow_position_mut(account_id); + borrow_position.record_collateral_asset_deposit(amount); } /// Returns the amount that should be returned to the account. pub fn execute_repay( &mut self, - account_id: &AccountId, + account_id: AccountId, amount: BorrowAssetAmount, ) -> BorrowAssetAmount { - if let Some(mut borrow_position) = self.borrow_positions.get(account_id) { + if let Some(mut borrow_position) = self.get_linked_borrow_position_mut(account_id) { // TODO: // Due to the slightly imprecise calculation of yield and // other fees, the returning of the excess should be // anything >1%, for example, over the total amount // borrowed + fees/interest. // -- https://github.com/Templar-Protocol/contract-mvp/pull/6#discussion_r1923876327 - let refund = - self.record_borrow_position_borrow_asset_repay(&mut borrow_position, amount); - - self.borrow_positions.insert(account_id, &borrow_position); - refund + borrow_position.record_repay(amount) } else { // No borrow exists: just return the whole amount. amount @@ -71,56 +53,46 @@ impl Contract { pub fn execute_liquidate_initial( &mut self, - account_id: &AccountId, + account_id: AccountId, amount: BorrowAssetAmount, - oracle_price_proof: &PricePair, + price_pair: &PricePair, ) -> CollateralAssetAmount { - let mut borrow_position = self - .borrow_positions - .get(account_id) - .unwrap_or_else(|| BorrowPosition::new(self.snapshot())); + let mut borrow_position = self.get_or_create_linked_borrow_position_mut(account_id); require!( - self.configuration - .borrow_status( - &borrow_position, - oracle_price_proof, - env::block_timestamp_ms(), - ) - .is_liquidation(), + borrow_position.can_be_liquidated(price_pair, env::block_timestamp_ms()), "Borrow position cannot be liquidated", ); - let minimum_acceptable_amount = self.configuration.minimum_acceptable_liquidation_amount( - borrow_position.collateral_asset_deposit, - oracle_price_proof, - ); + let minimum_acceptable_amount = + borrow_position.minimum_acceptable_liquidation_amount(price_pair); require!( amount >= minimum_acceptable_amount, "Too little attached to liquidate", ); - self.record_liquidation_lock(&mut borrow_position); - - self.borrow_positions.insert(account_id, &borrow_position); + borrow_position.liquidation_lock(); - borrow_position.collateral_asset_deposit + borrow_position.inner().collateral_asset_deposit } /// Returns the amount to return to the liquidator. pub fn execute_liquidate_final( &mut self, - account_id: &AccountId, + liquidator_id: AccountId, + account_id: AccountId, amount: BorrowAssetAmount, success: bool, ) -> BorrowAssetAmount { - let mut borrow_position = self.borrow_positions.get(account_id).unwrap_or_else(|| { - env::panic_str("Invariant violation: Liquidation of nonexistent position.") - }); + let mut borrow_position = self + .get_linked_borrow_position_mut(account_id) + .unwrap_or_else(|| { + env::panic_str("Invariant violation: Liquidation of nonexistent position.") + }); if success { - self.record_full_liquidation(&mut borrow_position, amount); + borrow_position.record_full_liquidation(liquidator_id, amount); BorrowAssetAmount::zero() } else { // Somehow transfer of collateral failed. This could mean: @@ -133,7 +105,7 @@ impl Contract { // broke down somewhere between the signer and the remote RPC. // Could be as simple as a nonce sync issue. Should just wait // and try again later. - self.record_liquidation_unlock(&mut borrow_position); + borrow_position.liquidation_unlock(); amount } } @@ -188,30 +160,24 @@ impl Contract { .of(amount) .unwrap_or_else(|| env::panic_str("Fee calculation failed")); - let Some(mut borrow_position) = self.borrow_positions.get(&account_id) else { + let Some(mut borrow_position) = self.get_linked_borrow_position_mut(account_id.clone()) + else { env::panic_str("No borrower record. Please deposit collateral first."); }; - self.record_borrow_position_borrow_asset_in_flight_start( - &mut borrow_position, - amount, - fees, - ); + borrow_position.record_borrow_asset_in_flight_start(amount, fees); require!( - self.configuration - .is_within_minimum_initial_collateral_ratio(&borrow_position, &price_pair), + borrow_position.is_within_minimum_initial_collateral_ratio(&price_pair), "New position must exceed initial minimum collateral ratio", ); require!( - self.configuration - .borrow_status(&borrow_position, &price_pair, env::block_timestamp_ms()) - .is_healthy(), + !borrow_position.can_be_liquidated(&price_pair, env::block_timestamp_ms()), "New position would be in liquidation", ); - self.borrow_positions.insert(&account_id, &borrow_position); + drop(borrow_position); self.configuration .borrow_asset @@ -231,11 +197,11 @@ impl Contract { ) { require!(env::promise_results_count() == 1); - let Some(mut borrow_position) = self.borrow_positions.get(&account_id) else { + let Some(mut borrow_position) = self.get_linked_borrow_position_mut(account_id) else { env::panic_str("Invariant violation: borrow position does not exist after transfer."); }; - self.record_borrow_position_borrow_asset_in_flight_end(&mut borrow_position, amount, fees); + borrow_position.record_borrow_asset_in_flight_end(amount, fees); match env::promise_result(0) { PromiseResult::Successful(_) => { @@ -243,11 +209,7 @@ impl Contract { // // Borrow position has already been created: finalize // withdrawal record. - self.record_borrow_position_borrow_asset_withdrawal( - &mut borrow_position, - amount, - fees, - ); + borrow_position.record_borrow_asset_withdrawal(amount, fees); } PromiseResult::Failed => { // Likely reasons for failure: @@ -272,8 +234,6 @@ impl Contract { // TODO: Implement case 2 mitigation. } } - - self.borrow_positions.insert(&account_id, &borrow_position); } #[private] @@ -310,9 +270,8 @@ impl Contract { env::log_str("The withdrawal request cannot be fulfilled at this time. Please try again later."); self.withdrawal_queue.unlock(); - if let Some(mut supply_position) = self.supply_positions.get(&account) { - self.record_supply_position_borrow_asset_deposit(&mut supply_position, amount); - self.supply_positions.insert(&account, &supply_position); + if let Some(mut supply_position) = self.get_linked_supply_position_mut(account) { + supply_position.record_deposit(amount); } } } @@ -333,14 +292,17 @@ impl Contract { .unwrap_or_else(|e| env::panic_str(&e.to_string())); let liquidated_collateral = - self.execute_liquidate_initial(&account_id, amount, &price_pair); + self.execute_liquidate_initial(account_id.clone(), amount, &price_pair); self.configuration .collateral_asset - .transfer(liquidator_id, liquidated_collateral) + .transfer(liquidator_id.clone(), liquidated_collateral) .then( - Self::ext(env::current_account_id()) - .liquidate_ft_transfer_call_02_finalize(account_id, amount), + Self::ext(env::current_account_id()).liquidate_ft_transfer_call_02_finalize( + liquidator_id, + account_id, + amount, + ), ) } @@ -349,6 +311,7 @@ impl Contract { #[private] pub fn liquidate_ft_transfer_call_02_finalize( &mut self, + liquidator_id: AccountId, account_id: AccountId, borrow_asset_amount: BorrowAssetAmount, ) -> U128 { @@ -357,7 +320,7 @@ impl Contract { let success = matches!(env::promise_result(0), PromiseResult::Successful(_)); let refund_to_liquidator = - self.execute_liquidate_final(&account_id, borrow_asset_amount, success); + self.execute_liquidate_final(liquidator_id, account_id, borrow_asset_amount, success); refund_to_liquidator.into() } @@ -377,7 +340,7 @@ impl Contract { .unwrap_or_else(|e| env::panic_str(&e.to_string())); let liquidated_collateral = - self.execute_liquidate_initial(&account_id, amount, &price_pair); + self.execute_liquidate_initial(account_id.clone(), amount, &price_pair); self.configuration .collateral_asset @@ -402,8 +365,12 @@ impl Contract { let success = matches!(env::promise_result(0), PromiseResult::Successful(_)); - let refund_to_liquidator = - self.execute_liquidate_final(&account_id, borrow_asset_amount, success); + let refund_to_liquidator = self.execute_liquidate_final( + liquidator_id.clone(), + account_id, + borrow_asset_amount, + success, + ); if refund_to_liquidator.is_zero() { PromiseOrValue::Value(()) @@ -429,19 +396,19 @@ impl Contract { .create_price_pair(&oracle_response) .unwrap_or_else(|e| env::panic_str(&e.to_string())); - let Some(mut borrow_position) = self.borrow_positions.get(&account_id) else { + let Some(mut borrow_position) = self.get_linked_borrow_position_mut(account_id.clone()) + else { env::panic_str("No borrower record. Please deposit collateral first."); }; - self.record_borrow_position_collateral_asset_withdrawal(&mut borrow_position, amount); + borrow_position.record_collateral_asset_withdrawal(amount); require!( - self.configuration - .is_within_minimum_collateral_ratio(&borrow_position, &price_pair), + borrow_position.is_within_minimum_collateral_ratio(&price_pair), "Borrow must still be above MCR after collateral withdrawal.", ); - self.borrow_positions.insert(&account_id, &borrow_position); + drop(borrow_position); self.configuration .collateral_asset @@ -465,13 +432,11 @@ impl Contract { if transfer_was_successful { // Do nothing } else { - let Some(mut borrow_position) = self.borrow_positions.get(&account_id) else { + let Some(mut borrow_position) = self.get_linked_borrow_position_mut(account_id) else { env::panic_str("Invariant violation: Borrow position must exist after collateral withdrawal failure."); }; - self.record_borrow_position_collateral_asset_deposit(&mut borrow_position, amount); - - self.borrow_positions.insert(&account_id, &borrow_position); + borrow_position.record_collateral_asset_deposit(amount); } } } diff --git a/contract/market/src/impl_market_external.rs b/contract/market/src/impl_market_external.rs index 9682cb48..2d7459cc 100644 --- a/contract/market/src/impl_market_external.rs +++ b/contract/market/src/impl_market_external.rs @@ -32,8 +32,7 @@ impl MarketExternalInterface for Contract { fn list_borrows(&self, offset: Option, count: Option) -> Vec { let offset = offset.map_or(0, |o| o as usize); let count = count.map_or(usize::MAX, |c| c as usize); - self.borrow_positions - .keys() + self.iter_borrow_account_ids() .skip(offset) .take(count) .collect() @@ -42,18 +41,16 @@ impl MarketExternalInterface for Contract { fn list_supplys(&self, offset: Option, count: Option) -> Vec { let offset = offset.map_or(0, |o| o as usize); let count = count.map_or(usize::MAX, |c| c as usize); - self.supply_positions - .keys() + self.iter_supply_account_ids() .skip(offset) .take(count) .collect() } fn get_borrow_position(&self, account_id: AccountId) -> Option { - let mut borrow_position = self.borrow_positions.get(&account_id)?; - borrow_position.pending_fee_estimate = - self.calculate_borrow_position_instantaneous_pending_interest(&borrow_position); - Some(borrow_position) + let mut borrow_position = self.get_linked_borrow_position(account_id)?; + borrow_position.with_pending_interest(); + Some(borrow_position.inner().clone()) } fn get_borrow_status( @@ -103,14 +100,19 @@ impl MarketExternalInterface for Contract { fn withdraw_collateral(&mut self, amount: CollateralAssetAmount) -> Promise { let account_id = env::predecessor_account_id(); - let Some(mut borrow_position) = self.borrow_positions.get(&account_id) else { + let Some(mut borrow_position) = self.get_linked_borrow_position_mut(account_id.clone()) + else { env::panic_str("No borrower record. Please deposit collateral first."); }; - if borrow_position.get_total_borrow_asset_liability().is_zero() { + if borrow_position + .inner() + .get_total_borrow_asset_liability() + .is_zero() + { // No need to retrieve prices, since there is zero liability. - self.record_borrow_position_collateral_asset_withdrawal(&mut borrow_position, amount); - self.borrow_positions.insert(&account_id, &borrow_position); + borrow_position.record_collateral_asset_withdrawal(amount); + drop(borrow_position); self.configuration .collateral_asset @@ -120,6 +122,7 @@ impl MarketExternalInterface for Contract { .withdraw_collateral_02_finalize(account_id, amount), ) } else { + drop(borrow_position); // They still have liability, so we need to check prices. self.configuration .balance_oracle @@ -133,9 +136,8 @@ impl MarketExternalInterface for Contract { fn apply_interest(&mut self) { let predecessor = env::predecessor_account_id(); - if let Some(mut borrow_position) = self.borrow_positions.get(&predecessor) { - self.accumulate_borrow_position_interest(&mut borrow_position); - self.borrow_positions.insert(&predecessor, &borrow_position); + if let Some(mut borrow_position) = self.get_linked_borrow_position_mut(predecessor) { + borrow_position.accumulate_interest(); } } @@ -144,10 +146,9 @@ impl MarketExternalInterface for Contract { } fn get_supply_position(&self, account_id: AccountId) -> Option { - let mut supply_position = self.supply_positions.get(&account_id)?; - supply_position.pending_yield_estimate = - self.calculate_supply_position_instantaneous_pending_yield(&supply_position); - Some(supply_position) + let mut supply_position = self.get_linked_supply_position(account_id)?; + supply_position.with_pending_yield_estimate(); + Some(supply_position.inner().clone()) } /// If the predecessor has already entered the queue, calling this function @@ -159,9 +160,8 @@ impl MarketExternalInterface for Contract { ); let predecessor = env::predecessor_account_id(); if self - .supply_positions - .get(&predecessor) - .filter(|supply_position| !supply_position.get_borrow_asset_deposit().is_zero()) + .get_linked_supply_position(predecessor.clone()) + .filter(|supply_position| !supply_position.inner().get_borrow_asset_deposit().is_zero()) .is_none() { env::panic_str("Supply position does not exist"); @@ -211,9 +211,8 @@ impl MarketExternalInterface for Contract { fn harvest_yield(&mut self) { let predecessor = env::predecessor_account_id(); - if let Some(mut supply_position) = self.supply_positions.get(&predecessor) { - self.accumulate_supply_position_yield(&mut supply_position); - self.supply_positions.insert(&predecessor, &supply_position); + if let Some(mut supply_position) = self.get_linked_supply_position_mut(predecessor) { + supply_position.accumulate_yield(); } } @@ -237,22 +236,23 @@ impl MarketExternalInterface for Contract { fn withdraw_supply_yield(&mut self, amount: Option) -> Promise { let predecessor = env::predecessor_account_id(); - let Some(mut supply_position) = self.supply_positions.get(&predecessor) else { + let Some(mut supply_position) = self.get_linked_supply_position_mut(predecessor.clone()) + else { env::panic_str("Supply position does not exist"); }; - let amount = amount.unwrap_or_else(|| supply_position.borrow_asset_yield.get_total()); + let amount = + amount.unwrap_or_else(|| supply_position.inner().borrow_asset_yield.get_total()); let withdrawn = supply_position - .borrow_asset_yield - .remove(amount) + .record_yield_withdrawal(amount) .unwrap_or_else(|| { env::panic_str("Attempt to withdraw more yield than has accumulated") }); if withdrawn.is_zero() { env::panic_str("No rewards can be withdrawn"); } - self.supply_positions.insert(&predecessor, &supply_position); + drop(supply_position); // TODO: Check for transfer success. self.configuration @@ -333,7 +333,7 @@ impl MarketExternalInterface for Contract { require!(!amount.is_zero(), "Deposit must be nonzero"); - self.execute_supply(&env::predecessor_account_id(), amount); + self.execute_supply(env::predecessor_account_id(), amount); } #[payable] @@ -347,7 +347,7 @@ impl MarketExternalInterface for Contract { require!(!amount.is_zero(), "Deposit must be nonzero"); - self.execute_collateralize(&env::predecessor_account_id(), amount); + self.execute_collateralize(env::predecessor_account_id(), amount); } #[payable] @@ -363,7 +363,7 @@ impl MarketExternalInterface for Contract { let predecessor = env::predecessor_account_id(); - let refund = self.execute_repay(&predecessor, amount); + let refund = self.execute_repay(predecessor.clone(), amount); if refund.is_zero() { PromiseOrValue::Value(()) diff --git a/contract/market/tests/interest_rate.rs b/contract/market/tests/interest_rate.rs index 12d4b1fc..1613c53c 100644 --- a/contract/market/tests/interest_rate.rs +++ b/contract/market/tests/interest_rate.rs @@ -2,8 +2,8 @@ use std::{sync::atomic::Ordering, time::Duration}; use rstest::rstest; use templar_common::{ - dec, fee::Fee, interest_rate_strategy::InterestRateStrategy, market::MS_IN_A_YEAR, - number::Decimal, + asset::BorrowAssetAmount, dec, fee::Fee, interest_rate_strategy::InterestRateStrategy, + number::Decimal, MS_IN_A_YEAR, }; use test_utils::*; @@ -80,9 +80,15 @@ async fn interest_rate(#[case] principal: u128, #[case] strategy: InterestRateSt let duration_outer = time_outer.elapsed(); let supply_yield_1 = supply_position_1.borrow_asset_yield.get_total().as_u128() - + supply_position_1.pending_yield_estimate.as_u128(); + + supply_position_1 + .borrow_asset_yield + .pending_estimate + .as_u128(); let supply_yield_2 = supply_position_2.borrow_asset_yield.get_total().as_u128() - + supply_position_2.pending_yield_estimate.as_u128(); + + supply_position_2 + .borrow_asset_yield + .pending_estimate + .as_u128(); // No yield yet. assert_eq!(supply_yield_1, 0); @@ -97,14 +103,20 @@ async fn interest_rate(#[case] principal: u128, #[case] strategy: InterestRateSt let approximation_above = (f * duration_outer.as_millis()).to_u128_ceil().unwrap(); let actual_1 = borrow_position_1.borrow_asset_fees.get_total().as_u128() - + borrow_position_1.pending_fee_estimate.as_u128(); + + borrow_position_1 + .borrow_asset_fees + .pending_estimate + .as_u128(); println!("{approximation_below} <= {actual_1} <= {approximation_above}?"); assert!(approximation_below <= actual_1); assert!(actual_1 <= approximation_above); let actual_2 = borrow_position_2.borrow_asset_fees.get_total().as_u128() - + borrow_position_2.pending_fee_estimate.as_u128(); + + borrow_position_2 + .borrow_asset_fees + .pending_estimate + .as_u128(); println!("{approximation_below} <= {actual_2} <= {approximation_above} + {iters}?"); assert!(approximation_below <= actual_2); @@ -123,21 +135,30 @@ async fn interest_rate(#[case] principal: u128, #[case] strategy: InterestRateSt tokio::join!( async { let borrow_position_before = c.get_borrow_position(borrow_user.id()).await.unwrap(); - c.repay( - &borrow_user, - borrow_position_before - .get_total_borrow_asset_liability() - .as_u128() - * 110 - / 100, /* overpayment */ - ) - .await; + let r = c + .repay( + &borrow_user, + (borrow_position_before + .get_total_borrow_asset_liability() + .as_u128() + + borrow_position_before + .borrow_asset_fees + .pending_estimate + .as_u128()) + * 110 + / 100, /* overpayment */ + ) + .await; + println!("{r:#?}"); + println!("logs"); + for log in r.logs() { + println!("\t{log}"); + } let borrow_position_after = c.get_borrow_position(borrow_user.id()).await.unwrap(); - assert!( - borrow_position_after - .get_total_borrow_asset_liability() - .is_zero(), + assert_eq!( + borrow_position_after.get_total_borrow_asset_liability(), + BorrowAssetAmount::zero(), "Borrow should be fully repaid", ); }, @@ -145,19 +166,22 @@ async fn interest_rate(#[case] principal: u128, #[case] strategy: InterestRateSt let borrow_position_before = c.get_borrow_position(borrow_user_2.id()).await.unwrap(); c.repay( &borrow_user_2, - borrow_position_before + (borrow_position_before .get_total_borrow_asset_liability() .as_u128() + + borrow_position_before + .borrow_asset_fees + .pending_estimate + .as_u128()) * 110 / 100, /* overpayment */ ) .await; let borrow_position_after = c.get_borrow_position(borrow_user_2.id()).await.unwrap(); - assert!( - borrow_position_after - .get_total_borrow_asset_liability() - .is_zero(), + assert_eq!( + borrow_position_after.get_total_borrow_asset_liability(), + BorrowAssetAmount::zero(), "Borrow should be fully repaid", ); },