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 time_chunk::TimeChunkConfiguration,
765 };
766
767 use super::*;
768
769 #[rstest]
770 #[test]
771 fn liquidatable_collateral(
772 #[values("1.2", "1.25", "1.5", "2")] mcr: Decimal,
773 #[values(11, 1000, 1005, 999_999)] collateral_price: i64,
774 #[values(1000, 1005, 999_999)] borrow_price: i64,
775 #[values(0, 10)] conf: u64,
776 ) {
777 use templar_primitives::dec;
778
779 use crate::oracle::pyth::PythTimestamp;
780
781 let c = VMContextBuilder::new()
782 .block_timestamp(1_000_000_000_000_000)
783 .build();
784 testing_env!(c.clone());
785
786 let configuration = MarketConfiguration {
787 time_chunk_configuration: TimeChunkConfiguration::new(600_000),
788 borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
789 collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
790 price_oracle_configuration: PriceOracleConfiguration {
791 account_id: "pyth-oracle.near".parse().unwrap(),
792 collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
793 collateral_asset_decimals: 24,
794 borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
795 borrow_asset_decimals: 24,
796 price_maximum_age_s: 60,
797 },
798 borrow_mcr_maintenance: mcr,
799 borrow_mcr_liquidation: mcr,
800 borrow_asset_maximum_usage_ratio: dec!("0.99"),
801 borrow_origination_fee: Fee::zero(),
802 borrow_interest_rate_strategy: InterestRateStrategy::zero(),
803 borrow_maximum_duration_ms: None,
804 borrow_range: (1, None).try_into().unwrap(),
805 supply_range: (1, None).try_into().unwrap(),
806 supply_withdrawal_range: (1, None).try_into().unwrap(),
807 supply_withdrawal_fee: TimeBasedFee::zero(),
808 yield_weights: YieldWeights::new_with_supply_weight(9)
809 .with_static("revenue.tmplr.near".parse().unwrap(), 1),
810 protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
811 liquidation_maximum_spread: dec!("0.05"),
812 };
813
814 let mut market = Market::new(b"m", configuration.clone());
815 market.borrow_asset_deposited_active += BorrowAssetAmount::new(100_000_000_000);
816 market.borrow_asset_balance += BorrowAssetAmount::new(100_000_000_000);
817 let snapshot_proof = market.snapshot();
818
819 let mut position = BorrowPositionGuard(BorrowPositionRef {
820 market: &mut market,
821 account_id: "borrower".parse().unwrap(),
822 position: BorrowPosition::new(1),
823 });
824
825 let interest_proof = position.accumulate_interest();
826 position.record_collateral_asset_deposit(
827 interest_proof,
828 CollateralAssetAmount::new(100_000_000),
829 );
830 let initial_price_pair = PricePair::new(
831 &pyth::Price {
832 price: 5.into(),
833 conf: 0.into(),
834 expo: 24,
835 publish_time: PythTimestamp::from_secs(10),
836 },
837 24,
838 &pyth::Price {
839 price: 1.into(),
840 conf: 0.into(),
841 expo: 24,
842 publish_time: PythTimestamp::from_secs(10),
843 },
844 24,
845 )
846 .unwrap();
847 assert_eq!(
848 position.liquidatable_collateral(&initial_price_pair),
849 CollateralAssetAmount::zero(),
850 );
851 let initial_borrow = position
852 .record_borrow_initial(
853 snapshot_proof,
854 interest_proof,
855 BorrowAssetAmount::new(85_000_000),
856 &initial_price_pair,
857 env::block_timestamp_ms(),
858 )
859 .unwrap();
860 position.record_borrow_final(
861 snapshot_proof,
862 interest_proof,
863 &initial_borrow,
864 true,
865 env::block_timestamp_ms(),
866 );
867 let price_pair = PricePair::new(
868 &pyth::Price {
869 price: collateral_price.into(),
870 conf: conf.into(),
871 expo: 24,
872 publish_time: PythTimestamp::from_secs(10),
873 },
874 24,
875 &pyth::Price {
876 price: borrow_price.into(),
877 conf: conf.into(),
878 expo: 24,
879 publish_time: PythTimestamp::from_secs(10),
880 },
881 24,
882 )
883 .unwrap();
884 let starting_cr = position.inner().collateralization_ratio(&price_pair);
885 eprintln!("Starting collateralization ratio: {starting_cr:?}");
886 let liquidatable_collateral = position.liquidatable_collateral(&price_pair);
887
888 let minimum_acceptable = configuration
889 .minimum_acceptable_liquidation_amount(liquidatable_collateral, &price_pair)
890 .unwrap();
891
892 eprintln!("Liquidatable collateral: {liquidatable_collateral}");
893 eprintln!("Minimum acceptable: {minimum_acceptable}");
894
895 match collateral_price.ilog10().cmp(&borrow_price.ilog10()) {
896 std::cmp::Ordering::Less => {
897 assert_eq!(
899 liquidatable_collateral,
900 CollateralAssetAmount::new(100_000_000),
901 "All collateral should be eligible for liquidation"
902 );
903 }
904 std::cmp::Ordering::Equal => {
905 let _liquidation = position
908 .record_liquidation(
909 interest_proof,
910 "liquidator".parse().unwrap(),
911 minimum_acceptable,
912 Some(liquidatable_collateral),
913 &price_pair,
914 env::block_timestamp_ms(),
915 )
916 .unwrap();
917
918 let finishing_cr = position
919 .inner()
920 .collateralization_ratio(&price_pair)
921 .unwrap();
922 eprintln!("Finishing collateralization ratio: {finishing_cr}");
923 eprintln!("Target MCR: {mcr}");
924
925 assert!(finishing_cr >= mcr);
926 let delta = finishing_cr.abs_diff(mcr);
927 assert!(delta < Decimal::ONE.mul_pow10(-4).unwrap());
928 }
929 std::cmp::Ordering::Greater => {
930 assert_eq!(
933 liquidatable_collateral,
934 CollateralAssetAmount::zero(),
935 "No collateral should be liquidatable"
936 );
937 }
938 }
939 }
940
941 #[test]
942 fn test_borrow_position_deserialize_new_format() {
943 let json = r#"{
945 "started_at_block_timestamp_ms": "1699564800000",
946 "collateral_asset_deposit": "1000000000000000000000000",
947 "borrow_asset_principal": "100000000",
948 "interest": {
949 "total": "0",
950 "fraction_as_u128_dividend": "0",
951 "next_snapshot_index": 42,
952 "pending_estimate": "0"
953 },
954 "fees": "500000",
955 "borrow_asset_in_flight": "50000000",
956 "collateral_asset_in_flight": "0",
957 "liquidation_lock": "0"
958 }"#;
959
960 let position: BorrowPosition =
961 serde_json::from_str(json).expect("Failed to deserialize new format");
962 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
963 assert_eq!(
964 position.get_borrow_asset_principal(),
965 BorrowAssetAmount::new(50_000_000 + 100_000_000)
966 );
967 }
968
969 #[test]
970 fn test_borrow_position_deserialize_old_format_with_borrow_asset_fees() {
971 let json = r#"{
973 "started_at_block_timestamp_ms": "1699564800000",
974 "collateral_asset_deposit": "1000000000000000000000000",
975 "borrow_asset_principal": "100000000",
976 "borrow_asset_fees": {
977 "total": "0",
978 "fraction_as_u128_dividend": "0",
979 "next_snapshot_index": 42,
980 "pending_estimate": "0"
981 },
982 "fees": "500000",
983 "borrow_asset_in_flight": "0",
984 "collateral_asset_in_flight": "0",
985 "liquidation_lock": "0"
986 }"#;
987
988 let position: BorrowPosition =
989 serde_json::from_str(json).expect("Failed to deserialize old format");
990 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
991 assert_eq!(
992 position.get_borrow_asset_principal(),
993 BorrowAssetAmount::new(100_000_000)
994 );
995 }
996
997 #[test]
998 fn test_borrow_position_deserialize_mixed_old_new_format() {
999 let json = r#"{
1001 "started_at_block_timestamp_ms": "1699564800000",
1002 "collateral_asset_deposit": "1000000000000000000000000",
1003 "borrow_asset_principal": "100000000",
1004 "borrow_asset_fees": {
1005 "total": "0",
1006 "fraction_as_u128_dividend": "0",
1007 "next_snapshot_index": 42,
1008 "pending_estimate": "0"
1009 },
1010 "fees": "500000",
1011 "borrow_asset_in_flight": "0",
1012 "collateral_asset_in_flight": "0",
1013 "liquidation_lock": "0"
1014 }"#;
1015
1016 let position: BorrowPosition =
1017 serde_json::from_str(json).expect("Failed to deserialize mixed format");
1018 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
1019 assert_eq!(
1020 position.get_borrow_asset_principal(),
1021 BorrowAssetAmount::new(100_000_000)
1022 );
1023 assert_eq!(
1024 position.get_total_collateral_amount(),
1025 CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1026 );
1027 }
1028
1029 #[test]
1030 fn test_borrow_position_deserialize_defaults() {
1031 let json = r#"{
1033 "collateral_asset_deposit": "1000000000000000000000000",
1034 "borrow_asset_principal": "100000000",
1035 "interest": {
1036 "total": "0",
1037 "fraction_as_u128_dividend": "0",
1038 "next_snapshot_index": 42,
1039 "pending_estimate": "0"
1040 }
1041 }"#;
1042
1043 let position: BorrowPosition =
1044 serde_json::from_str(json).expect("Failed to deserialize with defaults");
1045 assert_eq!(position.started_at_block_timestamp_ms, None);
1046 assert_eq!(position.fees, BorrowAssetAmount::new(0));
1047 assert_eq!(
1048 position.get_total_collateral_amount(),
1049 CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1050 );
1051 }
1052
1053 #[test]
1058 #[should_panic(expected = "attempt to add with overflow")]
1059 fn liability_overflow_aborts_on_principal_plus_in_flight() {
1060 let mut position = BorrowPosition::new(0);
1061 position.borrow_asset_principal = BorrowAssetAmount::new(u128::MAX);
1062 position.borrow_asset_in_flight = BorrowAssetAmount::new(1);
1063 let _ = position.get_total_borrow_asset_liability();
1064 }
1065
1066 #[test]
1074 #[should_panic(expected = "arithmetic operation overflow")]
1075 fn liquidatable_collateral_denominator_underflow_aborts() {
1076 use templar_primitives::dec;
1077
1078 use crate::oracle::pyth::PythTimestamp;
1079
1080 let price = pyth::Price {
1085 price: 1.into(),
1086 conf: 0.into(),
1087 expo: 0,
1088 publish_time: PythTimestamp::from_secs(0),
1089 };
1090 let price_pair = PricePair::new(&price, 0, &price, 0).unwrap();
1091
1092 let mut position = BorrowPosition::new(0);
1093 position.collateral_asset_deposit = CollateralAssetAmount::new(102);
1094 position.borrow_asset_principal = BorrowAssetAmount::new(100);
1095
1096 let _ = position.liquidatable_collateral(&price_pair, dec!("1.05"), dec!("0.1"));
1097 }
1098}