templar_common/
borrow.rs

1use std::ops::{Deref, DerefMut};
2
3use near_sdk::{json_types::U64, near, AccountId};
4use templar_primitives::number::Decimal;
5
6use crate::{
7    accumulator::{AccumulationRecord, Accumulator},
8    asset::{BorrowAsset, BorrowAssetAmount, CollateralAssetAmount},
9    event::MarketEvent,
10    market::{Market, SnapshotProof},
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        fee::{Fee, TimeBasedFee},
761        interest_rate_strategy::InterestRateStrategy,
762        market::{MarketConfiguration, PriceOracleConfiguration, YieldWeights},
763        oracle::pyth::{self, PriceIdentifier},
764        time_chunk::TimeChunkConfiguration,
765    };
766
767    use super::*;
768
769    #[rstest]
770    #[test]
771    fn liquidatable_collateral(
772        #[values("1.2", "1.25", "1.5", "2")] mcr: Decimal,
773        #[values(11, 1000, 1005, 999_999)] collateral_price: i64,
774        #[values(1000, 1005, 999_999)] borrow_price: i64,
775        #[values(0, 10)] conf: u64,
776    ) {
777        use templar_primitives::dec;
778
779        use crate::oracle::pyth::PythTimestamp;
780
781        let c = VMContextBuilder::new()
782            .block_timestamp(1_000_000_000_000_000)
783            .build();
784        testing_env!(c.clone());
785
786        let configuration = MarketConfiguration {
787            time_chunk_configuration: TimeChunkConfiguration::new(600_000),
788            borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
789            collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
790            price_oracle_configuration: PriceOracleConfiguration {
791                account_id: "pyth-oracle.near".parse().unwrap(),
792                collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
793                collateral_asset_decimals: 24,
794                borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
795                borrow_asset_decimals: 24,
796                price_maximum_age_s: 60,
797            },
798            borrow_mcr_maintenance: mcr,
799            borrow_mcr_liquidation: mcr,
800            borrow_asset_maximum_usage_ratio: dec!("0.99"),
801            borrow_origination_fee: Fee::zero(),
802            borrow_interest_rate_strategy: InterestRateStrategy::zero(),
803            borrow_maximum_duration_ms: None,
804            borrow_range: (1, None).try_into().unwrap(),
805            supply_range: (1, None).try_into().unwrap(),
806            supply_withdrawal_range: (1, None).try_into().unwrap(),
807            supply_withdrawal_fee: TimeBasedFee::zero(),
808            yield_weights: YieldWeights::new_with_supply_weight(9)
809                .with_static("revenue.tmplr.near".parse().unwrap(), 1),
810            protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
811            liquidation_maximum_spread: dec!("0.05"),
812        };
813
814        let mut market = Market::new(b"m", configuration.clone());
815        market.borrow_asset_deposited_active += BorrowAssetAmount::new(100_000_000_000);
816        market.borrow_asset_balance += BorrowAssetAmount::new(100_000_000_000);
817        let snapshot_proof = market.snapshot();
818
819        let mut position = BorrowPositionGuard(BorrowPositionRef {
820            market: &mut market,
821            account_id: "borrower".parse().unwrap(),
822            position: BorrowPosition::new(1),
823        });
824
825        let interest_proof = position.accumulate_interest();
826        position.record_collateral_asset_deposit(
827            interest_proof,
828            CollateralAssetAmount::new(100_000_000),
829        );
830        let initial_price_pair = PricePair::new(
831            &pyth::Price {
832                price: 5.into(),
833                conf: 0.into(),
834                expo: 24,
835                publish_time: PythTimestamp::from_secs(10),
836            },
837            24,
838            &pyth::Price {
839                price: 1.into(),
840                conf: 0.into(),
841                expo: 24,
842                publish_time: PythTimestamp::from_secs(10),
843            },
844            24,
845        )
846        .unwrap();
847        assert_eq!(
848            position.liquidatable_collateral(&initial_price_pair),
849            CollateralAssetAmount::zero(),
850        );
851        let initial_borrow = position
852            .record_borrow_initial(
853                snapshot_proof,
854                interest_proof,
855                BorrowAssetAmount::new(85_000_000),
856                &initial_price_pair,
857                env::block_timestamp_ms(),
858            )
859            .unwrap();
860        position.record_borrow_final(
861            snapshot_proof,
862            interest_proof,
863            &initial_borrow,
864            true,
865            env::block_timestamp_ms(),
866        );
867        let price_pair = PricePair::new(
868            &pyth::Price {
869                price: collateral_price.into(),
870                conf: conf.into(),
871                expo: 24,
872                publish_time: PythTimestamp::from_secs(10),
873            },
874            24,
875            &pyth::Price {
876                price: borrow_price.into(),
877                conf: conf.into(),
878                expo: 24,
879                publish_time: PythTimestamp::from_secs(10),
880            },
881            24,
882        )
883        .unwrap();
884        let starting_cr = position.inner().collateralization_ratio(&price_pair);
885        eprintln!("Starting collateralization ratio: {starting_cr:?}");
886        let liquidatable_collateral = position.liquidatable_collateral(&price_pair);
887
888        let minimum_acceptable = configuration
889            .minimum_acceptable_liquidation_amount(liquidatable_collateral, &price_pair)
890            .unwrap();
891
892        eprintln!("Liquidatable collateral: {liquidatable_collateral}");
893        eprintln!("Minimum acceptable: {minimum_acceptable}");
894
895        match collateral_price.ilog10().cmp(&borrow_price.ilog10()) {
896            std::cmp::Ordering::Less => {
897                // Completely underwater
898                assert_eq!(
899                    liquidatable_collateral,
900                    CollateralAssetAmount::new(100_000_000),
901                    "All collateral should be eligible for liquidation"
902                );
903            }
904            std::cmp::Ordering::Equal => {
905                // Partial liquidation
906
907                let _liquidation = position
908                    .record_liquidation(
909                        interest_proof,
910                        "liquidator".parse().unwrap(),
911                        minimum_acceptable,
912                        Some(liquidatable_collateral),
913                        &price_pair,
914                        env::block_timestamp_ms(),
915                    )
916                    .unwrap();
917
918                let finishing_cr = position
919                    .inner()
920                    .collateralization_ratio(&price_pair)
921                    .unwrap();
922                eprintln!("Finishing collateralization ratio: {finishing_cr}");
923                eprintln!("Target MCR: {mcr}");
924
925                assert!(finishing_cr >= mcr);
926                let delta = finishing_cr.abs_diff(mcr);
927                assert!(delta < Decimal::ONE.mul_pow10(-4).unwrap());
928            }
929            std::cmp::Ordering::Greater => {
930                // No liquidation
931
932                assert_eq!(
933                    liquidatable_collateral,
934                    CollateralAssetAmount::zero(),
935                    "No collateral should be liquidatable"
936                );
937            }
938        }
939    }
940
941    #[test]
942    fn test_borrow_position_deserialize_new_format() {
943        // New market format with interest field
944        let json = r#"{
945            "started_at_block_timestamp_ms": "1699564800000",
946            "collateral_asset_deposit": "1000000000000000000000000",
947            "borrow_asset_principal": "100000000",
948            "interest": {
949                "total": "0",
950                "fraction_as_u128_dividend": "0",
951                "next_snapshot_index": 42,
952                "pending_estimate": "0"
953            },
954            "fees": "500000",
955            "borrow_asset_in_flight": "50000000",
956            "collateral_asset_in_flight": "0",
957            "liquidation_lock": "0"
958        }"#;
959
960        let position: BorrowPosition =
961            serde_json::from_str(json).expect("Failed to deserialize new format");
962        assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
963        assert_eq!(
964            position.get_borrow_asset_principal(),
965            BorrowAssetAmount::new(50_000_000 + 100_000_000)
966        );
967    }
968
969    #[test]
970    fn test_borrow_position_deserialize_old_format_with_borrow_asset_fees() {
971        // Old market format with borrow_asset_fees instead of interest
972        let json = r#"{
973            "started_at_block_timestamp_ms": "1699564800000",
974            "collateral_asset_deposit": "1000000000000000000000000",
975            "borrow_asset_principal": "100000000",
976            "borrow_asset_fees": {
977                "total": "0",
978                "fraction_as_u128_dividend": "0",
979                "next_snapshot_index": 42,
980                "pending_estimate": "0"
981            },
982            "fees": "500000",
983            "borrow_asset_in_flight": "0",
984            "collateral_asset_in_flight": "0",
985            "liquidation_lock": "0"
986        }"#;
987
988        let position: BorrowPosition =
989            serde_json::from_str(json).expect("Failed to deserialize old format");
990        assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
991        assert_eq!(
992            position.get_borrow_asset_principal(),
993            BorrowAssetAmount::new(100_000_000)
994        );
995    }
996
997    #[test]
998    fn test_borrow_position_deserialize_mixed_old_new_format() {
999        // Mixed format: old field name for interest (borrow_asset_fees), new field names for others
1000        let json = r#"{
1001            "started_at_block_timestamp_ms": "1699564800000",
1002            "collateral_asset_deposit": "1000000000000000000000000",
1003            "borrow_asset_principal": "100000000",
1004            "borrow_asset_fees": {
1005                "total": "0",
1006                "fraction_as_u128_dividend": "0",
1007                "next_snapshot_index": 42,
1008                "pending_estimate": "0"
1009            },
1010            "fees": "500000",
1011            "borrow_asset_in_flight": "0",
1012            "collateral_asset_in_flight": "0",
1013            "liquidation_lock": "0"
1014        }"#;
1015
1016        let position: BorrowPosition =
1017            serde_json::from_str(json).expect("Failed to deserialize mixed format");
1018        assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
1019        assert_eq!(
1020            position.get_borrow_asset_principal(),
1021            BorrowAssetAmount::new(100_000_000)
1022        );
1023        assert_eq!(
1024            position.get_total_collateral_amount(),
1025            CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1026        );
1027    }
1028
1029    #[test]
1030    fn test_borrow_position_deserialize_defaults() {
1031        // Minimal JSON with only required fields, others should use defaults
1032        let json = r#"{
1033            "collateral_asset_deposit": "1000000000000000000000000",
1034            "borrow_asset_principal": "100000000",
1035            "interest": {
1036                "total": "0",
1037                "fraction_as_u128_dividend": "0",
1038                "next_snapshot_index": 42,
1039                "pending_estimate": "0"
1040            }
1041        }"#;
1042
1043        let position: BorrowPosition =
1044            serde_json::from_str(json).expect("Failed to deserialize with defaults");
1045        assert_eq!(position.started_at_block_timestamp_ms, None);
1046        assert_eq!(position.fees, BorrowAssetAmount::new(0));
1047        assert_eq!(
1048            position.get_total_collateral_amount(),
1049            CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1050        );
1051    }
1052
1053    // Backstop for `fuzz_borrow_overflow`: that target asserts correctness up
1054    // to the boundary but cannot observe the abort itself (libfuzzer-sys
1055    // aborts on panics before catch_unwind sees them). The contract's safety
1056    // property — overflow on principal + in_flight aborts — is asserted here.
1057    #[test]
1058    #[should_panic(expected = "attempt to add with overflow")]
1059    fn liability_overflow_aborts_on_principal_plus_in_flight() {
1060        let mut position = BorrowPosition::new(0);
1061        position.borrow_asset_principal = BorrowAssetAmount::new(u128::MAX);
1062        position.borrow_asset_in_flight = BorrowAssetAmount::new(1);
1063        let _ = position.get_total_borrow_asset_liability();
1064    }
1065
1066    // Backstop for `fuzz_liquidations` (ENG-342). That target predicts and
1067    // skips the `1 < cr < mcr` band when `mcr * (1 - spread) <= 1`, because the
1068    // denominator `mcr * discount - 1` underflows there and libfuzzer-sys can't
1069    // distinguish that abort from a real crash. The abort — the actual symptom
1070    // of the tracked bug — is asserted here so the suppressed region is still
1071    // pinned down. (`Decimal`'s U512 subtraction underflow panics with
1072    // "arithmetic operation overflow", unlike a std-integer add.)
1073    #[test]
1074    #[should_panic(expected = "arithmetic operation overflow")]
1075    fn liquidatable_collateral_denominator_underflow_aborts() {
1076        use templar_primitives::dec;
1077
1078        use crate::oracle::pyth::PythTimestamp;
1079
1080        // Equal prices/decimals ⇒ cr is the pure amount ratio. collateral=102,
1081        // liability=100 ⇒ cr=1.02, inside (1, mcr=1.05). With spread=0.1,
1082        // discount=0.9 and mcr*discount=0.945 <= 1, so `mcr*discount - 1`
1083        // underflows on unsigned `Decimal` subtraction.
1084        let price = pyth::Price {
1085            price: 1.into(),
1086            conf: 0.into(),
1087            expo: 0,
1088            publish_time: PythTimestamp::from_secs(0),
1089        };
1090        let price_pair = PricePair::new(&price, 0, &price, 0).unwrap();
1091
1092        let mut position = BorrowPosition::new(0);
1093        position.collateral_asset_deposit = CollateralAssetAmount::new(102);
1094        position.borrow_asset_principal = BorrowAssetAmount::new(100);
1095
1096        let _ = position.liquidatable_collateral(&price_pair, dec!("1.05"), dec!("0.1"));
1097    }
1098}