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