templar_common/
borrow.rs

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/// 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}
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            // Start from current (not next) snapshot to avoid the possibility
81            // of borrowing "for free". e.g. if TimeChunk units are epochs (12
82            // hours), this prevents someone from getting 11 hours of free
83            // borrowing if they create the borrow 1 hour into the epoch.
84            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    /// Returns `None` if liability is zero.
113    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    /// Interest accumulation MUST be applied before calling this function.
127    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            // Zero-valued liability
158            return CollateralAssetAmount::zero();
159        };
160
161        if cr <= Decimal::ONE {
162            // Totally underwater
163            return collateral;
164        }
165
166        if cr >= mcr {
167            // Above MCR
168            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        // No bounds checks necessary here: the min() call prevents underflow.
387        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            // fully paid off
430            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    /// # Errors
500    ///
501    /// - If there is not enough borrow asset available to borrow.
502    /// - If there is an error calculating the fee (e.g. overflow).
503    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        // Ensure we have enough funds to dispense.
512        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        // Necessary because we track borrows in terms of whole snapshots, so
525        // this covers the interest that could be missed because of ignoring
526        // fractional snapshots.
527        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        // This should never panic, because a given amount of in-flight borrow
570        // asset should always be added before it is removed.
571        self.market.borrow_asset_borrowed_in_flight -= borrow.amount;
572        self.position.borrow_asset_in_flight -= borrow.amount;
573
574        if success {
575            // GREAT SUCCESS
576            //
577            // Borrow position has already been created: finalize
578            // withdrawal record.
579            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            // Likely reasons for failure:
594            //
595            // 1. Price oracle is out-of-date. This is kind of bad, but
596            //  not necessarily catastrophic nor unrecoverable. Probably,
597            //  the oracle is just lagging and will be fine if the user
598            //  tries again later.
599            //
600            // Mitigation strategy: Revert locks & state changes (i.e. do
601            // nothing else).
602            //
603            // 2. MPC signing failed or took too long. Need to do a bit
604            //  more research to see if it is possible for the signature to
605            //  still show up on chain after the promise expires.
606            //
607            // Mitigation strategy: Retain locks until we know the
608            // signature will not be issued. Note that we can't implement
609            // this strategy until we implement asset transfer for MPC
610            // assets, so we IGNORE THIS CASE FOR NOW.
611            //
612            // TODO: Implement case 2 mitigation.
613            // NOTE: Not needed for chain-local (NEP-141-only) tokens.
614
615            self.market.borrow_asset_balance += borrow.amount;
616        }
617    }
618
619    /// Returns the amount that is left over after repaying the whole
620    /// position. That is, the return value is the number of tokens that may
621    /// be returned to the owner of the borrow position.
622    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    /// # Errors
663    ///
664    /// - If this record is not eligible for liquidation.
665    /// - If the liquidator requests to liquidate too much collateral from the
666    ///   position.
667    /// - If the calculation of the collateral value fails.
668    /// - If the liquidator offers too little to purchase the collateral.
669    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        // If liquidator doesn't specify an amount of collateral to liquidate,
688        // attempt to liquidate all of the collateral that can be liquidated
689        // from the position.
690        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                // Completely underwater
897                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                // Partial liquidation
905
906                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                // No liquidation
930
931                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        // New market format with interest field
943        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        // Old market format with borrow_asset_fees instead of interest
971        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        // Mixed format: old field name for interest (borrow_asset_fees), new field names for others
999        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        // Minimal JSON with only required fields, others should use defaults
1031        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}