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        snapshot::Snapshot,
765        time_chunk::{TimeChunk, TimeChunkConfiguration},
766    };
767
768    use super::*;
769
770    /// Build a minimal valid market configuration. The interest-rate strategy
771    /// is irrelevant to [`calculate_interest`] because the test overwrites the
772    /// `interest_rate` of each finalized snapshot directly.
773    fn interest_test_configuration() -> MarketConfiguration {
774        use templar_primitives::dec;
775
776        MarketConfiguration {
777            time_chunk_configuration: TimeChunkConfiguration::new(600_000),
778            borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
779            collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
780            price_oracle_configuration: PriceOracleConfiguration {
781                account_id: "pyth-oracle.near".parse().unwrap(),
782                collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
783                collateral_asset_decimals: 24,
784                borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
785                borrow_asset_decimals: 24,
786                price_maximum_age_s: 60,
787            },
788            borrow_mcr_maintenance: dec!("1.25"),
789            borrow_mcr_liquidation: dec!("1.2"),
790            borrow_asset_maximum_usage_ratio: dec!("0.99"),
791            borrow_origination_fee: Fee::zero(),
792            borrow_interest_rate_strategy: InterestRateStrategy::zero(),
793            borrow_maximum_duration_ms: None,
794            borrow_range: (1, None).try_into().unwrap(),
795            supply_range: (1, None).try_into().unwrap(),
796            supply_withdrawal_range: (1, None).try_into().unwrap(),
797            supply_withdrawal_fee: TimeBasedFee::zero(),
798            yield_weights: YieldWeights::new_with_supply_weight(9)
799                .with_static("revenue.tmplr.near".parse().unwrap(), 1),
800            protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
801            liquidation_maximum_spread: dec!("0.05"),
802        }
803    }
804
805    /// Push a finalized snapshot with an explicitly chosen end timestamp and
806    /// interest rate. Only `end_timestamp_ms`, `interest_rate`, and
807    /// `time_chunk` (for panic messages) feed into [`calculate_interest`]; the
808    /// remaining balance fields are irrelevant and left at zero.
809    fn push_finalized_snapshot(market: &mut Market, end_timestamp_ms: u64, interest_rate: Decimal) {
810        market.finalized_snapshots.push(Snapshot {
811            time_chunk: TimeChunk(end_timestamp_ms.into()),
812            end_timestamp_ms: end_timestamp_ms.into(),
813            borrow_asset_deposited_active: 0.into(),
814            borrow_asset_borrowed: 0.into(),
815            collateral_asset_deposited: 0.into(),
816            yield_distribution: BorrowAssetAmount::zero(),
817            interest_rate,
818        });
819    }
820
821    /// Deterministic, node-free unit test of the interest-accrual math in
822    /// [`BorrowPositionRef::calculate_interest`].
823    ///
824    /// The market is constructed in the in-process near-sdk test VM (no neard
825    /// sandbox). `Market::new` pushes finalized snapshot #0, whose
826    /// `end_timestamp_ms` is the construction block timestamp (`T0`). We then
827    /// push two more finalized snapshots at chosen timestamps with chosen
828    /// interest rates, and build a borrow position whose interest accumulator
829    /// begins at snapshot index 1 (so accrual spans snapshots #1 and #2).
830    ///
831    /// For each finalized snapshot the function accrues:
832    ///     principal * interest_rate * duration_ms * YEAR_PER_MS
833    /// where `duration_ms` is the gap to the previous snapshot's end timestamp,
834    /// then returns `floor(sum)`.
835    ///
836    /// The exact floored integer is pinned to `EXPECTED` below.
837    #[test]
838    fn calculate_interest_two_snapshots_exact() {
839        const T0_MS: u64 = 1_000_000;
840        const DURATION_1_MS: u64 = 2_592_000_000; // 30 days
841        const DURATION_2_MS: u64 = 5_184_000_000; // 60 days
842        const T1_MS: u64 = T0_MS + DURATION_1_MS;
843        const T2_MS: u64 = T1_MS + DURATION_2_MS;
844        const PRINCIPAL: u128 = 1_000_000_000_000;
845        // Pin the exact integer so this is not merely a re-run of the impl.
846        const EXPECTED: u128 = 41_068_605_104;
847
848        let rate_1 = templar_primitives::dec!("0.10");
849        let rate_2 = templar_primitives::dec!("0.20");
850
851        // In-process near-sdk VM: set the construction-time block timestamp so
852        // that finalized snapshot #0 lands at T0_MS (block timestamp is in ns).
853        let context = VMContextBuilder::new()
854            .block_timestamp(T0_MS * 1_000_000)
855            .build();
856        testing_env!(context);
857
858        let mut market = Market::new(b"m", interest_test_configuration());
859        // Sanity: Market::new pushed exactly one snapshot (#0) at T0_MS.
860        assert_eq!(market.finalized_snapshots.len(), 1);
861        assert_eq!(
862            market.get_last_finalized_snapshot().end_timestamp_ms.0,
863            T0_MS
864        );
865
866        push_finalized_snapshot(&mut market, T1_MS, rate_1); // index 1
867        push_finalized_snapshot(&mut market, T2_MS, rate_2); // index 2
868        assert_eq!(market.finalized_snapshots.len(), 3);
869
870        // Borrow position with the chosen principal; its interest accumulator
871        // starts at snapshot index 1, so accrual covers snapshots #1 and #2.
872        let mut position = BorrowPosition::new(1);
873        position.borrow_asset_principal = BorrowAssetAmount::new(PRINCIPAL);
874        assert_eq!(position.interest.get_next_snapshot_index(), 1);
875
876        let position_ref =
877            BorrowPositionRef::new(&market, "borrower.near".parse().unwrap(), position);
878
879        let record = position_ref.calculate_interest(u32::MAX);
880
881        // Independently recompute the expected value with the exact same
882        // fixed-point operations and accumulation order as the function under
883        // test, so fixed-point rounding matches bit-for-bit.
884        let principal = Decimal::from(PRINCIPAL);
885        let mut expected_acc = Decimal::ZERO;
886        expected_acc += principal * rate_1 * Decimal::from(DURATION_1_MS) * YEAR_PER_MS;
887        expected_acc += principal * rate_2 * Decimal::from(DURATION_2_MS) * YEAR_PER_MS;
888        let expected = expected_acc.to_u128_floor().unwrap();
889
890        assert_eq!(
891            expected, EXPECTED,
892            "hand-derived Decimal computation drifted from the pinned literal",
893        );
894
895        assert_eq!(
896            u128::from(record.amount),
897            EXPECTED,
898            "calculate_interest accrued the wrong amount",
899        );
900        // The accumulator advances to one-past the last consumed snapshot.
901        assert_eq!(record.next_snapshot_index, 3);
902    }
903
904    #[rstest]
905    #[test]
906    fn liquidatable_collateral(
907        #[values("1.2", "1.25", "1.5", "2")] mcr: Decimal,
908        #[values(11, 1000, 1005, 999_999)] collateral_price: i64,
909        #[values(1000, 1005, 999_999)] borrow_price: i64,
910        #[values(0, 10)] conf: u64,
911    ) {
912        use templar_primitives::dec;
913
914        use crate::oracle::pyth::PythTimestamp;
915
916        let c = VMContextBuilder::new()
917            .block_timestamp(1_000_000_000_000_000)
918            .build();
919        testing_env!(c.clone());
920
921        let configuration = MarketConfiguration {
922            time_chunk_configuration: TimeChunkConfiguration::new(600_000),
923            borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
924            collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
925            price_oracle_configuration: PriceOracleConfiguration {
926                account_id: "pyth-oracle.near".parse().unwrap(),
927                collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
928                collateral_asset_decimals: 24,
929                borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
930                borrow_asset_decimals: 24,
931                price_maximum_age_s: 60,
932            },
933            borrow_mcr_maintenance: mcr,
934            borrow_mcr_liquidation: mcr,
935            borrow_asset_maximum_usage_ratio: dec!("0.99"),
936            borrow_origination_fee: Fee::zero(),
937            borrow_interest_rate_strategy: InterestRateStrategy::zero(),
938            borrow_maximum_duration_ms: None,
939            borrow_range: (1, None).try_into().unwrap(),
940            supply_range: (1, None).try_into().unwrap(),
941            supply_withdrawal_range: (1, None).try_into().unwrap(),
942            supply_withdrawal_fee: TimeBasedFee::zero(),
943            yield_weights: YieldWeights::new_with_supply_weight(9)
944                .with_static("revenue.tmplr.near".parse().unwrap(), 1),
945            protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
946            liquidation_maximum_spread: dec!("0.05"),
947        };
948
949        let mut market = Market::new(b"m", configuration.clone());
950        market.borrow_asset_deposited_active += BorrowAssetAmount::new(100_000_000_000);
951        market.borrow_asset_balance += BorrowAssetAmount::new(100_000_000_000);
952        let snapshot_proof = market.snapshot();
953
954        let mut position = BorrowPositionGuard(BorrowPositionRef {
955            market: &mut market,
956            account_id: "borrower".parse().unwrap(),
957            position: BorrowPosition::new(1),
958        });
959
960        let interest_proof = position.accumulate_interest();
961        position.record_collateral_asset_deposit(
962            interest_proof,
963            CollateralAssetAmount::new(100_000_000),
964        );
965        let initial_price_pair = PricePair::new(
966            &pyth::Price {
967                price: 5.into(),
968                conf: 0.into(),
969                expo: 24,
970                publish_time: PythTimestamp::from_secs(10),
971            },
972            24,
973            &pyth::Price {
974                price: 1.into(),
975                conf: 0.into(),
976                expo: 24,
977                publish_time: PythTimestamp::from_secs(10),
978            },
979            24,
980        )
981        .unwrap();
982        assert_eq!(
983            position.liquidatable_collateral(&initial_price_pair),
984            CollateralAssetAmount::zero(),
985        );
986        let initial_borrow = position
987            .record_borrow_initial(
988                snapshot_proof,
989                interest_proof,
990                BorrowAssetAmount::new(85_000_000),
991                &initial_price_pair,
992                env::block_timestamp_ms(),
993            )
994            .unwrap();
995        position.record_borrow_final(
996            snapshot_proof,
997            interest_proof,
998            &initial_borrow,
999            true,
1000            env::block_timestamp_ms(),
1001        );
1002        let price_pair = PricePair::new(
1003            &pyth::Price {
1004                price: collateral_price.into(),
1005                conf: conf.into(),
1006                expo: 24,
1007                publish_time: PythTimestamp::from_secs(10),
1008            },
1009            24,
1010            &pyth::Price {
1011                price: borrow_price.into(),
1012                conf: conf.into(),
1013                expo: 24,
1014                publish_time: PythTimestamp::from_secs(10),
1015            },
1016            24,
1017        )
1018        .unwrap();
1019        let starting_cr = position.inner().collateralization_ratio(&price_pair);
1020        eprintln!("Starting collateralization ratio: {starting_cr:?}");
1021        let liquidatable_collateral = position.liquidatable_collateral(&price_pair);
1022
1023        let minimum_acceptable = configuration
1024            .minimum_acceptable_liquidation_amount(liquidatable_collateral, &price_pair)
1025            .unwrap();
1026
1027        eprintln!("Liquidatable collateral: {liquidatable_collateral}");
1028        eprintln!("Minimum acceptable: {minimum_acceptable}");
1029
1030        match collateral_price.ilog10().cmp(&borrow_price.ilog10()) {
1031            std::cmp::Ordering::Less => {
1032                // Completely underwater
1033                assert_eq!(
1034                    liquidatable_collateral,
1035                    CollateralAssetAmount::new(100_000_000),
1036                    "All collateral should be eligible for liquidation"
1037                );
1038            }
1039            std::cmp::Ordering::Equal => {
1040                // Partial liquidation
1041
1042                let _liquidation = position
1043                    .record_liquidation(
1044                        interest_proof,
1045                        "liquidator".parse().unwrap(),
1046                        minimum_acceptable,
1047                        Some(liquidatable_collateral),
1048                        &price_pair,
1049                        env::block_timestamp_ms(),
1050                    )
1051                    .unwrap();
1052
1053                let finishing_cr = position
1054                    .inner()
1055                    .collateralization_ratio(&price_pair)
1056                    .unwrap();
1057                eprintln!("Finishing collateralization ratio: {finishing_cr}");
1058                eprintln!("Target MCR: {mcr}");
1059
1060                assert!(finishing_cr >= mcr);
1061                let delta = finishing_cr.abs_diff(mcr);
1062                assert!(delta < Decimal::ONE.mul_pow10(-4).unwrap());
1063            }
1064            std::cmp::Ordering::Greater => {
1065                // No liquidation
1066
1067                assert_eq!(
1068                    liquidatable_collateral,
1069                    CollateralAssetAmount::zero(),
1070                    "No collateral should be liquidatable"
1071                );
1072            }
1073        }
1074    }
1075
1076    #[test]
1077    fn test_borrow_position_deserialize_new_format() {
1078        // New market format with interest field
1079        let json = r#"{
1080            "started_at_block_timestamp_ms": "1699564800000",
1081            "collateral_asset_deposit": "1000000000000000000000000",
1082            "borrow_asset_principal": "100000000",
1083            "interest": {
1084                "total": "0",
1085                "fraction_as_u128_dividend": "0",
1086                "next_snapshot_index": 42,
1087                "pending_estimate": "0"
1088            },
1089            "fees": "500000",
1090            "borrow_asset_in_flight": "50000000",
1091            "collateral_asset_in_flight": "0",
1092            "liquidation_lock": "0"
1093        }"#;
1094
1095        let position: BorrowPosition =
1096            serde_json::from_str(json).expect("Failed to deserialize new format");
1097        assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
1098        assert_eq!(
1099            position.get_borrow_asset_principal(),
1100            BorrowAssetAmount::new(50_000_000 + 100_000_000)
1101        );
1102    }
1103
1104    #[test]
1105    fn test_borrow_position_deserialize_old_format_with_borrow_asset_fees() {
1106        // Old market format with borrow_asset_fees instead of interest
1107        let json = r#"{
1108            "started_at_block_timestamp_ms": "1699564800000",
1109            "collateral_asset_deposit": "1000000000000000000000000",
1110            "borrow_asset_principal": "100000000",
1111            "borrow_asset_fees": {
1112                "total": "0",
1113                "fraction_as_u128_dividend": "0",
1114                "next_snapshot_index": 42,
1115                "pending_estimate": "0"
1116            },
1117            "fees": "500000",
1118            "borrow_asset_in_flight": "0",
1119            "collateral_asset_in_flight": "0",
1120            "liquidation_lock": "0"
1121        }"#;
1122
1123        let position: BorrowPosition =
1124            serde_json::from_str(json).expect("Failed to deserialize old format");
1125        assert_eq!(position.fees, BorrowAssetAmount::new(500_000));
1126        assert_eq!(
1127            position.get_borrow_asset_principal(),
1128            BorrowAssetAmount::new(100_000_000)
1129        );
1130        assert_eq!(
1131            position.get_total_collateral_amount(),
1132            CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1133        );
1134    }
1135
1136    #[test]
1137    fn test_borrow_position_deserialize_defaults() {
1138        // Minimal JSON with only required fields, others should use defaults
1139        let json = r#"{
1140            "collateral_asset_deposit": "1000000000000000000000000",
1141            "borrow_asset_principal": "100000000",
1142            "interest": {
1143                "total": "0",
1144                "fraction_as_u128_dividend": "0",
1145                "next_snapshot_index": 42,
1146                "pending_estimate": "0"
1147            }
1148        }"#;
1149
1150        let position: BorrowPosition =
1151            serde_json::from_str(json).expect("Failed to deserialize with defaults");
1152        assert_eq!(position.started_at_block_timestamp_ms, None);
1153        assert_eq!(position.fees, BorrowAssetAmount::new(0));
1154        assert_eq!(
1155            position.get_total_collateral_amount(),
1156            CollateralAssetAmount::new(1_000_000_000_000_000_000_000_000)
1157        );
1158    }
1159
1160    // Backstop for `fuzz_borrow_overflow`: that target asserts correctness up
1161    // to the boundary but cannot observe the abort itself (libfuzzer-sys
1162    // aborts on panics before catch_unwind sees them). The contract's safety
1163    // property — overflow on principal + in_flight aborts — is asserted here.
1164    #[test]
1165    #[should_panic(expected = "attempt to add with overflow")]
1166    fn liability_overflow_aborts_on_principal_plus_in_flight() {
1167        let mut position = BorrowPosition::new(0);
1168        position.borrow_asset_principal = BorrowAssetAmount::new(u128::MAX);
1169        position.borrow_asset_in_flight = BorrowAssetAmount::new(1);
1170        let _ = position.get_total_borrow_asset_liability();
1171    }
1172
1173    // Backstop for `fuzz_liquidations` (ENG-342). That target predicts and
1174    // skips the `1 < cr < mcr` band when `mcr * (1 - spread) <= 1`, because the
1175    // denominator `mcr * discount - 1` underflows there and libfuzzer-sys can't
1176    // distinguish that abort from a real crash. The abort — the actual symptom
1177    // of the tracked bug — is asserted here so the suppressed region is still
1178    // pinned down. (`Decimal`'s U512 subtraction underflow panics with
1179    // "arithmetic operation overflow", unlike a std-integer add.)
1180    #[test]
1181    #[should_panic(expected = "arithmetic operation overflow")]
1182    fn liquidatable_collateral_denominator_underflow_aborts() {
1183        use templar_primitives::dec;
1184
1185        use crate::oracle::pyth::PythTimestamp;
1186
1187        // Equal prices/decimals ⇒ cr is the pure amount ratio. collateral=102,
1188        // liability=100 ⇒ cr=1.02, inside (1, mcr=1.05). With spread=0.1,
1189        // discount=0.9 and mcr*discount=0.945 <= 1, so `mcr*discount - 1`
1190        // underflows on unsigned `Decimal` subtraction.
1191        let price = pyth::Price {
1192            price: 1.into(),
1193            conf: 0.into(),
1194            expo: 0,
1195            publish_time: PythTimestamp::from_secs(0),
1196        };
1197        let price_pair = PricePair::new(&price, 0, &price, 0).unwrap();
1198
1199        let mut position = BorrowPosition::new(0);
1200        position.collateral_asset_deposit = CollateralAssetAmount::new(102);
1201        position.borrow_asset_principal = BorrowAssetAmount::new(100);
1202
1203        let _ = position.liquidatable_collateral(&price_pair, dec!("1.05"), dec!("0.1"));
1204    }
1205}