1use std::ops::{Deref, DerefMut};
2
3use near_sdk::{json_types::U64, near, AccountId};
4
5use crate::{
6 accumulator::{AccumulationRecord, Accumulator},
7 asset::{BorrowAsset, BorrowAssetAmount, CollateralAssetAmount},
8 event::MarketEvent,
9 market::{Market, SnapshotProof},
10 number::Decimal,
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)]
186pub struct LiabilityReduction {
187 pub to_fees: BorrowAssetAmount,
188 pub to_interest: BorrowAssetAmount,
189 pub to_principal: BorrowAssetAmount,
190 pub remaining: BorrowAssetAmount,
191}
192
193#[must_use]
194#[derive(Debug, Clone)]
195#[near(serializers = [json, borsh])]
196pub struct Liquidation {
197 pub liquidated: CollateralAssetAmount,
198 pub refund: BorrowAssetAmount,
199}
200
201#[must_use]
202#[derive(Debug, Clone)]
203#[near(serializers = [json, borsh])]
204pub struct InitialBorrow {
205 pub amount: BorrowAssetAmount,
206 pub fees: BorrowAssetAmount,
207}
208
209pub mod error {
210 use thiserror::Error;
211
212 use crate::asset::{BorrowAssetAmount, CollateralAssetAmount};
213
214 #[derive(Error, Debug)]
215 pub enum LiquidationError {
216 #[error("Borrow position is not eligible for liquidation")]
217 Ineligible,
218 #[error("Attempt to liquidate more collateral than is currently eligible: {requested} requested > {available} available")]
219 ExcessiveLiquidation {
220 requested: CollateralAssetAmount,
221 available: CollateralAssetAmount,
222 },
223 #[error("Failed to calculate value of collateral")]
224 ValueCalculationFailure,
225 #[error("Liquidation offer too low: {offered} offered < {minimum_acceptable} minimum acceptable")]
226 OfferTooLow {
227 offered: BorrowAssetAmount,
228 minimum_acceptable: BorrowAssetAmount,
229 },
230 }
231
232 #[derive(Debug, Error)]
233 pub enum InitialBorrowError {
234 #[error("Insufficient borrow asset available")]
235 InsufficientBorrowAssetAvailable,
236 #[error("Fee calculation failed")]
237 FeeCalculationFailure,
238 #[error("Borrow position must be healthy after borrow")]
239 Undercollateralization,
240 #[error("New borrow position is outside of allowable range")]
241 OutsideAllowableRange,
242 }
243}
244
245pub struct BorrowPositionRef<M> {
246 market: M,
247 account_id: AccountId,
248 position: BorrowPosition,
249}
250
251impl<M> BorrowPositionRef<M> {
252 pub fn new(market: M, account_id: AccountId, position: BorrowPosition) -> Self {
253 Self {
254 market,
255 account_id,
256 position,
257 }
258 }
259
260 pub fn account_id(&self) -> &AccountId {
261 &self.account_id
262 }
263
264 pub fn inner(&self) -> &BorrowPosition {
265 &self.position
266 }
267}
268
269impl<M: Deref<Target = Market>> BorrowPositionRef<M> {
270 pub fn calculate_interest(&self, snapshot_limit: u32) -> AccumulationRecord<BorrowAsset> {
271 let principal: Decimal = self.position.get_borrow_asset_principal().into();
272 let mut next_snapshot_index = self.position.interest.get_next_snapshot_index();
273
274 let mut accumulated = Decimal::ZERO;
275 #[allow(clippy::unwrap_used, reason = "1 finalized snapshot guaranteed")]
276 let mut prev_end_timestamp_ms = self
277 .market
278 .finalized_snapshots
279 .get(next_snapshot_index.checked_sub(1).unwrap())
280 .unwrap()
281 .end_timestamp_ms
282 .0;
283
284 #[allow(
285 clippy::cast_possible_truncation,
286 reason = "Assume # of snapshots will never be > u32::MAX"
287 )]
288 for (i, snapshot) in self
289 .market
290 .finalized_snapshots
291 .iter()
292 .enumerate()
293 .skip(next_snapshot_index as usize)
294 .take(snapshot_limit as usize)
295 {
296 let duration_ms = Decimal::from(
297 snapshot
298 .end_timestamp_ms
299 .0
300 .checked_sub(prev_end_timestamp_ms)
301 .unwrap_or_else(|| {
302 crate::panic_with_message(&format!(
303 "Invariant violation: Snapshot timestamp decrease at time chunk #{}.",
304 u64::from(snapshot.time_chunk.0),
305 ))
306 }),
307 );
308 accumulated += principal * snapshot.interest_rate * duration_ms * YEAR_PER_MS;
309
310 prev_end_timestamp_ms = snapshot.end_timestamp_ms.0;
311 next_snapshot_index = i as u32 + 1;
312 }
313
314 AccumulationRecord {
315 #[allow(
316 clippy::unwrap_used,
317 reason = "Assume accumulated interest will never exceed u128::MAX"
318 )]
319 amount: accumulated.to_u128_floor().unwrap().into(),
320 fraction_as_u128_dividend: accumulated.fractional_part_as_u128_dividend(),
321 next_snapshot_index,
322 }
323 }
324
325 pub fn status(&self, price_pair: &PricePair, block_timestamp_ms: u64) -> BorrowStatus {
326 let collateralization_ratio = self.position.collateralization_ratio(price_pair);
327 self.market.configuration.borrow_status(
328 collateralization_ratio,
329 self.position.started_at_block_timestamp_ms,
330 block_timestamp_ms,
331 )
332 }
333
334 pub fn within_allowable_borrow_range(&self) -> bool {
335 self.market
336 .configuration
337 .borrow_range
338 .contains(self.position.get_borrow_asset_principal())
339 }
340
341 pub fn liquidatable_collateral(&self, price_pair: &PricePair) -> CollateralAssetAmount {
342 self.position.liquidatable_collateral(
343 price_pair,
344 self.market.configuration.borrow_mcr_maintenance,
345 self.market.configuration.liquidation_maximum_spread,
346 )
347 }
348}
349
350pub struct BorrowPositionGuard<'a>(BorrowPositionRef<&'a mut Market>);
351
352impl Drop for BorrowPositionGuard<'_> {
353 fn drop(&mut self) {
354 self.0
355 .market
356 .borrow_positions
357 .insert(&self.0.account_id, &self.0.position);
358 }
359}
360
361impl<'a> Deref for BorrowPositionGuard<'a> {
362 type Target = BorrowPositionRef<&'a mut Market>;
363
364 fn deref(&self) -> &Self::Target {
365 &self.0
366 }
367}
368
369impl DerefMut for BorrowPositionGuard<'_> {
370 fn deref_mut(&mut self) -> &mut Self::Target {
371 &mut self.0
372 }
373}
374
375impl<'a> BorrowPositionGuard<'a> {
376 pub fn new(market: &'a mut Market, account_id: AccountId, position: BorrowPosition) -> Self {
377 Self(BorrowPositionRef::new(market, account_id, position))
378 }
379
380 pub(crate) fn reduce_borrow_asset_liability(
381 &mut self,
382 _proof: InterestAccumulationProof,
383 mut amount: BorrowAssetAmount,
384 ) -> LiabilityReduction {
385 let to_fees = self.position.fees.min(amount);
387 amount = amount.unwrap_sub(to_fees, "Invariant violation: min() precludes underflow");
388 self.position.fees = self
389 .position
390 .fees
391 .unwrap_sub(to_fees, "Invariant violation: min() precludes underflow");
392
393 let to_interest = self.position.interest.get_total().min(amount);
394 amount = amount.unwrap_sub(
395 to_interest,
396 "Invariant violation: min() precludes underflow",
397 );
398 self.position.interest.remove(to_interest);
399
400 self.market.borrow_asset_paid_to_fees += to_fees + to_interest;
401
402 let to_principal = {
403 let minimum_amount = u128::from(self.market.configuration.borrow_range.minimum);
404 let amount_remaining =
405 u128::from(self.position.borrow_asset_principal).saturating_sub(u128::from(amount));
406 if amount_remaining > 0 && amount_remaining < minimum_amount {
407 u128::from(self.position.borrow_asset_principal)
408 .saturating_sub(minimum_amount)
409 .into()
410 } else {
411 self.position.borrow_asset_principal.min(amount)
412 }
413 };
414 amount = amount.unwrap_sub(
415 to_principal,
416 "Invariant violation: amount_to_principal > amount",
417 );
418 self.position.borrow_asset_principal = self.position.borrow_asset_principal.unwrap_sub(
419 to_principal,
420 "Invariant violation: amount_to_principal > borrow_asset_principal",
421 );
422 self.market.borrow_asset_borrowed = self.market.borrow_asset_borrowed.unwrap_sub(
423 to_principal,
424 "Invariant violation: amount_to_principal > market.borrow_asset_borrowed",
425 );
426
427 if self.position.borrow_asset_principal.is_zero() {
428 self.position.started_at_block_timestamp_ms = None;
430 }
431
432 LiabilityReduction {
433 to_fees,
434 to_interest,
435 to_principal,
436 remaining: amount,
437 }
438 }
439
440 pub fn record_collateral_asset_deposit(
441 &mut self,
442 _proof: InterestAccumulationProof,
443 amount: CollateralAssetAmount,
444 ) {
445 self.position.collateral_asset_deposit += amount;
446 self.market.collateral_asset_deposited += amount;
447
448 MarketEvent::CollateralDeposited {
449 account_id: self.account_id.clone(),
450 collateral_asset_amount: amount,
451 }
452 .emit();
453 }
454
455 pub fn record_collateral_asset_withdrawal_initial(
456 &mut self,
457 _proof: InterestAccumulationProof,
458 amount: CollateralAssetAmount,
459 ) {
460 self.position.collateral_asset_in_flight += amount;
461 self.position.collateral_asset_deposit -= amount;
462 self.market.collateral_asset_deposited -= amount;
463 }
464
465 pub fn record_collateral_asset_withdrawal_final(
466 &mut self,
467 _proof: InterestAccumulationProof,
468 amount: CollateralAssetAmount,
469 success: bool,
470 ) {
471 self.position.collateral_asset_in_flight =
472 self.position.collateral_asset_in_flight.unwrap_sub(
473 amount,
474 "Invariant violation: attempt to unlock more than locked as in-flight",
475 );
476
477 if success {
478 MarketEvent::CollateralWithdrawn {
479 account_id: self.account_id.clone(),
480 collateral_asset_amount: amount,
481 }
482 .emit();
483 } else {
484 self.position.collateral_asset_deposit += amount;
485 self.market.collateral_asset_deposited += amount;
486 }
487 }
488
489 pub(crate) fn record_collateral_asset_withdrawal(
490 &mut self,
491 _proof: InterestAccumulationProof,
492 amount: CollateralAssetAmount,
493 ) {
494 self.position.collateral_asset_deposit -= amount;
495 self.market.collateral_asset_deposited -= amount;
496 }
497
498 pub fn record_borrow_initial(
503 &mut self,
504 _proof: SnapshotProof,
505 _interest: InterestAccumulationProof,
506 amount: BorrowAssetAmount,
507 price_pair: &PricePair,
508 block_timestamp_ms: u64,
509 ) -> Result<InitialBorrow, error::InitialBorrowError> {
510 let available_to_borrow = self.market.get_borrow_asset_available_to_borrow();
512 if amount > available_to_borrow {
513 return Err(error::InitialBorrowError::InsufficientBorrowAssetAvailable);
514 }
515
516 let origination_fee = self
517 .market
518 .configuration
519 .borrow_origination_fee
520 .of(amount)
521 .ok_or(error::InitialBorrowError::FeeCalculationFailure)?;
522
523 let single_snapshot_fee = self
527 .market
528 .single_snapshot_fee(amount)
529 .ok_or(error::InitialBorrowError::FeeCalculationFailure)?;
530
531 let mut fees = origination_fee;
532 fees = fees
533 .checked_add(single_snapshot_fee)
534 .ok_or(error::InitialBorrowError::FeeCalculationFailure)?;
535
536 self.market.borrow_asset_borrowed_in_flight += amount;
537 self.position.borrow_asset_in_flight += amount;
538 self.position.fees += fees;
539
540 if !self.status(price_pair, block_timestamp_ms).is_healthy() {
541 self.market.borrow_asset_borrowed_in_flight -= amount;
542 self.position.borrow_asset_in_flight -= amount;
543 self.position.fees -= fees;
544 return Err(error::InitialBorrowError::Undercollateralization);
545 }
546
547 if !self.within_allowable_borrow_range() {
548 self.market.borrow_asset_borrowed_in_flight -= amount;
549 self.position.borrow_asset_in_flight -= amount;
550 self.position.fees -= fees;
551 return Err(error::InitialBorrowError::OutsideAllowableRange);
552 }
553
554 self.market.record_borrow_asset_yield_distribution(fees);
555 self.market.borrow_asset_balance -= amount;
556
557 Ok(InitialBorrow { amount, fees })
558 }
559
560 pub fn record_borrow_final(
561 &mut self,
562 _snapshot: SnapshotProof,
563 interest: InterestAccumulationProof,
564 borrow: &InitialBorrow,
565 success: bool,
566 block_timestamp_ms: u64,
567 ) {
568 self.market.borrow_asset_borrowed_in_flight -= borrow.amount;
571 self.position.borrow_asset_in_flight -= borrow.amount;
572
573 if success {
574 self.position.increase_borrow_asset_principal(
579 interest,
580 borrow.amount,
581 block_timestamp_ms,
582 );
583
584 self.market.borrow_asset_borrowed += borrow.amount;
585
586 MarketEvent::BorrowWithdrawn {
587 account_id: self.account_id.clone(),
588 borrow_asset_amount: borrow.amount,
589 }
590 .emit();
591 } else {
592 self.market.borrow_asset_balance += borrow.amount;
615 }
616 }
617
618 pub fn record_repay(
622 &mut self,
623 proof: InterestAccumulationProof,
624 amount: BorrowAssetAmount,
625 ) -> BorrowAssetAmount {
626 self.market.borrow_asset_balance += amount;
627 let liability_reduction = self.reduce_borrow_asset_liability(proof, amount);
628
629 MarketEvent::BorrowRepaid {
630 account_id: self.account_id.clone(),
631 borrow_asset_fees_repaid: liability_reduction.to_fees,
632 borrow_asset_principal_repaid: liability_reduction.to_principal,
633 borrow_asset_principal_remaining: self.position.get_borrow_asset_principal(),
634 }
635 .emit();
636
637 liability_reduction.remaining
638 }
639
640 pub fn accumulate_interest_partial(&mut self, snapshot_limit: u32) {
641 let accumulation_record = self.calculate_interest(snapshot_limit);
642
643 if !accumulation_record.amount.is_zero() {
644 MarketEvent::InterestAccumulated {
645 account_id: self.account_id.clone(),
646 borrow_asset_amount: accumulation_record.amount,
647 }
648 .emit();
649 }
650
651 self.position.interest.accumulate(accumulation_record);
652 }
653
654 pub fn accumulate_interest(&mut self) -> InterestAccumulationProof {
655 self.accumulate_interest_partial(u32::MAX);
656 InterestAccumulationProof(())
657 }
658
659 pub fn record_liquidation(
667 &mut self,
668 proof: InterestAccumulationProof,
669 liquidator_id: AccountId,
670 liquidator_sent: BorrowAssetAmount,
671 liquidator_request: Option<CollateralAssetAmount>,
672 price_pair: &PricePair,
673 block_timestamp_ms: u64,
674 ) -> Result<Liquidation, error::LiquidationError> {
675 let BorrowStatus::Liquidation(reason) = self.status(price_pair, block_timestamp_ms) else {
676 return Err(error::LiquidationError::Ineligible);
677 };
678
679 let liquidatable_collateral = match reason {
680 LiquidationReason::Undercollateralization => self.liquidatable_collateral(price_pair),
681 LiquidationReason::Expiration => self.position.collateral_asset_deposit,
682 };
683
684 let liquidator_request = liquidator_request.unwrap_or(liquidatable_collateral);
688
689 if liquidator_request > liquidatable_collateral {
690 return Err(error::LiquidationError::ExcessiveLiquidation {
691 requested: liquidator_request,
692 available: liquidatable_collateral,
693 });
694 }
695
696 let collateral_value = price_pair.convert(liquidator_request);
697
698 let maximum_acceptable: BorrowAssetAmount = collateral_value
699 .to_u128_ceil()
700 .ok_or(error::LiquidationError::ValueCalculationFailure)?
701 .max(1)
702 .into();
703 #[allow(
704 clippy::unwrap_used,
705 reason = "Previous line guarantees this will not panic"
706 )]
707 let minimum_acceptable: BorrowAssetAmount = (collateral_value
708 * (Decimal::ONE - self.market.configuration.liquidation_maximum_spread))
709 .to_u128_ceil()
710 .unwrap()
711 .max(1)
712 .into();
713
714 if liquidator_sent < minimum_acceptable {
715 return Err(error::LiquidationError::OfferTooLow {
716 offered: liquidator_sent,
717 minimum_acceptable,
718 });
719 }
720
721 let (refund, recovered) = if liquidator_sent > maximum_acceptable {
722 (liquidator_sent - maximum_acceptable, maximum_acceptable)
723 } else {
724 (BorrowAssetAmount::zero(), liquidator_sent)
725 };
726
727 self.record_collateral_asset_withdrawal(proof, liquidator_request);
728
729 let liability_reduction = self.reduce_borrow_asset_liability(proof, recovered);
730 self.market
731 .record_borrow_asset_yield_distribution(liability_reduction.remaining);
732
733 self.market.borrow_asset_balance += recovered;
734
735 MarketEvent::Liquidation {
736 liquidator_id,
737 account_id: self.account_id.clone(),
738 borrow_asset_recovered: recovered,
739 collateral_asset_liquidated: liquidator_request,
740 }
741 .emit();
742
743 Ok(Liquidation {
744 liquidated: liquidator_request,
745 refund,
746 })
747 }
748}
749
750#[cfg(test)]
751mod tests {
752 use near_sdk::{env, serde_json, test_utils::VMContextBuilder, testing_env};
753 use rstest::rstest;
754
755 use crate::{
756 asset::FungibleAsset,
757 dec,
758 fee::{Fee, TimeBasedFee},
759 interest_rate_strategy::InterestRateStrategy,
760 market::{MarketConfiguration, PriceOracleConfiguration, YieldWeights},
761 oracle::pyth::{self, PriceIdentifier},
762 time_chunk::TimeChunkConfiguration,
763 };
764
765 use super::*;
766
767 #[rstest]
768 #[test]
769 fn liquidatable_collateral(
770 #[values("1.2", "1.25", "1.5", "2")] mcr: Decimal,
771 #[values(11, 1000, 1005, 999_999)] collateral_price: i64,
772 #[values(1000, 1005, 999_999)] borrow_price: i64,
773 #[values(0, 10)] conf: u64,
774 ) {
775 let c = VMContextBuilder::new()
776 .block_timestamp(1_000_000_000_000_000)
777 .build();
778 testing_env!(c.clone());
779
780 let configuration = MarketConfiguration {
781 time_chunk_configuration: TimeChunkConfiguration::new(600_000),
782 borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
783 collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
784 price_oracle_configuration: PriceOracleConfiguration {
785 account_id: "pyth-oracle.near".parse().unwrap(),
786 collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
787 collateral_asset_decimals: 24,
788 borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
789 borrow_asset_decimals: 24,
790 price_maximum_age_s: 60,
791 },
792 borrow_mcr_maintenance: mcr,
793 borrow_mcr_liquidation: mcr,
794 borrow_asset_maximum_usage_ratio: dec!("0.99"),
795 borrow_origination_fee: Fee::zero(),
796 borrow_interest_rate_strategy: InterestRateStrategy::zero(),
797 borrow_maximum_duration_ms: None,
798 borrow_range: (1, None).try_into().unwrap(),
799 supply_range: (1, None).try_into().unwrap(),
800 supply_withdrawal_range: (1, None).try_into().unwrap(),
801 supply_withdrawal_fee: TimeBasedFee::zero(),
802 yield_weights: YieldWeights::new_with_supply_weight(9)
803 .with_static("revenue.tmplr.near".parse().unwrap(), 1),
804 protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
805 liquidation_maximum_spread: dec!("0.05"),
806 };
807
808 let mut market = Market::new(b"m", configuration.clone());
809 market.borrow_asset_deposited_active += BorrowAssetAmount::new(100_000_000_000);
810 market.borrow_asset_balance += BorrowAssetAmount::new(100_000_000_000);
811 let snapshot_proof = market.snapshot();
812
813 let mut position = BorrowPositionGuard(BorrowPositionRef {
814 market: &mut market,
815 account_id: "borrower".parse().unwrap(),
816 position: BorrowPosition::new(1),
817 });
818
819 let interest_proof = position.accumulate_interest();
820 position.record_collateral_asset_deposit(
821 interest_proof,
822 CollateralAssetAmount::new(100_000_000),
823 );
824 let initial_price_pair = PricePair::new(
825 &pyth::Price {
826 price: 5.into(),
827 conf: 0.into(),
828 expo: 24,
829 publish_time: 10,
830 },
831 24,
832 &pyth::Price {
833 price: 1.into(),
834 conf: 0.into(),
835 expo: 24,
836 publish_time: 10,
837 },
838 24,
839 )
840 .unwrap();
841 assert_eq!(
842 position.liquidatable_collateral(&initial_price_pair),
843 CollateralAssetAmount::zero(),
844 );
845 let initial_borrow = position
846 .record_borrow_initial(
847 snapshot_proof,
848 interest_proof,
849 BorrowAssetAmount::new(85_000_000),
850 &initial_price_pair,
851 env::block_timestamp_ms(),
852 )
853 .unwrap();
854 position.record_borrow_final(
855 snapshot_proof,
856 interest_proof,
857 &initial_borrow,
858 true,
859 env::block_timestamp_ms(),
860 );
861 let price_pair = PricePair::new(
862 &pyth::Price {
863 price: collateral_price.into(),
864 conf: conf.into(),
865 expo: 24,
866 publish_time: 10,
867 },
868 24,
869 &pyth::Price {
870 price: borrow_price.into(),
871 conf: conf.into(),
872 expo: 24,
873 publish_time: 10,
874 },
875 24,
876 )
877 .unwrap();
878 let starting_cr = position.inner().collateralization_ratio(&price_pair);
879 eprintln!("Starting collateralization ratio: {starting_cr:?}");
880 let liquidatable_collateral = position.liquidatable_collateral(&price_pair);
881
882 let minimum_acceptable = configuration
883 .minimum_acceptable_liquidation_amount(liquidatable_collateral, &price_pair)
884 .unwrap();
885
886 eprintln!("Liquidatable collateral: {liquidatable_collateral}");
887 eprintln!("Minimum acceptable: {minimum_acceptable}");
888
889 match collateral_price.ilog10().cmp(&borrow_price.ilog10()) {
890 std::cmp::Ordering::Less => {
891 assert_eq!(
893 liquidatable_collateral,
894 CollateralAssetAmount::new(100_000_000),
895 "All collateral should be eligible for liquidation"
896 );
897 }
898 std::cmp::Ordering::Equal => {
899 let _liquidation = position
902 .record_liquidation(
903 interest_proof,
904 "liquidator".parse().unwrap(),
905 minimum_acceptable,
906 Some(liquidatable_collateral),
907 &price_pair,
908 env::block_timestamp_ms(),
909 )
910 .unwrap();
911
912 let finishing_cr = position
913 .inner()
914 .collateralization_ratio(&price_pair)
915 .unwrap();
916 eprintln!("Finishing collateralization ratio: {finishing_cr}");
917 eprintln!("Target MCR: {mcr}");
918
919 assert!(finishing_cr >= mcr);
920 let delta = finishing_cr.abs_diff(mcr);
921 assert!(delta < Decimal::ONE.mul_pow10(-4).unwrap());
922 }
923 std::cmp::Ordering::Greater => {
924 assert_eq!(
927 liquidatable_collateral,
928 CollateralAssetAmount::zero(),
929 "No collateral should be liquidatable"
930 );
931 }
932 }
933 }
934
935 #[test]
936 fn test_borrow_position_deserialize_new_format() {
937 let json = r#"{
939 "started_at_block_timestamp_ms": "1699564800000",
940 "collateral_asset_deposit": "1000000000000000000000000",
941 "borrow_asset_principal": "100000000",
942 "interest": {
943 "total": "0",
944 "fraction_as_u128_dividend": "0",
945 "next_snapshot_index": 42,
946 "pending_estimate": "0"
947 },
948 "fees": "500000",
949 "borrow_asset_in_flight": "50000000",
950 "collateral_asset_in_flight": "0",
951 "liquidation_lock": "0"
952 }"#;
953
954 let position: BorrowPosition =
955 serde_json::from_str(json).expect("Failed to deserialize new format");
956 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
957 assert_eq!(
958 position.get_borrow_asset_principal(),
959 BorrowAssetAmount::new(50_000_000 + 100_000_000)
960 );
961 }
962
963 #[test]
964 fn test_borrow_position_deserialize_old_format_with_borrow_asset_fees() {
965 let json = r#"{
967 "started_at_block_timestamp_ms": "1699564800000",
968 "collateral_asset_deposit": "1000000000000000000000000",
969 "borrow_asset_principal": "100000000",
970 "borrow_asset_fees": {
971 "total": "0",
972 "fraction_as_u128_dividend": "0",
973 "next_snapshot_index": 42,
974 "pending_estimate": "0"
975 },
976 "fees": "500000",
977 "borrow_asset_in_flight": "0",
978 "collateral_asset_in_flight": "0",
979 "liquidation_lock": "0"
980 }"#;
981
982 let position: BorrowPosition =
983 serde_json::from_str(json).expect("Failed to deserialize old format");
984 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
985 assert_eq!(
986 position.get_borrow_asset_principal(),
987 BorrowAssetAmount::new(100_000_000)
988 );
989 }
990
991 #[test]
992 fn test_borrow_position_deserialize_mixed_old_new_format() {
993 let json = r#"{
995 "started_at_block_timestamp_ms": "1699564800000",
996 "collateral_asset_deposit": "1000000000000000000000000",
997 "borrow_asset_principal": "100000000",
998 "borrow_asset_fees": {
999 "total": "0",
1000 "fraction_as_u128_dividend": "0",
1001 "next_snapshot_index": 42,
1002 "pending_estimate": "0"
1003 },
1004 "fees": "500000",
1005 "borrow_asset_in_flight": "0",
1006 "collateral_asset_in_flight": "0",
1007 "liquidation_lock": "0"
1008 }"#;
1009
1010 let position: BorrowPosition =
1011 serde_json::from_str(json).expect("Failed to deserialize mixed format");
1012 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
1013 assert_eq!(
1014 position.get_borrow_asset_principal(),
1015 BorrowAssetAmount::new(100_000_000)
1016 );
1017 assert_eq!(
1018 position.get_total_collateral_amount(),
1019 CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1020 );
1021 }
1022
1023 #[test]
1024 fn test_borrow_position_deserialize_defaults() {
1025 let json = r#"{
1027 "collateral_asset_deposit": "1000000000000000000000000",
1028 "borrow_asset_principal": "100000000",
1029 "interest": {
1030 "total": "0",
1031 "fraction_as_u128_dividend": "0",
1032 "next_snapshot_index": 42,
1033 "pending_estimate": "0"
1034 }
1035 }"#;
1036
1037 let position: BorrowPosition =
1038 serde_json::from_str(json).expect("Failed to deserialize with defaults");
1039 assert_eq!(position.started_at_block_timestamp_ms, None);
1040 assert_eq!(position.fees, BorrowAssetAmount::new(0));
1041 assert_eq!(
1042 position.get_total_collateral_amount(),
1043 CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1044 );
1045 }
1046}