templar_common/
borrow.rs

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/// This struct can only be constructed after accumulating interest on a
16/// borrow position. This serves as proof that the interest has accrued, so it
17/// is safe to perform certain other operations.
18#[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    /// The position is in good standing.
32    Healthy,
33    /// Collateralization ratio is below
34    /// [`crate::market::MarketConfiguration::borrow_mcr_maintenance`]. More
35    /// collateral should be deposited or repayment should occur.
36    MaintenanceRequired,
37    /// The position can be liquidated.
38    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            // Start from current (not next) snapshot to avoid the possibility
83            // of borrowing "for free". e.g. if TimeChunk units are epochs (12
84            // hours), this prevents someone from getting 11 hours of free
85            // borrowing if they create the borrow 1 hour into the epoch.
86            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    /// Returns `None` if liability is zero.
116    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    /// Interest accumulation MUST be applied before calling this function.
130    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            // Zero-valued liability
161            return CollateralAssetAmount::zero();
162        };
163
164        if cr <= Decimal::ONE {
165            // Totally underwater
166            return collateral.saturating_sub(self.liquidation_lock);
167        }
168
169        if cr >= mcr {
170            // Above MCR
171            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        // No bounds checks necessary here: the min() call prevents underflow.
415        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            // fully paid off
456            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    /// # Errors
526    ///
527    /// - If there is not enough borrow asset available to borrow.
528    /// - If there is an error calculating the fee (e.g. overflow).
529    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        // Ensure we have enough funds to dispense.
538        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        // Necessary because we track borrows in terms of whole snapshots, so
551        // this covers the interest that could be missed because of ignoring
552        // fractional snapshots.
553        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        // This should never panic, because a given amount of in-flight borrow
595        // asset should always be added before it is removed.
596        self.market.borrow_asset_borrowed_in_flight -= borrow.amount;
597        self.position.borrow_asset_in_flight -= borrow.amount;
598
599        if success {
600            // GREAT SUCCESS
601            //
602            // Borrow position has already been created: finalize
603            // withdrawal record.
604            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            // Likely reasons for failure:
619            //
620            // 1. Price oracle is out-of-date. This is kind of bad, but
621            //  not necessarily catastrophic nor unrecoverable. Probably,
622            //  the oracle is just lagging and will be fine if the user
623            //  tries again later.
624            //
625            // Mitigation strategy: Revert locks & state changes (i.e. do
626            // nothing else).
627            //
628            // 2. MPC signing failed or took too long. Need to do a bit
629            //  more research to see if it is possible for the signature to
630            //  still show up on chain after the promise expires.
631            //
632            // Mitigation strategy: Retain locks until we know the
633            // signature will not be issued. Note that we can't implement
634            // this strategy until we implement asset transfer for MPC
635            // assets, so we IGNORE THIS CASE FOR NOW.
636            //
637            // TODO: Implement case 2 mitigation.
638            // NOTE: Not needed for chain-local (NEP-141-only) tokens.
639        }
640    }
641
642    /// Returns the amount that is left over after repaying the whole
643    /// position. That is, the return value is the number of tokens that may
644    /// be returned to the owner of the borrow position.
645    ///
646    /// # Errors
647    ///
648    /// - If any collateral is locked for liquidation.
649    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    /// # Errors
707    ///
708    /// - If this record is not eligible for liquidation.
709    /// - If the liquidator requests to liquidate too much collateral from the
710    ///   position.
711    /// - If the calculation of the collateral value fails.
712    /// - If the liquidator offers too little to purchase the collateral.
713    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        // If liquidator doesn't specify an amount of collateral to liquidate,
731        // attempt to liquidate all of the collateral that can be liquidated
732        // from the position.
733        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                // Completely underwater
946                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                // Partial liquidation
954
955                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                // No liquidation
983
984                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        // New market format with interest field
996        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        // Old market format with borrow_asset_fees instead of interest
1024        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        // Mixed format: old field name for interest (borrow_asset_fees), new field names for others
1052        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        // Minimal JSON with only required fields, others should use defaults
1084        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}