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