1use std::ops::{Deref, DerefMut};
2
3use near_sdk::{env, 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 #[serde(default)]
73 pub liquidation_lock: CollateralAssetAmount,
74}
75
76impl BorrowPosition {
77 pub fn new(current_snapshot_index: u32) -> Self {
78 Self {
79 started_at_block_timestamp_ms: None,
80 collateral_asset_deposit: 0.into(),
81 borrow_asset_principal: 0.into(),
82 interest: Accumulator::new(current_snapshot_index),
87 fees: 0.into(),
88 borrow_asset_in_flight: 0.into(),
89 collateral_asset_in_flight: 0.into(),
90 liquidation_lock: 0.into(),
91 }
92 }
93
94 pub fn get_borrow_asset_principal(&self) -> BorrowAssetAmount {
95 self.borrow_asset_principal + self.borrow_asset_in_flight
96 }
97
98 pub fn get_total_borrow_asset_liability(&self) -> BorrowAssetAmount {
99 self.borrow_asset_principal
100 + self.borrow_asset_in_flight
101 + self.interest.get_total()
102 + self.fees
103 }
104
105 pub fn get_total_collateral_amount(&self) -> CollateralAssetAmount {
106 self.collateral_asset_deposit + self.liquidation_lock
107 }
108
109 pub fn exists(&self) -> bool {
110 !self.get_total_collateral_amount().is_zero()
111 || !self.get_total_borrow_asset_liability().is_zero()
112 || !self.collateral_asset_in_flight.is_zero()
113 }
114
115 pub fn collateralization_ratio(&self, price_pair: &PricePair) -> Option<Decimal> {
117 let borrow_liability = self.get_total_borrow_asset_liability();
118 if borrow_liability.is_zero() {
119 return None;
120 }
121
122 let collateral_valuation =
123 Valuation::pessimistic(self.get_total_collateral_amount(), &price_pair.collateral);
124 let borrow_valuation = Valuation::optimistic(borrow_liability, &price_pair.borrow);
125
126 collateral_valuation.ratio(borrow_valuation)
127 }
128
129 pub(crate) fn increase_borrow_asset_principal(
131 &mut self,
132 _proof: InterestAccumulationProof,
133 amount: BorrowAssetAmount,
134 block_timestamp_ms: u64,
135 ) {
136 if self.started_at_block_timestamp_ms.is_none()
137 || self.get_total_borrow_asset_liability().is_zero()
138 {
139 self.started_at_block_timestamp_ms = Some(block_timestamp_ms.into());
140 }
141 self.borrow_asset_principal += amount;
142 }
143
144 pub fn liquidatable_collateral(
145 &self,
146 price_pair: &PricePair,
147 mcr: Decimal,
148 liquidator_spread: Decimal,
149 ) -> CollateralAssetAmount {
150 let liability = self.get_total_borrow_asset_liability();
151 if liability.is_zero() {
152 return CollateralAssetAmount::zero();
153 }
154
155 let valuation_liability = price_pair.valuation(liability);
156 let collateral = self.get_total_collateral_amount();
157 let valuation_collateral = price_pair.valuation(collateral);
158
159 let Some(cr) = valuation_collateral.ratio(valuation_liability) else {
160 return CollateralAssetAmount::zero();
162 };
163
164 if cr <= Decimal::ONE {
165 return collateral.saturating_sub(self.liquidation_lock);
167 }
168
169 if cr >= mcr {
170 return CollateralAssetAmount::zero();
172 }
173
174 let collateral_dec = Decimal::from(collateral);
175 let discount = Decimal::ONE - liquidator_spread;
176
177 let liquidatable_amount = (mcr * price_pair.convert(liability) - collateral_dec)
178 / (mcr * discount - Decimal::ONE);
179
180 liquidatable_amount
181 .to_u128_ceil()
182 .map_or(collateral, CollateralAssetAmount::new)
183 .min(collateral)
184 .saturating_sub(self.liquidation_lock)
185 }
186}
187
188#[must_use]
189#[derive(Debug, Clone)]
190pub struct LiabilityReduction {
191 pub to_fees: BorrowAssetAmount,
192 pub to_interest: BorrowAssetAmount,
193 pub to_principal: BorrowAssetAmount,
194 pub remaining: BorrowAssetAmount,
195}
196
197#[must_use]
198#[derive(Debug, Clone)]
199#[near(serializers = [json, borsh])]
200pub struct InitialLiquidation {
201 pub liquidated: CollateralAssetAmount,
202 pub recovered: BorrowAssetAmount,
203 pub refund: BorrowAssetAmount,
204}
205
206#[must_use]
207#[derive(Debug, Clone)]
208#[near(serializers = [json, borsh])]
209pub struct InitialBorrow {
210 pub amount: BorrowAssetAmount,
211 pub fees: BorrowAssetAmount,
212}
213
214pub mod error {
215 use thiserror::Error;
216
217 use crate::asset::{BorrowAssetAmount, CollateralAssetAmount};
218
219 #[derive(Error, Debug)]
220 #[error("This position is currently being liquidated.")]
221 pub struct LiquidationLockError;
222
223 #[derive(Error, Debug)]
224 pub enum InitialLiquidationError {
225 #[error("Borrow position is not eligible for liquidation")]
226 Ineligible,
227 #[error("Attempt to liquidate more collateral than is currently eligible: {requested} requested > {available} available")]
228 ExcessiveLiquidation {
229 requested: CollateralAssetAmount,
230 available: CollateralAssetAmount,
231 },
232 #[error("Failed to calculate value of collateral")]
233 ValueCalculationFailure,
234 #[error("Liquidation offer too low: {offered} offered < {minimum_acceptable} minimum acceptable")]
235 OfferTooLow {
236 offered: BorrowAssetAmount,
237 minimum_acceptable: BorrowAssetAmount,
238 },
239 }
240
241 #[derive(Debug, Error)]
242 pub enum InitialBorrowError {
243 #[error("Insufficient borrow asset available")]
244 InsufficientBorrowAssetAvailable,
245 #[error("Fee calculation failed")]
246 FeeCalculationFailure,
247 #[error("Borrow position must be healthy after borrow")]
248 Undercollateralization,
249 #[error("New borrow position is outside of allowable range")]
250 OutsideAllowableRange,
251 }
252}
253
254pub struct BorrowPositionRef<M> {
255 market: M,
256 account_id: AccountId,
257 position: BorrowPosition,
258}
259
260impl<M> BorrowPositionRef<M> {
261 pub fn new(market: M, account_id: AccountId, position: BorrowPosition) -> Self {
262 Self {
263 market,
264 account_id,
265 position,
266 }
267 }
268
269 pub fn account_id(&self) -> &AccountId {
270 &self.account_id
271 }
272
273 pub fn inner(&self) -> &BorrowPosition {
274 &self.position
275 }
276}
277
278impl<M: Deref<Target = Market>> BorrowPositionRef<M> {
279 pub fn estimate_current_snapshot_interest(&self) -> BorrowAssetAmount {
280 let prev_end_timestamp_ms = self.market.get_last_finalized_snapshot().end_timestamp_ms.0;
281 let interest_in_current_snapshot = self.market.interest_rate()
282 * (env::block_timestamp_ms().saturating_sub(prev_end_timestamp_ms))
283 * Decimal::from(self.position.get_borrow_asset_principal())
284 * YEAR_PER_MS;
285 #[allow(clippy::unwrap_used, reason = "Interest rate guaranteed <= APY_LIMIT")]
286 interest_in_current_snapshot.to_u128_ceil().unwrap().into()
287 }
288
289 pub fn with_pending_interest(&mut self) {
290 let pending_estimate = self.calculate_interest(u32::MAX).get_amount()
291 + self.estimate_current_snapshot_interest();
292
293 self.position.interest.pending_estimate = pending_estimate;
294 }
295
296 pub(crate) fn calculate_interest(
297 &self,
298 snapshot_limit: u32,
299 ) -> AccumulationRecord<BorrowAsset> {
300 let principal: Decimal = self.position.get_borrow_asset_principal().into();
301 let mut next_snapshot_index = self.position.interest.get_next_snapshot_index();
302
303 let mut accumulated = Decimal::ZERO;
304 #[allow(clippy::unwrap_used, reason = "1 finalized snapshot guaranteed")]
305 let mut prev_end_timestamp_ms = self
306 .market
307 .finalized_snapshots
308 .get(next_snapshot_index.checked_sub(1).unwrap())
309 .unwrap()
310 .end_timestamp_ms
311 .0;
312
313 #[allow(
314 clippy::cast_possible_truncation,
315 reason = "Assume # of snapshots will never be > u32::MAX"
316 )]
317 for (i, snapshot) in self
318 .market
319 .finalized_snapshots
320 .iter()
321 .enumerate()
322 .skip(next_snapshot_index as usize)
323 .take(snapshot_limit as usize)
324 {
325 let duration_ms = Decimal::from(
326 snapshot
327 .end_timestamp_ms
328 .0
329 .checked_sub(prev_end_timestamp_ms)
330 .unwrap_or_else(|| {
331 crate::panic_with_message(&format!(
332 "Invariant violation: Snapshot timestamp decrease at time chunk #{}.",
333 u64::from(snapshot.time_chunk.0),
334 ))
335 }),
336 );
337 accumulated += principal * snapshot.interest_rate * duration_ms * YEAR_PER_MS;
338
339 prev_end_timestamp_ms = snapshot.end_timestamp_ms.0;
340 next_snapshot_index = i as u32 + 1;
341 }
342
343 AccumulationRecord {
344 #[allow(
345 clippy::unwrap_used,
346 reason = "Assume accumulated interest will never exceed u128::MAX"
347 )]
348 amount: accumulated.to_u128_floor().unwrap().into(),
349 fraction_as_u128_dividend: accumulated.fractional_part_as_u128_dividend(),
350 next_snapshot_index,
351 }
352 }
353
354 pub fn status(&self, price_pair: &PricePair, block_timestamp_ms: u64) -> BorrowStatus {
355 let collateralization_ratio = self.position.collateralization_ratio(price_pair);
356 self.market.configuration.borrow_status(
357 collateralization_ratio,
358 self.position.started_at_block_timestamp_ms,
359 block_timestamp_ms,
360 )
361 }
362
363 pub fn within_allowable_borrow_range(&self) -> bool {
364 self.market
365 .configuration
366 .borrow_range
367 .contains(self.position.get_borrow_asset_principal())
368 }
369
370 pub fn liquidatable_collateral(&self, price_pair: &PricePair) -> CollateralAssetAmount {
371 self.position.liquidatable_collateral(
372 price_pair,
373 self.market.configuration.borrow_mcr_maintenance,
374 self.market.configuration.liquidation_maximum_spread,
375 )
376 }
377}
378
379pub struct BorrowPositionGuard<'a>(BorrowPositionRef<&'a mut Market>);
380
381impl Drop for BorrowPositionGuard<'_> {
382 fn drop(&mut self) {
383 self.0
384 .market
385 .borrow_positions
386 .insert(&self.0.account_id, &self.0.position);
387 }
388}
389
390impl<'a> Deref for BorrowPositionGuard<'a> {
391 type Target = BorrowPositionRef<&'a mut Market>;
392
393 fn deref(&self) -> &Self::Target {
394 &self.0
395 }
396}
397
398impl DerefMut for BorrowPositionGuard<'_> {
399 fn deref_mut(&mut self) -> &mut Self::Target {
400 &mut self.0
401 }
402}
403
404impl<'a> BorrowPositionGuard<'a> {
405 pub fn new(market: &'a mut Market, account_id: AccountId, position: BorrowPosition) -> Self {
406 Self(BorrowPositionRef::new(market, account_id, position))
407 }
408
409 pub(crate) fn reduce_borrow_asset_liability(
410 &mut self,
411 _proof: InterestAccumulationProof,
412 mut amount: BorrowAssetAmount,
413 ) -> LiabilityReduction {
414 let to_fees = self.position.fees.min(amount);
416 amount = amount.unwrap_sub(to_fees, "Invariant violation: min() precludes underflow");
417 self.position.fees = self
418 .position
419 .fees
420 .unwrap_sub(to_fees, "Invariant violation: min() precludes underflow");
421
422 let to_interest = self.position.interest.get_total().min(amount);
423 amount = amount.unwrap_sub(
424 to_interest,
425 "Invariant violation: min() precludes underflow",
426 );
427 self.position.interest.remove(to_interest);
428
429 let to_principal = {
430 let minimum_amount = u128::from(self.market.configuration.borrow_range.minimum);
431 let amount_remaining =
432 u128::from(self.position.borrow_asset_principal).saturating_sub(u128::from(amount));
433 if amount_remaining > 0 && amount_remaining < minimum_amount {
434 u128::from(self.position.borrow_asset_principal)
435 .saturating_sub(minimum_amount)
436 .into()
437 } else {
438 self.position.borrow_asset_principal.min(amount)
439 }
440 };
441 amount = amount.unwrap_sub(
442 to_principal,
443 "Invariant violation: amount_to_principal > amount",
444 );
445 self.position.borrow_asset_principal = self.position.borrow_asset_principal.unwrap_sub(
446 to_principal,
447 "Invariant violation: amount_to_principal > borrow_asset_principal",
448 );
449 self.market.borrow_asset_borrowed = self.market.borrow_asset_borrowed.unwrap_sub(
450 to_principal,
451 "Invariant violation: amount_to_principal > market.borrow_asset_borrowed",
452 );
453
454 if self.position.borrow_asset_principal.is_zero() {
455 self.position.started_at_block_timestamp_ms = None;
457 }
458
459 LiabilityReduction {
460 to_fees,
461 to_interest,
462 to_principal,
463 remaining: amount,
464 }
465 }
466
467 pub fn record_collateral_asset_deposit(
468 &mut self,
469 _proof: InterestAccumulationProof,
470 amount: CollateralAssetAmount,
471 ) {
472 self.position.collateral_asset_deposit += amount;
473 self.market.collateral_asset_deposited += amount;
474
475 MarketEvent::CollateralDeposited {
476 account_id: self.account_id.clone(),
477 collateral_asset_amount: amount,
478 }
479 .emit();
480 }
481
482 pub fn record_collateral_asset_withdrawal_initial(
483 &mut self,
484 _proof: InterestAccumulationProof,
485 amount: CollateralAssetAmount,
486 ) {
487 self.position.collateral_asset_in_flight += amount;
488 self.position.collateral_asset_deposit -= amount;
489 self.market.collateral_asset_deposited -= amount;
490 }
491
492 pub fn record_collateral_asset_withdrawal_final(
493 &mut self,
494 _proof: InterestAccumulationProof,
495 amount: CollateralAssetAmount,
496 success: bool,
497 ) {
498 self.position.collateral_asset_in_flight =
499 self.position.collateral_asset_in_flight.unwrap_sub(
500 amount,
501 "Invariant violation: attempt to unlock more than locked as in-flight",
502 );
503
504 if success {
505 MarketEvent::CollateralWithdrawn {
506 account_id: self.account_id.clone(),
507 collateral_asset_amount: amount,
508 }
509 .emit();
510 } else {
511 self.position.collateral_asset_deposit += amount;
512 self.market.collateral_asset_deposited += amount;
513 }
514 }
515
516 pub(crate) fn record_collateral_asset_withdrawal(
517 &mut self,
518 _proof: InterestAccumulationProof,
519 amount: CollateralAssetAmount,
520 ) {
521 self.position.collateral_asset_deposit -= amount;
522 self.market.collateral_asset_deposited -= amount;
523 }
524
525 pub fn record_borrow_initial(
530 &mut self,
531 _proof: SnapshotProof,
532 _interest: InterestAccumulationProof,
533 amount: BorrowAssetAmount,
534 price_pair: &PricePair,
535 block_timestamp_ms: u64,
536 ) -> Result<InitialBorrow, error::InitialBorrowError> {
537 let available_to_borrow = self.market.get_borrow_asset_available_to_borrow();
539 if amount > available_to_borrow {
540 return Err(error::InitialBorrowError::InsufficientBorrowAssetAvailable);
541 }
542
543 let origination_fee = self
544 .market
545 .configuration
546 .borrow_origination_fee
547 .of(amount)
548 .ok_or(error::InitialBorrowError::FeeCalculationFailure)?;
549
550 let single_snapshot_fee = self
554 .market
555 .single_snapshot_fee(amount)
556 .ok_or(error::InitialBorrowError::FeeCalculationFailure)?;
557
558 let mut fees = origination_fee;
559 fees = fees
560 .checked_add(single_snapshot_fee)
561 .ok_or(error::InitialBorrowError::FeeCalculationFailure)?;
562
563 self.market.borrow_asset_borrowed_in_flight += amount;
564 self.position.borrow_asset_in_flight += amount;
565 self.position.fees += fees;
566
567 if !self.status(price_pair, block_timestamp_ms).is_healthy() {
568 self.market.borrow_asset_borrowed_in_flight -= amount;
569 self.position.borrow_asset_in_flight -= amount;
570 self.position.fees -= fees;
571 return Err(error::InitialBorrowError::Undercollateralization);
572 }
573
574 if !self.within_allowable_borrow_range() {
575 self.market.borrow_asset_borrowed_in_flight -= amount;
576 self.position.borrow_asset_in_flight -= amount;
577 self.position.fees -= fees;
578 return Err(error::InitialBorrowError::OutsideAllowableRange);
579 }
580
581 self.market.record_borrow_asset_yield_distribution(fees);
582
583 Ok(InitialBorrow { amount, fees })
584 }
585
586 pub fn record_borrow_final(
587 &mut self,
588 _snapshot: SnapshotProof,
589 interest: InterestAccumulationProof,
590 borrow: &InitialBorrow,
591 success: bool,
592 block_timestamp_ms: u64,
593 ) {
594 self.market.borrow_asset_borrowed_in_flight -= borrow.amount;
597 self.position.borrow_asset_in_flight -= borrow.amount;
598
599 if success {
600 self.position.increase_borrow_asset_principal(
605 interest,
606 borrow.amount,
607 block_timestamp_ms,
608 );
609
610 self.market.borrow_asset_borrowed += borrow.amount;
611
612 MarketEvent::BorrowWithdrawn {
613 account_id: self.account_id.clone(),
614 borrow_asset_amount: borrow.amount,
615 }
616 .emit();
617 } else {
618 }
640 }
641
642 pub fn record_repay(
650 &mut self,
651 proof: InterestAccumulationProof,
652 amount: BorrowAssetAmount,
653 ) -> Result<BorrowAssetAmount, error::LiquidationLockError> {
654 if !self.position.liquidation_lock.is_zero() {
655 return Err(error::LiquidationLockError);
656 }
657
658 let liability_reduction = self.reduce_borrow_asset_liability(proof, amount);
659
660 MarketEvent::BorrowRepaid {
661 account_id: self.account_id.clone(),
662 borrow_asset_fees_repaid: liability_reduction.to_fees,
663 borrow_asset_principal_repaid: liability_reduction.to_principal,
664 borrow_asset_principal_remaining: self.position.get_borrow_asset_principal(),
665 }
666 .emit();
667
668 Ok(liability_reduction.remaining)
669 }
670
671 pub fn accumulate_interest_partial(&mut self, snapshot_limit: u32) {
672 let accumulation_record = self.calculate_interest(snapshot_limit);
673
674 if !accumulation_record.amount.is_zero() {
675 MarketEvent::InterestAccumulated {
676 account_id: self.account_id.clone(),
677 borrow_asset_amount: accumulation_record.amount,
678 }
679 .emit();
680 }
681
682 self.position.interest.accumulate(accumulation_record);
683 }
684
685 pub fn accumulate_interest(&mut self) -> InterestAccumulationProof {
686 self.accumulate_interest_partial(u32::MAX);
687 InterestAccumulationProof(())
688 }
689
690 pub(crate) fn liquidation_lock(&mut self, amount: CollateralAssetAmount) {
691 self.position.collateral_asset_deposit = self.position.collateral_asset_deposit.unwrap_sub(
692 amount,
693 "Attempt to liquidate more collateral than position has deposited",
694 );
695 self.position.liquidation_lock += amount;
696 }
697
698 pub(crate) fn liquidation_unlock(&mut self, amount: CollateralAssetAmount) {
699 self.position.liquidation_lock = self.position.liquidation_lock.unwrap_sub(
700 amount,
701 "Invariant violation: Liquidation unlock of more collateral that was locked",
702 );
703 self.position.collateral_asset_deposit += amount;
704 }
705
706 pub fn record_liquidation_initial(
714 &mut self,
715 _proof: InterestAccumulationProof,
716 liquidator_sent: BorrowAssetAmount,
717 liquidator_request: Option<CollateralAssetAmount>,
718 price_pair: &PricePair,
719 block_timestamp_ms: u64,
720 ) -> Result<InitialLiquidation, error::InitialLiquidationError> {
721 let BorrowStatus::Liquidation(reason) = self.status(price_pair, block_timestamp_ms) else {
722 return Err(error::InitialLiquidationError::Ineligible);
723 };
724
725 let liquidatable_collateral = match reason {
726 LiquidationReason::Undercollateralization => self.liquidatable_collateral(price_pair),
727 LiquidationReason::Expiration => self.position.collateral_asset_deposit,
728 };
729
730 let liquidator_request = liquidator_request.unwrap_or(liquidatable_collateral);
734
735 if liquidator_request > liquidatable_collateral {
736 return Err(error::InitialLiquidationError::ExcessiveLiquidation {
737 requested: liquidator_request,
738 available: liquidatable_collateral,
739 });
740 }
741
742 let collateral_value = price_pair.convert(liquidator_request);
743
744 let maximum_acceptable: BorrowAssetAmount = collateral_value
745 .to_u128_ceil()
746 .ok_or(error::InitialLiquidationError::ValueCalculationFailure)?
747 .max(1)
748 .into();
749 #[allow(
750 clippy::unwrap_used,
751 reason = "Previous line guarantees this will not panic"
752 )]
753 let minimum_acceptable: BorrowAssetAmount = (collateral_value
754 * (Decimal::ONE - self.market.configuration.liquidation_maximum_spread))
755 .to_u128_ceil()
756 .unwrap()
757 .max(1)
758 .into();
759
760 if liquidator_sent < minimum_acceptable {
761 return Err(error::InitialLiquidationError::OfferTooLow {
762 offered: liquidator_sent,
763 minimum_acceptable,
764 });
765 }
766
767 self.liquidation_lock(liquidator_request);
768
769 let (refund, recovered) = if liquidator_sent > maximum_acceptable {
770 (liquidator_sent - maximum_acceptable, maximum_acceptable)
771 } else {
772 (BorrowAssetAmount::zero(), liquidator_sent)
773 };
774
775 Ok(InitialLiquidation {
776 liquidated: liquidator_request,
777 recovered,
778 refund,
779 })
780 }
781
782 pub fn record_liquidation_final(
783 &mut self,
784 proof: InterestAccumulationProof,
785 liquidator_id: AccountId,
786 initial_liquidation: &InitialLiquidation,
787 ) {
788 let liability_reduction =
789 self.reduce_borrow_asset_liability(proof, initial_liquidation.recovered);
790 self.market
791 .record_borrow_asset_yield_distribution(liability_reduction.remaining);
792 self.liquidation_unlock(initial_liquidation.liquidated);
793 self.record_collateral_asset_withdrawal(proof, initial_liquidation.liquidated);
794
795 MarketEvent::Liquidation {
796 liquidator_id,
797 account_id: self.account_id.clone(),
798 borrow_asset_recovered: initial_liquidation.recovered,
799 collateral_asset_liquidated: initial_liquidation.liquidated,
800 }
801 .emit();
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use near_sdk::{serde_json, test_utils::VMContextBuilder, testing_env};
808 use rstest::rstest;
809
810 use crate::{
811 asset::FungibleAsset,
812 dec,
813 fee::{Fee, TimeBasedFee},
814 interest_rate_strategy::InterestRateStrategy,
815 market::{MarketConfiguration, PriceOracleConfiguration, YieldWeights},
816 oracle::pyth::{self, PriceIdentifier},
817 time_chunk::TimeChunkConfiguration,
818 };
819
820 use super::*;
821
822 #[rstest]
823 #[test]
824 fn liquidatable_collateral(
825 #[values("1.2", "1.25", "1.5", "2")] mcr: Decimal,
826 #[values(11, 1000, 1005, 999_999)] collateral_price: i64,
827 #[values(1000, 1005, 999_999)] borrow_price: i64,
828 #[values(0, 10)] conf: u64,
829 ) {
830 let c = VMContextBuilder::new()
831 .block_timestamp(1_000_000_000_000_000)
832 .build();
833 testing_env!(c.clone());
834
835 let configuration = MarketConfiguration {
836 time_chunk_configuration: TimeChunkConfiguration::new(600_000),
837 borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
838 collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
839 price_oracle_configuration: PriceOracleConfiguration {
840 account_id: "pyth-oracle.near".parse().unwrap(),
841 collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
842 collateral_asset_decimals: 24,
843 borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
844 borrow_asset_decimals: 24,
845 price_maximum_age_s: 60,
846 },
847 borrow_mcr_maintenance: mcr,
848 borrow_mcr_liquidation: mcr,
849 borrow_asset_maximum_usage_ratio: dec!("0.99"),
850 borrow_origination_fee: Fee::zero(),
851 borrow_interest_rate_strategy: InterestRateStrategy::zero(),
852 borrow_maximum_duration_ms: None,
853 borrow_range: (1, None).try_into().unwrap(),
854 supply_range: (1, None).try_into().unwrap(),
855 supply_withdrawal_range: (1, None).try_into().unwrap(),
856 supply_withdrawal_fee: TimeBasedFee::zero(),
857 yield_weights: YieldWeights::new_with_supply_weight(9)
858 .with_static("revenue.tmplr.near".parse().unwrap(), 1),
859 protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
860 liquidation_maximum_spread: dec!("0.05"),
861 };
862
863 let mut market = Market::new(b"m", configuration.clone());
864 market.borrow_asset_deposited_active += BorrowAssetAmount::new(100_000_000_000);
865 let snapshot_proof = market.snapshot();
866
867 let mut position = BorrowPositionGuard(BorrowPositionRef {
868 market: &mut market,
869 account_id: "borrower".parse().unwrap(),
870 position: BorrowPosition::new(1),
871 });
872
873 let interest_proof = position.accumulate_interest();
874 position.record_collateral_asset_deposit(
875 interest_proof,
876 CollateralAssetAmount::new(100_000_000),
877 );
878 let initial_price_pair = PricePair::new(
879 &pyth::Price {
880 price: 5.into(),
881 conf: 0.into(),
882 expo: 24,
883 publish_time: 10,
884 },
885 24,
886 &pyth::Price {
887 price: 1.into(),
888 conf: 0.into(),
889 expo: 24,
890 publish_time: 10,
891 },
892 24,
893 )
894 .unwrap();
895 assert_eq!(
896 position.liquidatable_collateral(&initial_price_pair),
897 CollateralAssetAmount::zero(),
898 );
899 let initial_borrow = position
900 .record_borrow_initial(
901 snapshot_proof,
902 interest_proof,
903 BorrowAssetAmount::new(85_000_000),
904 &initial_price_pair,
905 env::block_timestamp_ms(),
906 )
907 .unwrap();
908 position.record_borrow_final(
909 snapshot_proof,
910 interest_proof,
911 &initial_borrow,
912 true,
913 env::block_timestamp_ms(),
914 );
915 let price_pair = PricePair::new(
916 &pyth::Price {
917 price: collateral_price.into(),
918 conf: conf.into(),
919 expo: 24,
920 publish_time: 10,
921 },
922 24,
923 &pyth::Price {
924 price: borrow_price.into(),
925 conf: conf.into(),
926 expo: 24,
927 publish_time: 10,
928 },
929 24,
930 )
931 .unwrap();
932 let starting_cr = position.inner().collateralization_ratio(&price_pair);
933 eprintln!("Starting collateralization ratio: {starting_cr:?}");
934 let liquidatable_collateral = position.liquidatable_collateral(&price_pair);
935
936 let minimum_acceptable = configuration
937 .minimum_acceptable_liquidation_amount(liquidatable_collateral, &price_pair)
938 .unwrap();
939
940 eprintln!("Liquidatable collateral: {liquidatable_collateral}");
941 eprintln!("Minimum acceptable: {minimum_acceptable}");
942
943 match collateral_price.ilog10().cmp(&borrow_price.ilog10()) {
944 std::cmp::Ordering::Less => {
945 assert_eq!(
947 liquidatable_collateral,
948 CollateralAssetAmount::new(100_000_000),
949 "All collateral should be eligible for liquidation"
950 );
951 }
952 std::cmp::Ordering::Equal => {
953 let initial_liquidation = position
956 .record_liquidation_initial(
957 interest_proof,
958 minimum_acceptable,
959 Some(liquidatable_collateral),
960 &price_pair,
961 env::block_timestamp_ms(),
962 )
963 .unwrap();
964 position.record_liquidation_final(
965 interest_proof,
966 "liquidator".parse().unwrap(),
967 &initial_liquidation,
968 );
969
970 let finishing_cr = position
971 .inner()
972 .collateralization_ratio(&price_pair)
973 .unwrap();
974 eprintln!("Finishing collateralization ratio: {finishing_cr}");
975 eprintln!("Target MCR: {mcr}");
976
977 assert!(finishing_cr >= mcr);
978 let delta = finishing_cr.abs_diff(mcr);
979 assert!(delta < Decimal::ONE.mul_pow10(-4).unwrap());
980 }
981 std::cmp::Ordering::Greater => {
982 assert_eq!(
985 liquidatable_collateral,
986 CollateralAssetAmount::zero(),
987 "No collateral should be liquidatable"
988 );
989 }
990 }
991 }
992
993 #[test]
994 fn test_borrow_position_deserialize_new_format() {
995 let json = r#"{
997 "started_at_block_timestamp_ms": "1699564800000",
998 "collateral_asset_deposit": "1000000000000000000000000",
999 "borrow_asset_principal": "100000000",
1000 "interest": {
1001 "total": "0",
1002 "fraction_as_u128_dividend": "0",
1003 "next_snapshot_index": 42,
1004 "pending_estimate": "0"
1005 },
1006 "fees": "500000",
1007 "borrow_asset_in_flight": "50000000",
1008 "collateral_asset_in_flight": "0",
1009 "liquidation_lock": "0"
1010 }"#;
1011
1012 let position: BorrowPosition =
1013 serde_json::from_str(json).expect("Failed to deserialize new format");
1014 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
1015 assert_eq!(
1016 position.get_borrow_asset_principal(),
1017 BorrowAssetAmount::new(50_000_000 + 100_000_000)
1018 );
1019 }
1020
1021 #[test]
1022 fn test_borrow_position_deserialize_old_format_with_borrow_asset_fees() {
1023 let json = r#"{
1025 "started_at_block_timestamp_ms": "1699564800000",
1026 "collateral_asset_deposit": "1000000000000000000000000",
1027 "borrow_asset_principal": "100000000",
1028 "borrow_asset_fees": {
1029 "total": "0",
1030 "fraction_as_u128_dividend": "0",
1031 "next_snapshot_index": 42,
1032 "pending_estimate": "0"
1033 },
1034 "fees": "500000",
1035 "borrow_asset_in_flight": "0",
1036 "collateral_asset_in_flight": "0",
1037 "liquidation_lock": "0"
1038 }"#;
1039
1040 let position: BorrowPosition =
1041 serde_json::from_str(json).expect("Failed to deserialize old format");
1042 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
1043 assert_eq!(
1044 position.get_borrow_asset_principal(),
1045 BorrowAssetAmount::new(100_000_000)
1046 );
1047 }
1048
1049 #[test]
1050 fn test_borrow_position_deserialize_mixed_old_new_format() {
1051 let json = r#"{
1053 "started_at_block_timestamp_ms": "1699564800000",
1054 "collateral_asset_deposit": "1000000000000000000000000",
1055 "borrow_asset_principal": "100000000",
1056 "borrow_asset_fees": {
1057 "total": "0",
1058 "fraction_as_u128_dividend": "0",
1059 "next_snapshot_index": 42,
1060 "pending_estimate": "0"
1061 },
1062 "fees": "500000",
1063 "borrow_asset_in_flight": "0",
1064 "collateral_asset_in_flight": "0",
1065 "liquidation_lock": "0"
1066 }"#;
1067
1068 let position: BorrowPosition =
1069 serde_json::from_str(json).expect("Failed to deserialize mixed format");
1070 assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
1071 assert_eq!(
1072 position.get_borrow_asset_principal(),
1073 BorrowAssetAmount::new(100_000_000)
1074 );
1075 assert_eq!(
1076 position.get_total_collateral_amount(),
1077 CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1078 );
1079 }
1080
1081 #[test]
1082 fn test_borrow_position_deserialize_defaults() {
1083 let json = r#"{
1085 "collateral_asset_deposit": "1000000000000000000000000",
1086 "borrow_asset_principal": "100000000",
1087 "interest": {
1088 "total": "0",
1089 "fraction_as_u128_dividend": "0",
1090 "next_snapshot_index": 42,
1091 "pending_estimate": "0"
1092 }
1093 }"#;
1094
1095 let position: BorrowPosition =
1096 serde_json::from_str(json).expect("Failed to deserialize with defaults");
1097 assert_eq!(position.started_at_block_timestamp_ms, None);
1098 assert_eq!(position.fees, BorrowAssetAmount::new(0));
1099 assert_eq!(
1100 position.get_total_collateral_amount(),
1101 CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1102 );
1103 }
1104}