1use std::ops::{Deref, DerefMut};
2
3use near_sdk::{json_types::U64, near, AccountId};
4use templar_primitives::number::Decimal;
5
6use crate::{
7 accumulator::{AccumulationRecord, Accumulator},
8 asset::{BorrowAsset, BorrowAssetAmount, CollateralAssetAmount},
9 event::MarketEvent,
10 market::{Market, SnapshotProof},
11 price::{Appraise, Convert, PricePair, Valuation},
12 YEAR_PER_MS,
13};
14
15#[derive(Clone, Copy)]
19pub struct InterestAccumulationProof(());
20
21#[cfg(test)]
22impl InterestAccumulationProof {
23 pub fn test() -> Self {
24 Self(())
25 }
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
29#[near(serializers = [borsh, json])]
30pub enum BorrowStatus {
31 Healthy,
33 MaintenanceRequired,
37 Liquidation(LiquidationReason),
39}
40
41impl BorrowStatus {
42 pub fn is_healthy(&self) -> bool {
43 matches!(self, Self::Healthy)
44 }
45
46 pub fn is_liquidation(&self) -> bool {
47 matches!(self, Self::Liquidation(..))
48 }
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
52#[near(serializers = [borsh, json])]
53pub enum LiquidationReason {
54 Undercollateralization,
55 Expiration,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
59#[near(serializers = [borsh, json])]
60pub struct BorrowPosition {
61 pub started_at_block_timestamp_ms: Option<U64>,
62 pub collateral_asset_deposit: CollateralAssetAmount,
63 pub borrow_asset_principal: BorrowAssetAmount,
64 #[serde(alias = "borrow_asset_fees")]
65 pub interest: Accumulator<BorrowAsset>,
66 #[serde(default)]
67 pub fees: BorrowAssetAmount,
68 #[serde(default)]
69 pub borrow_asset_in_flight: BorrowAssetAmount,
70 #[serde(default)]
71 pub collateral_asset_in_flight: CollateralAssetAmount,
72}
73
74impl BorrowPosition {
75 pub fn new(current_snapshot_index: u32) -> Self {
76 Self {
77 started_at_block_timestamp_ms: None,
78 collateral_asset_deposit: 0.into(),
79 borrow_asset_principal: 0.into(),
80 interest: Accumulator::new(current_snapshot_index),
85 fees: 0.into(),
86 borrow_asset_in_flight: 0.into(),
87 collateral_asset_in_flight: 0.into(),
88 }
89 }
90
91 pub fn get_borrow_asset_principal(&self) -> BorrowAssetAmount {
92 self.borrow_asset_principal + self.borrow_asset_in_flight
93 }
94
95 pub fn get_total_borrow_asset_liability(&self) -> BorrowAssetAmount {
96 self.borrow_asset_principal
97 + self.borrow_asset_in_flight
98 + self.interest.get_total()
99 + self.fees
100 }
101
102 pub fn get_total_collateral_amount(&self) -> CollateralAssetAmount {
103 self.collateral_asset_deposit
104 }
105
106 pub fn exists(&self) -> bool {
107 !self.get_total_collateral_amount().is_zero()
108 || !self.get_total_borrow_asset_liability().is_zero()
109 || !self.collateral_asset_in_flight.is_zero()
110 }
111
112 pub fn collateralization_ratio(&self, price_pair: &PricePair) -> Option<Decimal> {
114 let borrow_liability = self.get_total_borrow_asset_liability();
115 if borrow_liability.is_zero() {
116 return None;
117 }
118
119 let collateral_valuation =
120 Valuation::pessimistic(self.get_total_collateral_amount(), &price_pair.collateral);
121 let borrow_valuation = Valuation::optimistic(borrow_liability, &price_pair.borrow);
122
123 collateral_valuation.ratio(borrow_valuation)
124 }
125
126 pub(crate) fn increase_borrow_asset_principal(
128 &mut self,
129 _proof: InterestAccumulationProof,
130 amount: BorrowAssetAmount,
131 block_timestamp_ms: u64,
132 ) {
133 if self.started_at_block_timestamp_ms.is_none()
134 || self.get_total_borrow_asset_liability().is_zero()
135 {
136 self.started_at_block_timestamp_ms = Some(block_timestamp_ms.into());
137 }
138 self.borrow_asset_principal += amount;
139 }
140
141 pub fn liquidatable_collateral(
142 &self,
143 price_pair: &PricePair,
144 mcr: Decimal,
145 liquidator_spread: Decimal,
146 ) -> CollateralAssetAmount {
147 let liability = self.get_total_borrow_asset_liability();
148 if liability.is_zero() {
149 return CollateralAssetAmount::zero();
150 }
151
152 let valuation_liability = price_pair.valuation(liability);
153 let collateral = self.get_total_collateral_amount();
154 let valuation_collateral = price_pair.valuation(collateral);
155
156 let Some(cr) = valuation_collateral.ratio(valuation_liability) else {
157 return CollateralAssetAmount::zero();
159 };
160
161 if cr <= Decimal::ONE {
162 return collateral;
164 }
165
166 if cr >= mcr {
167 return CollateralAssetAmount::zero();
169 }
170
171 let collateral_dec = Decimal::from(collateral);
172 let discount = Decimal::ONE - liquidator_spread;
173
174 let liquidatable_amount = (mcr * price_pair.convert(liability) - collateral_dec)
175 / (mcr * discount - Decimal::ONE);
176
177 liquidatable_amount
178 .to_u128_ceil()
179 .map_or(collateral, CollateralAssetAmount::new)
180 .min(collateral)
181 }
182}
183
184#[must_use]
185#[derive(Debug, Clone)]
186#[near(serializers = [json])]
187pub struct LiabilityReduction {
188 pub to_fees: BorrowAssetAmount,
189 pub to_interest: BorrowAssetAmount,
190 pub to_principal: BorrowAssetAmount,
191 pub to_refund: BorrowAssetAmount,
192}
193
194#[must_use]
195#[derive(Debug, Clone)]
196#[near(serializers = [json, borsh])]
197pub struct Liquidation {
198 pub liquidated: CollateralAssetAmount,
199 pub refund: BorrowAssetAmount,
200}
201
202#[must_use]
203#[derive(Debug, Clone)]
204#[near(serializers = [json, borsh])]
205pub struct InitialBorrow {
206 pub amount: BorrowAssetAmount,
207 pub fees: BorrowAssetAmount,
208}
209
210pub mod error {
211 use thiserror::Error;
212
213 use crate::asset::{BorrowAssetAmount, CollateralAssetAmount};
214
215 #[derive(Error, Debug)]
216 pub enum LiquidationError {
217 #[error("Borrow position is not eligible for liquidation")]
218 Ineligible,
219 #[error("Attempt to liquidate more collateral than is currently eligible: {requested} requested > {available} available")]
220 ExcessiveLiquidation {
221 requested: CollateralAssetAmount,
222 available: CollateralAssetAmount,
223 },
224 #[error("Failed to calculate value of collateral")]
225 ValueCalculationFailure,
226 #[error("Liquidation offer too low: {offered} offered < {minimum_acceptable} minimum acceptable")]
227 OfferTooLow {
228 offered: BorrowAssetAmount,
229 minimum_acceptable: BorrowAssetAmount,
230 },
231 }
232
233 #[derive(Debug, Error)]
234 pub enum InitialBorrowError {
235 #[error("Insufficient borrow asset available")]
236 InsufficientBorrowAssetAvailable,
237 #[error("Fee calculation failed")]
238 FeeCalculationFailure,
239 #[error("Borrow position must be healthy after borrow")]
240 Undercollateralization,
241 #[error("New borrow position is outside of allowable range")]
242 OutsideAllowableRange,
243 }
244}
245
246pub struct BorrowPositionRef<M> {
247 market: M,
248 account_id: AccountId,
249 position: BorrowPosition,
250}
251
252impl<M> BorrowPositionRef<M> {
253 pub fn new(market: M, account_id: AccountId, position: BorrowPosition) -> Self {
254 Self {
255 market,
256 account_id,
257 position,
258 }
259 }
260
261 pub fn account_id(&self) -> &AccountId {
262 &self.account_id
263 }
264
265 pub fn inner(&self) -> &BorrowPosition {
266 &self.position
267 }
268}
269
270impl<M: Deref<Target = Market>> BorrowPositionRef<M> {
271 pub fn calculate_interest(&self, snapshot_limit: u32) -> AccumulationRecord<BorrowAsset> {
272 let principal: Decimal = self.position.get_borrow_asset_principal().into();
273 let mut next_snapshot_index = self.position.interest.get_next_snapshot_index();
274
275 let mut accumulated = Decimal::ZERO;
276 #[allow(clippy::unwrap_used, reason = "1 finalized snapshot guaranteed")]
277 let mut prev_end_timestamp_ms = self
278 .market
279 .finalized_snapshots
280 .get(next_snapshot_index.checked_sub(1).unwrap())
281 .unwrap()
282 .end_timestamp_ms
283 .0;
284
285 #[allow(
286 clippy::cast_possible_truncation,
287 reason = "Assume # of snapshots will never be > u32::MAX"
288 )]
289 for (i, snapshot) in self
290 .market
291 .finalized_snapshots
292 .iter()
293 .enumerate()
294 .skip(next_snapshot_index as usize)
295 .take(snapshot_limit as usize)
296 {
297 let duration_ms = Decimal::from(
298 snapshot
299 .end_timestamp_ms
300 .0
301 .checked_sub(prev_end_timestamp_ms)
302 .unwrap_or_else(|| {
303 crate::panic_with_message(&format!(
304 "Invariant violation: Snapshot timestamp decrease at time chunk #{}.",
305 u64::from(snapshot.time_chunk.0),
306 ))
307 }),
308 );
309 accumulated += principal * snapshot.interest_rate * duration_ms * YEAR_PER_MS;
310
311 prev_end_timestamp_ms = snapshot.end_timestamp_ms.0;
312 next_snapshot_index = i as u32 + 1;
313 }
314
315 AccumulationRecord {
316 #[allow(
317 clippy::unwrap_used,
318 reason = "Assume accumulated interest will never exceed u128::MAX"
319 )]
320 amount: accumulated.to_u128_floor().unwrap().into(),
321 fraction_as_u128_dividend: accumulated.fractional_part_as_u128_dividend(),
322 next_snapshot_index,
323 }
324 }
325
326 pub fn status(&self, price_pair: &PricePair, block_timestamp_ms: u64) -> BorrowStatus {
327 let collateralization_ratio = self.position.collateralization_ratio(price_pair);
328 self.market.configuration.borrow_status(
329 collateralization_ratio,
330 self.position.started_at_block_timestamp_ms,
331 block_timestamp_ms,
332 )
333 }
334
335 pub fn within_allowable_borrow_range(&self) -> bool {
336 self.market
337 .configuration
338 .borrow_range
339 .contains(self.position.get_borrow_asset_principal())
340 }
341
342 pub fn liquidatable_collateral(&self, price_pair: &PricePair) -> CollateralAssetAmount {
343 self.position.liquidatable_collateral(
344 price_pair,
345 self.market.configuration.borrow_mcr_maintenance,
346 self.market.configuration.liquidation_maximum_spread,
347 )
348 }
349}
350
351pub struct BorrowPositionGuard<'a>(BorrowPositionRef<&'a mut Market>);
352
353impl Drop for BorrowPositionGuard<'_> {
354 fn drop(&mut self) {
355 self.0
356 .market
357 .borrow_positions
358 .insert(&self.0.account_id, &self.0.position);
359 }
360}
361
362impl<'a> Deref for BorrowPositionGuard<'a> {
363 type Target = BorrowPositionRef<&'a mut Market>;
364
365 fn deref(&self) -> &Self::Target {
366 &self.0
367 }
368}
369
370impl DerefMut for BorrowPositionGuard<'_> {
371 fn deref_mut(&mut self) -> &mut Self::Target {
372 &mut self.0
373 }
374}
375
376impl<'a> BorrowPositionGuard<'a> {
377 pub fn new(market: &'a mut Market, account_id: AccountId, position: BorrowPosition) -> Self {
378 Self(BorrowPositionRef::new(market, account_id, position))
379 }
380
381 pub(crate) fn reduce_borrow_asset_liability(
382 &mut self,
383 _proof: InterestAccumulationProof,
384 mut amount: BorrowAssetAmount,
385 ) -> LiabilityReduction {
386 let to_fees = self.position.fees.min(amount);
388 amount = amount.unwrap_sub(to_fees, "Invariant violation: min() precludes underflow");
389 self.position.fees = self
390 .position
391 .fees
392 .unwrap_sub(to_fees, "Invariant violation: min() precludes underflow");
393
394 let to_interest = self.position.interest.get_total().min(amount);
395 amount = amount.unwrap_sub(
396 to_interest,
397 "Invariant violation: min() precludes underflow",
398 );
399 self.position.interest.remove(to_interest);
400
401 self.market.borrow_asset_paid_to_fees += to_fees + to_interest;
402
403 let to_principal = {
404 let minimum_amount = u128::from(self.market.configuration.borrow_range.minimum);
405 let amount_remaining =
406 u128::from(self.position.borrow_asset_principal).saturating_sub(u128::from(amount));
407 if amount_remaining > 0 && amount_remaining < minimum_amount {
408 u128::from(self.position.borrow_asset_principal)
409 .saturating_sub(minimum_amount)
410 .into()
411 } else {
412 self.position.borrow_asset_principal.min(amount)
413 }
414 };
415 amount = amount.unwrap_sub(
416 to_principal,
417 "Invariant violation: amount_to_principal > amount",
418 );
419 self.position.borrow_asset_principal = self.position.borrow_asset_principal.unwrap_sub(
420 to_principal,
421 "Invariant violation: amount_to_principal > borrow_asset_principal",
422 );
423 self.market.borrow_asset_borrowed = self.market.borrow_asset_borrowed.unwrap_sub(
424 to_principal,
425 "Invariant violation: amount_to_principal > market.borrow_asset_borrowed",
426 );
427
428 if self.position.borrow_asset_principal.is_zero() {
429 self.position.started_at_block_timestamp_ms = None;
431 }
432
433 LiabilityReduction {
434 to_fees,
435 to_interest,
436 to_principal,
437 to_refund: amount,
438 }
439 }
440
441 pub fn record_collateral_asset_deposit(
442 &mut self,
443 _proof: InterestAccumulationProof,
444 amount: CollateralAssetAmount,
445 ) {
446 self.position.collateral_asset_deposit += amount;
447 self.market.collateral_asset_deposited += amount;
448
449 MarketEvent::CollateralDeposited {
450 account_id: self.account_id.clone(),
451 collateral_asset_amount: amount,
452 }
453 .emit();
454 }
455
456 pub fn record_collateral_asset_withdrawal_initial(
457 &mut self,
458 _proof: InterestAccumulationProof,
459 amount: CollateralAssetAmount,
460 ) {
461 self.position.collateral_asset_in_flight += amount;
462 self.position.collateral_asset_deposit -= amount;
463 self.market.collateral_asset_deposited -= amount;
464 }
465
466 pub fn record_collateral_asset_withdrawal_final(
467 &mut self,
468 _proof: InterestAccumulationProof,
469 amount: CollateralAssetAmount,
470 success: bool,
471 ) {
472 self.position.collateral_asset_in_flight =
473 self.position.collateral_asset_in_flight.unwrap_sub(
474 amount,
475 "Invariant violation: attempt to unlock more than locked as in-flight",
476 );
477
478 if success {
479 MarketEvent::CollateralWithdrawn {
480 account_id: self.account_id.clone(),
481 collateral_asset_amount: amount,
482 }
483 .emit();
484 } else {
485 self.position.collateral_asset_deposit += amount;
486 self.market.collateral_asset_deposited += amount;
487 }
488 }
489
490 pub(crate) fn record_collateral_asset_withdrawal(
491 &mut self,
492 _proof: InterestAccumulationProof,
493 amount: CollateralAssetAmount,
494 ) {
495 self.position.collateral_asset_deposit -= amount;
496 self.market.collateral_asset_deposited -= amount;
497 }
498
499 pub fn record_borrow_initial(
504 &mut self,
505 _proof: SnapshotProof,
506 _interest: InterestAccumulationProof,
507 amount: BorrowAssetAmount,
508 price_pair: &PricePair,
509 block_timestamp_ms: u64,
510 ) -> Result<InitialBorrow, error::InitialBorrowError> {
511 let available_to_borrow = self.market.get_borrow_asset_available_to_borrow();
513 if amount > available_to_borrow {
514 return Err(error::InitialBorrowError::InsufficientBorrowAssetAvailable);
515 }
516
517 let origination_fee = self
518 .market
519 .configuration
520 .borrow_origination_fee
521 .of(amount)
522 .ok_or(error::InitialBorrowError::FeeCalculationFailure)?;
523
524 let single_snapshot_fee = self
528 .market
529 .single_snapshot_fee(amount)
530 .ok_or(error::InitialBorrowError::FeeCalculationFailure)?;
531
532 let mut fees = origination_fee;
533 fees = fees
534 .checked_add(single_snapshot_fee)
535 .ok_or(error::InitialBorrowError::FeeCalculationFailure)?;
536
537 self.market.borrow_asset_borrowed_in_flight += amount;
538 self.position.borrow_asset_in_flight += amount;
539 self.position.fees += fees;
540
541 if !self.status(price_pair, block_timestamp_ms).is_healthy() {
542 self.market.borrow_asset_borrowed_in_flight -= amount;
543 self.position.borrow_asset_in_flight -= amount;
544 self.position.fees -= fees;
545 return Err(error::InitialBorrowError::Undercollateralization);
546 }
547
548 if !self.within_allowable_borrow_range() {
549 self.market.borrow_asset_borrowed_in_flight -= amount;
550 self.position.borrow_asset_in_flight -= amount;
551 self.position.fees -= fees;
552 return Err(error::InitialBorrowError::OutsideAllowableRange);
553 }
554
555 self.market.record_borrow_asset_yield_distribution(fees);
556 self.market.borrow_asset_balance -= amount;
557
558 Ok(InitialBorrow { amount, fees })
559 }
560
561 pub fn record_borrow_final(
562 &mut self,
563 _snapshot: SnapshotProof,
564 interest: InterestAccumulationProof,
565 borrow: &InitialBorrow,
566 success: bool,
567 block_timestamp_ms: u64,
568 ) {
569 self.market.borrow_asset_borrowed_in_flight -= borrow.amount;
572 self.position.borrow_asset_in_flight -= borrow.amount;
573
574 if success {
575 self.position.increase_borrow_asset_principal(
580 interest,
581 borrow.amount,
582 block_timestamp_ms,
583 );
584
585 self.market.borrow_asset_borrowed += borrow.amount;
586
587 MarketEvent::BorrowWithdrawn {
588 account_id: self.account_id.clone(),
589 borrow_asset_amount: borrow.amount,
590 }
591 .emit();
592 } else {
593 self.market.borrow_asset_balance += borrow.amount;
616 }
617 }
618
619 pub fn record_repay(
623 &mut self,
624 proof: InterestAccumulationProof,
625 amount: BorrowAssetAmount,
626 ) -> BorrowAssetAmount {
627 self.market.borrow_asset_balance += amount;
628 let liability_reduction = self.reduce_borrow_asset_liability(proof, amount);
629
630 MarketEvent::BorrowRepaid {
631 account_id: self.account_id.clone(),
632 amount_sent: amount,
633 liability_reduction: liability_reduction.clone(),
634 liability_remaining: self.position.get_total_borrow_asset_liability(),
635 }
636 .emit();
637
638 self.market.borrow_asset_balance -= liability_reduction.to_refund;
639
640 liability_reduction.to_refund
641 }
642
643 pub fn accumulate_interest_partial(&mut self, snapshot_limit: u32) {
644 let accumulation_record = self.calculate_interest(snapshot_limit);
645
646 if !accumulation_record.amount.is_zero() {
647 MarketEvent::InterestAccumulated {
648 account_id: self.account_id.clone(),
649 borrow_asset_amount: accumulation_record.amount,
650 }
651 .emit();
652 }
653
654 self.position.interest.accumulate(accumulation_record);
655 }
656
657 pub fn accumulate_interest(&mut self) -> InterestAccumulationProof {
658 self.accumulate_interest_partial(u32::MAX);
659 InterestAccumulationProof(())
660 }
661
662 pub fn record_liquidation(
670 &mut self,
671 proof: InterestAccumulationProof,
672 liquidator_id: AccountId,
673 liquidator_sent: BorrowAssetAmount,
674 liquidator_request: Option<CollateralAssetAmount>,
675 price_pair: &PricePair,
676 block_timestamp_ms: u64,
677 ) -> Result<Liquidation, error::LiquidationError> {
678 let BorrowStatus::Liquidation(reason) = self.status(price_pair, block_timestamp_ms) else {
679 return Err(error::LiquidationError::Ineligible);
680 };
681
682 let liquidatable_collateral = match reason {
683 LiquidationReason::Undercollateralization => self.liquidatable_collateral(price_pair),
684 LiquidationReason::Expiration => self.position.collateral_asset_deposit,
685 };
686
687 let liquidator_request = liquidator_request.unwrap_or(liquidatable_collateral);
691
692 if liquidator_request > liquidatable_collateral {
693 return Err(error::LiquidationError::ExcessiveLiquidation {
694 requested: liquidator_request,
695 available: liquidatable_collateral,
696 });
697 }
698
699 let collateral_value = price_pair.convert(liquidator_request);
700
701 let maximum_acceptable: BorrowAssetAmount = collateral_value
702 .to_u128_ceil()
703 .ok_or(error::LiquidationError::ValueCalculationFailure)?
704 .max(1)
705 .into();
706 #[allow(
707 clippy::unwrap_used,
708 reason = "Previous line guarantees this will not panic"
709 )]
710 let minimum_acceptable: BorrowAssetAmount = (collateral_value
711 * (Decimal::ONE - self.market.configuration.liquidation_maximum_spread))
712 .to_u128_ceil()
713 .unwrap()
714 .max(1)
715 .into();
716
717 if liquidator_sent < minimum_acceptable {
718 return Err(error::LiquidationError::OfferTooLow {
719 offered: liquidator_sent,
720 minimum_acceptable,
721 });
722 }
723
724 let (refund, recovered) = if liquidator_sent > maximum_acceptable {
725 (liquidator_sent - maximum_acceptable, maximum_acceptable)
726 } else {
727 (BorrowAssetAmount::zero(), liquidator_sent)
728 };
729
730 self.record_collateral_asset_withdrawal(proof, liquidator_request);
731
732 let liability_reduction = self.reduce_borrow_asset_liability(proof, recovered);
733 self.market
734 .record_borrow_asset_yield_distribution(liability_reduction.to_refund);
735
736 self.market.borrow_asset_balance += recovered;
737
738 MarketEvent::Liquidation {
739 liquidator_id,
740 account_id: self.account_id.clone(),
741 borrow_asset_recovered: recovered,
742 collateral_asset_liquidated: liquidator_request,
743 }
744 .emit();
745
746 Ok(Liquidation {
747 liquidated: liquidator_request,
748 refund,
749 })
750 }
751}
752
753#[cfg(test)]
754mod tests {
755 use near_sdk::{env, serde_json, test_utils::VMContextBuilder, testing_env};
756 use rstest::rstest;
757
758 use crate::{
759 asset::FungibleAsset,
760 fee::{Fee, TimeBasedFee},
761 interest_rate_strategy::InterestRateStrategy,
762 market::{MarketConfiguration, PriceOracleConfiguration, YieldWeights},
763 oracle::pyth::{self, PriceIdentifier},
764 snapshot::Snapshot,
765 time_chunk::{TimeChunk, TimeChunkConfiguration},
766 };
767
768 use super::*;
769
770 fn interest_test_configuration() -> MarketConfiguration {
774 use templar_primitives::dec;
775
776 MarketConfiguration {
777 time_chunk_configuration: TimeChunkConfiguration::new(600_000),
778 borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
779 collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
780 price_oracle_configuration: PriceOracleConfiguration {
781 account_id: "pyth-oracle.near".parse().unwrap(),
782 collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
783 collateral_asset_decimals: 24,
784 borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
785 borrow_asset_decimals: 24,
786 price_maximum_age_s: 60,
787 },
788 borrow_mcr_maintenance: dec!("1.25"),
789 borrow_mcr_liquidation: dec!("1.2"),
790 borrow_asset_maximum_usage_ratio: dec!("0.99"),
791 borrow_origination_fee: Fee::zero(),
792 borrow_interest_rate_strategy: InterestRateStrategy::zero(),
793 borrow_maximum_duration_ms: None,
794 borrow_range: (1, None).try_into().unwrap(),
795 supply_range: (1, None).try_into().unwrap(),
796 supply_withdrawal_range: (1, None).try_into().unwrap(),
797 supply_withdrawal_fee: TimeBasedFee::zero(),
798 yield_weights: YieldWeights::new_with_supply_weight(9)
799 .with_static("revenue.tmplr.near".parse().unwrap(), 1),
800 protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
801 liquidation_maximum_spread: dec!("0.05"),
802 }
803 }
804
805 fn push_finalized_snapshot(market: &mut Market, end_timestamp_ms: u64, interest_rate: Decimal) {
810 market.finalized_snapshots.push(Snapshot {
811 time_chunk: TimeChunk(end_timestamp_ms.into()),
812 end_timestamp_ms: end_timestamp_ms.into(),
813 borrow_asset_deposited_active: 0.into(),
814 borrow_asset_borrowed: 0.into(),
815 collateral_asset_deposited: 0.into(),
816 yield_distribution: BorrowAssetAmount::zero(),
817 interest_rate,
818 });
819 }
820
821 #[test]
838 fn calculate_interest_two_snapshots_exact() {
839 const T0_MS: u64 = 1_000_000;
840 const DURATION_1_MS: u64 = 2_592_000_000; const DURATION_2_MS: u64 = 5_184_000_000; const T1_MS: u64 = T0_MS + DURATION_1_MS;
843 const T2_MS: u64 = T1_MS + DURATION_2_MS;
844 const PRINCIPAL: u128 = 1_000_000_000_000;
845 const EXPECTED: u128 = 41_068_605_104;
847
848 let rate_1 = templar_primitives::dec!("0.10");
849 let rate_2 = templar_primitives::dec!("0.20");
850
851 let context = VMContextBuilder::new()
854 .block_timestamp(T0_MS * 1_000_000)
855 .build();
856 testing_env!(context);
857
858 let mut market = Market::new(b"m", interest_test_configuration());
859 assert_eq!(market.finalized_snapshots.len(), 1);
861 assert_eq!(
862 market.get_last_finalized_snapshot().end_timestamp_ms.0,
863 T0_MS
864 );
865
866 push_finalized_snapshot(&mut market, T1_MS, rate_1); push_finalized_snapshot(&mut market, T2_MS, rate_2); assert_eq!(market.finalized_snapshots.len(), 3);
869
870 let mut position = BorrowPosition::new(1);
873 position.borrow_asset_principal = BorrowAssetAmount::new(PRINCIPAL);
874 assert_eq!(position.interest.get_next_snapshot_index(), 1);
875
876 let position_ref =
877 BorrowPositionRef::new(&market, "borrower.near".parse().unwrap(), position);
878
879 let record = position_ref.calculate_interest(u32::MAX);
880
881 let principal = Decimal::from(PRINCIPAL);
885 let mut expected_acc = Decimal::ZERO;
886 expected_acc += principal * rate_1 * Decimal::from(DURATION_1_MS) * YEAR_PER_MS;
887 expected_acc += principal * rate_2 * Decimal::from(DURATION_2_MS) * YEAR_PER_MS;
888 let expected = expected_acc.to_u128_floor().unwrap();
889
890 assert_eq!(
891 expected, EXPECTED,
892 "hand-derived Decimal computation drifted from the pinned literal",
893 );
894
895 assert_eq!(
896 u128::from(record.amount),
897 EXPECTED,
898 "calculate_interest accrued the wrong amount",
899 );
900 assert_eq!(record.next_snapshot_index, 3);
902 }
903
904 #[rstest]
905 #[test]
906 fn liquidatable_collateral(
907 #[values("1.2", "1.25", "1.5", "2")] mcr: Decimal,
908 #[values(11, 1000, 1005, 999_999)] collateral_price: i64,
909 #[values(1000, 1005, 999_999)] borrow_price: i64,
910 #[values(0, 10)] conf: u64,
911 ) {
912 use templar_primitives::dec;
913
914 use crate::oracle::pyth::PythTimestamp;
915
916 let c = VMContextBuilder::new()
917 .block_timestamp(1_000_000_000_000_000)
918 .build();
919 testing_env!(c.clone());
920
921 let configuration = MarketConfiguration {
922 time_chunk_configuration: TimeChunkConfiguration::new(600_000),
923 borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
924 collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
925 price_oracle_configuration: PriceOracleConfiguration {
926 account_id: "pyth-oracle.near".parse().unwrap(),
927 collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
928 collateral_asset_decimals: 24,
929 borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
930 borrow_asset_decimals: 24,
931 price_maximum_age_s: 60,
932 },
933 borrow_mcr_maintenance: mcr,
934 borrow_mcr_liquidation: mcr,
935 borrow_asset_maximum_usage_ratio: dec!("0.99"),
936 borrow_origination_fee: Fee::zero(),
937 borrow_interest_rate_strategy: InterestRateStrategy::zero(),
938 borrow_maximum_duration_ms: None,
939 borrow_range: (1, None).try_into().unwrap(),
940 supply_range: (1, None).try_into().unwrap(),
941 supply_withdrawal_range: (1, None).try_into().unwrap(),
942 supply_withdrawal_fee: TimeBasedFee::zero(),
943 yield_weights: YieldWeights::new_with_supply_weight(9)
944 .with_static("revenue.tmplr.near".parse().unwrap(), 1),
945 protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
946 liquidation_maximum_spread: dec!("0.05"),
947 };
948
949 let mut market = Market::new(b"m", configuration.clone());
950 market.borrow_asset_deposited_active += BorrowAssetAmount::new(100_000_000_000);
951 market.borrow_asset_balance += BorrowAssetAmount::new(100_000_000_000);
952 let snapshot_proof = market.snapshot();
953
954 let mut position = BorrowPositionGuard(BorrowPositionRef {
955 market: &mut market,
956 account_id: "borrower".parse().unwrap(),
957 position: BorrowPosition::new(1),
958 });
959
960 let interest_proof = position.accumulate_interest();
961 position.record_collateral_asset_deposit(
962 interest_proof,
963 CollateralAssetAmount::new(100_000_000),
964 );
965 let initial_price_pair = PricePair::new(
966 &pyth::Price {
967 price: 5.into(),
968 conf: 0.into(),
969 expo: 24,
970 publish_time: PythTimestamp::from_secs(10),
971 },
972 24,
973 &pyth::Price {
974 price: 1.into(),
975 conf: 0.into(),
976 expo: 24,
977 publish_time: PythTimestamp::from_secs(10),
978 },
979 24,
980 )
981 .unwrap();
982 assert_eq!(
983 position.liquidatable_collateral(&initial_price_pair),
984 CollateralAssetAmount::zero(),
985 );
986 let initial_borrow = position
987 .record_borrow_initial(
988 snapshot_proof,
989 interest_proof,
990 BorrowAssetAmount::new(85_000_000),
991 &initial_price_pair,
992 env::block_timestamp_ms(),
993 )
994 .unwrap();
995 position.record_borrow_final(
996 snapshot_proof,
997 interest_proof,
998 &initial_borrow,
999 true,
1000 env::block_timestamp_ms(),
1001 );
1002 let price_pair = PricePair::new(
1003 &pyth::Price {
1004 price: collateral_price.into(),
1005 conf: conf.into(),
1006 expo: 24,
1007 publish_time: PythTimestamp::from_secs(10),
1008 },
1009 24,
1010 &pyth::Price {
1011 price: borrow_price.into(),
1012 conf: conf.into(),
1013 expo: 24,
1014 publish_time: PythTimestamp::from_secs(10),
1015 },
1016 24,
1017 )
1018 .unwrap();
1019 let starting_cr = position.inner().collateralization_ratio(&price_pair);
1020 eprintln!("Starting collateralization ratio: {starting_cr:?}");
1021 let liquidatable_collateral = position.liquidatable_collateral(&price_pair);
1022
1023 let minimum_acceptable = configuration
1024 .minimum_acceptable_liquidation_amount(liquidatable_collateral, &price_pair)
1025 .unwrap();
1026
1027 eprintln!("Liquidatable collateral: {liquidatable_collateral}");
1028 eprintln!("Minimum acceptable: {minimum_acceptable}");
1029
1030 match collateral_price.ilog10().cmp(&borrow_price.ilog10()) {
1031 std::cmp::Ordering::Less => {
1032 assert_eq!(
1034 liquidatable_collateral,
1035 CollateralAssetAmount::new(100_000_000),
1036 "All collateral should be eligible for liquidation"
1037 );
1038 }
1039 std::cmp::Ordering::Equal => {
1040 let _liquidation = position
1043 .record_liquidation(
1044 interest_proof,
1045 "liquidator".parse().unwrap(),
1046 minimum_acceptable,
1047 Some(liquidatable_collateral),
1048 &price_pair,
1049 env::block_timestamp_ms(),
1050 )
1051 .unwrap();
1052
1053 let finishing_cr = position
1054 .inner()
1055 .collateralization_ratio(&price_pair)
1056 .unwrap();
1057 eprintln!("Finishing collateralization ratio: {finishing_cr}");
1058 eprintln!("Target MCR: {mcr}");
1059
1060 assert!(finishing_cr >= mcr);
1061 let delta = finishing_cr.abs_diff(mcr);
1062 assert!(delta < Decimal::ONE.mul_pow10(-4).unwrap());
1063 }
1064 std::cmp::Ordering::Greater => {
1065 assert_eq!(
1068 liquidatable_collateral,
1069 CollateralAssetAmount::zero(),
1070 "No collateral should be liquidatable"
1071 );
1072 }
1073 }
1074 }
1075
1076 #[test]
1077 fn test_borrow_position_deserialize_new_format() {
1078 let json = r#"{
1080 "started_at_block_timestamp_ms": "1699564800000",
1081 "collateral_asset_deposit": "1000000000000000000000000",
1082 "borrow_asset_principal": "100000000",
1083 "interest": {
1084 "total": "0",
1085 "fraction_as_u128_dividend": "0",
1086 "next_snapshot_index": 42,
1087 "pending_estimate": "0"
1088 },
1089 "fees": "500000",
1090 "borrow_asset_in_flight": "50000000",
1091 "collateral_asset_in_flight": "0",
1092 "liquidation_lock": "0"
1093 }"#;
1094
1095 let position: BorrowPosition =
1096 serde_json::from_str(json).expect("Failed to deserialize new format");
1097 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
1098 assert_eq!(
1099 position.get_borrow_asset_principal(),
1100 BorrowAssetAmount::new(50_000_000 + 100_000_000)
1101 );
1102 }
1103
1104 #[test]
1105 fn test_borrow_position_deserialize_old_format_with_borrow_asset_fees() {
1106 let json = r#"{
1108 "started_at_block_timestamp_ms": "1699564800000",
1109 "collateral_asset_deposit": "1000000000000000000000000",
1110 "borrow_asset_principal": "100000000",
1111 "borrow_asset_fees": {
1112 "total": "0",
1113 "fraction_as_u128_dividend": "0",
1114 "next_snapshot_index": 42,
1115 "pending_estimate": "0"
1116 },
1117 "fees": "500000",
1118 "borrow_asset_in_flight": "0",
1119 "collateral_asset_in_flight": "0",
1120 "liquidation_lock": "0"
1121 }"#;
1122
1123 let position: BorrowPosition =
1124 serde_json::from_str(json).expect("Failed to deserialize old format");
1125 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
1126 assert_eq!(
1127 position.get_borrow_asset_principal(),
1128 BorrowAssetAmount::new(100_000_000)
1129 );
1130 assert_eq!(
1131 position.get_total_collateral_amount(),
1132 CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1133 );
1134 }
1135
1136 #[test]
1137 fn test_borrow_position_deserialize_defaults() {
1138 let json = r#"{
1140 "collateral_asset_deposit": "1000000000000000000000000",
1141 "borrow_asset_principal": "100000000",
1142 "interest": {
1143 "total": "0",
1144 "fraction_as_u128_dividend": "0",
1145 "next_snapshot_index": 42,
1146 "pending_estimate": "0"
1147 }
1148 }"#;
1149
1150 let position: BorrowPosition =
1151 serde_json::from_str(json).expect("Failed to deserialize with defaults");
1152 assert_eq!(position.started_at_block_timestamp_ms, None);
1153 assert_eq!(position.fees, BorrowAssetAmount::new(0));
1154 assert_eq!(
1155 position.get_total_collateral_amount(),
1156 CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1157 );
1158 }
1159
1160 #[test]
1165 #[should_panic(expected = "attempt to add with overflow")]
1166 fn liability_overflow_aborts_on_principal_plus_in_flight() {
1167 let mut position = BorrowPosition::new(0);
1168 position.borrow_asset_principal = BorrowAssetAmount::new(u128::MAX);
1169 position.borrow_asset_in_flight = BorrowAssetAmount::new(1);
1170 let _ = position.get_total_borrow_asset_liability();
1171 }
1172
1173 #[test]
1181 #[should_panic(expected = "arithmetic operation overflow")]
1182 fn liquidatable_collateral_denominator_underflow_aborts() {
1183 use templar_primitives::dec;
1184
1185 use crate::oracle::pyth::PythTimestamp;
1186
1187 let price = pyth::Price {
1192 price: 1.into(),
1193 conf: 0.into(),
1194 expo: 0,
1195 publish_time: PythTimestamp::from_secs(0),
1196 };
1197 let price_pair = PricePair::new(&price, 0, &price, 0).unwrap();
1198
1199 let mut position = BorrowPosition::new(0);
1200 position.collateral_asset_deposit = CollateralAssetAmount::new(102);
1201 position.borrow_asset_principal = BorrowAssetAmount::new(100);
1202
1203 let _ = position.liquidatable_collateral(&price_pair, dec!("1.05"), dec!("0.1"));
1204 }
1205}