templar_common/market/
configuration.rs

1use std::{io::ErrorKind, ops::Deref};
2
3use near_sdk::{borsh, json_types::U64, near, AccountId};
4use templar_primitives::number::Decimal;
5
6use crate::{
7    asset::{
8        AssetClass, BorrowAsset, BorrowAssetAmount, CollateralAsset, CollateralAssetAmount,
9        FungibleAsset, FungibleAssetAmount,
10    },
11    borrow::{BorrowStatus, LiquidationReason},
12    fee::{Fee, TimeBasedFee},
13    interest_rate_strategy::InterestRateStrategy,
14    price::{Convert, PricePair},
15    snapshot::Snapshot,
16    time_chunk::TimeChunkConfiguration,
17    YEAR_PER_MS,
18};
19
20use super::{PriceOracleConfiguration, YieldWeights};
21
22/// Reject >10,000,000% APY interest rates as misconfigurations.
23/// This also guarantees a reasonable upper-limit to interest rates to help avoid overflows.
24pub const APY_LIMIT: u128 = 100_000;
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27#[near(serializers = [borsh, json])]
28#[serde(try_from = "AmountRange::<A>")]
29pub struct ValidAmountRange<A: AssetClass + PartialOrd>(
30    #[borsh(deserialize_with = "deserialize_valid_amount_range")] AmountRange<A>,
31);
32
33fn deserialize_valid_amount_range<
34    R: borsh::io::Read,
35    A: AssetClass + PartialOrd + borsh::BorshDeserialize,
36>(
37    reader: &mut R,
38) -> ::core::result::Result<AmountRange<A>, borsh::io::Error> {
39    <AmountRange<A> as borsh::BorshDeserialize>::deserialize_reader(reader)?.validate()
40}
41
42impl<A: AssetClass + PartialOrd> Deref for ValidAmountRange<A> {
43    type Target = AmountRange<A>;
44
45    fn deref(&self) -> &Self::Target {
46        &self.0
47    }
48}
49
50impl<A: AssetClass + PartialOrd> TryFrom<AmountRange<A>> for ValidAmountRange<A> {
51    type Error = std::io::Error;
52
53    fn try_from(value: AmountRange<A>) -> Result<Self, Self::Error> {
54        Ok(Self(value.validate()?))
55    }
56}
57
58impl<A: AssetClass + PartialOrd, T: Into<FungibleAssetAmount<A>>> TryFrom<(T, Option<T>)>
59    for ValidAmountRange<A>
60{
61    type Error = std::io::Error;
62
63    fn try_from((minimum, maximum): (T, Option<T>)) -> Result<Self, Self::Error> {
64        AmountRange {
65            minimum: minimum.into(),
66            maximum: maximum.map(Into::into),
67        }
68        .try_into()
69    }
70}
71
72#[derive(Clone, Debug, PartialEq, Eq)]
73#[near(serializers = [borsh, json])]
74pub struct AmountRange<A: AssetClass> {
75    pub minimum: FungibleAssetAmount<A>,
76    pub maximum: Option<FungibleAssetAmount<A>>,
77}
78
79impl<A: AssetClass + PartialOrd> AmountRange<A> {
80    pub fn contains(&self, amount: FungibleAssetAmount<A>) -> bool {
81        amount >= self.minimum && self.maximum.is_none_or(|max| amount <= max)
82    }
83
84    pub fn validate(self) -> std::io::Result<Self> {
85        if self.is_valid() {
86            Ok(self)
87        } else {
88            Err(std::io::Error::new(
89                ErrorKind::InvalidInput,
90                "Invalid range specified",
91            ))
92        }
93    }
94
95    pub fn is_valid(&self) -> bool {
96        self.maximum
97            .is_none_or(|max| !max.is_zero() && max >= self.minimum)
98    }
99
100    pub fn new(
101        minimum: FungibleAssetAmount<A>,
102        maximum: Option<FungibleAssetAmount<A>>,
103    ) -> std::io::Result<Self> {
104        Self { minimum, maximum }.validate()
105    }
106}
107
108/// Configuration for a single asset-pair borrow market.
109///
110/// A market's configuration is immutable after deployment.
111#[derive(Clone, Debug, PartialEq, Eq)]
112#[near(serializers = [json, borsh])]
113pub struct MarketConfiguration {
114    /// As time passes, the market creates snapshots of its state. These
115    /// snapshots are used to calculate the interest charged to borrowers,
116    /// yield earned by suppliers, etc. A **time chunk** represents the period
117    /// of time over which a snapshot is taken, and is used as a disambiguating
118    /// index for snapshots.
119    pub time_chunk_configuration: TimeChunkConfiguration,
120    /// The borrow asset supported by this market.
121    pub borrow_asset: FungibleAsset<BorrowAsset>,
122    /// The collateral asset supported by this market.
123    pub collateral_asset: FungibleAsset<CollateralAsset>,
124    /// The market communicates with a price oracle to determine asset
125    /// valuations.
126    pub price_oracle_configuration: PriceOracleConfiguration,
127    /// A borrow position must satisfy this minimum collateralization ratio
128    /// after any modifications (e.g. withdrawing collateral).
129    ///
130    /// Must be greater than or equal to `borrow_mcr_liquidation`.
131    pub borrow_mcr_maintenance: Decimal,
132    /// A borrow position is eligible for liquidation if it does not satisfy
133    /// this minimu collateralization ratio.
134    ///
135    /// Must be less than or equal to `borrow_mcr_maintenance`.
136    pub borrow_mcr_liquidation: Decimal,
137    /// Maintain a reserve of some% of the deposited supply; how much of the
138    /// deposited principal may be lent out (up to 100%)?
139    /// This is a matter of protection for supply providers.
140    pub borrow_asset_maximum_usage_ratio: Decimal,
141    /// The origination fee is a one-time amount added to the principal of the
142    /// borrow. That is to say, the origination fee is denominated in units of
143    /// the borrow asset and is paid by the borrowing account during repayment
144    /// (or liquidation).
145    pub borrow_origination_fee: Fee<BorrowAsset>,
146    /// Interest rate is decided by a function of utilization ratio [0.0, 1.0].
147    pub borrow_interest_rate_strategy: InterestRateStrategy,
148    /// If a maximum borrow duration is configured, a borrow position is
149    /// instantly eligible for liquidation (regardless of collateralization
150    /// ratio) after this period has expired.
151    pub borrow_maximum_duration_ms: Option<U64>,
152    /// A borrow position's principal must be within this range after modification.
153    pub borrow_range: ValidAmountRange<BorrowAsset>,
154    /// A supply position's deposit must be within this range after modification.
155    pub supply_range: ValidAmountRange<BorrowAsset>,
156    /// A supply position may only request to withdraw amounts within this range.
157    pub supply_withdrawal_range: ValidAmountRange<BorrowAsset>,
158    /// A time-bound fee for supply, to discourage extremely short-lived
159    /// supply positions.
160    pub supply_withdrawal_fee: TimeBasedFee<BorrowAsset>,
161    /// Determines how yield is distributed between suppliers (dynamically
162    /// allocated based on deposit) and statically-configured accounts (e.g. a
163    /// protocol insurance account).
164    pub yield_weights: YieldWeights,
165    /// For collecting supply withdrawal fees.
166    ///
167    /// Supply withdrawal fees cannot be distributed to other suppliers
168    /// because there may not be any suppliers to earn those fees after the
169    /// last one withdraws.
170    pub protocol_account_id: AccountId,
171    /// How far below market rate to accept liquidation? This is effectively the liquidator's spread.
172    ///
173    /// For example, if a 100USDC borrow is (under)collateralized with $110 of
174    /// NEAR, a "maximum liquidator spread" of 1% would mean that a liquidator
175    /// could liquidate this borrow by sending 108.9USDC, netting the liquidator
176    /// $110 * 1% = $1.1 of NEAR.
177    pub liquidation_maximum_spread: Decimal,
178}
179
180pub mod error {
181    use std::fmt::Display;
182
183    use thiserror::Error;
184
185    #[derive(Debug, Clone, Error)]
186    #[error("Invalid configuration field `{field}`: {reason}")]
187    pub struct ConfigurationValidationError {
188        field: &'static str,
189        reason: InvalidFieldReason,
190    }
191
192    #[derive(Debug, Clone)]
193    pub enum InvalidFieldReason {
194        OutOfBounds,
195        MustNotEqual(&'static str),
196    }
197
198    impl Display for InvalidFieldReason {
199        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200            match self {
201                Self::OutOfBounds => write!(f, "out of bounds"),
202                Self::MustNotEqual(other) => write!(f, "must not equal `{other}`"),
203            }
204        }
205    }
206
207    pub(super) fn out_of_bounds(field: &'static str) -> ConfigurationValidationError {
208        ConfigurationValidationError {
209            field,
210            reason: InvalidFieldReason::OutOfBounds,
211        }
212    }
213
214    pub(super) fn must_not_equal(
215        field: &'static str,
216        other: &'static str,
217    ) -> ConfigurationValidationError {
218        ConfigurationValidationError {
219            field,
220            reason: InvalidFieldReason::MustNotEqual(other),
221        }
222    }
223}
224
225impl MarketConfiguration {
226    /// # Errors
227    ///
228    /// If the configuration is invalid.
229    pub fn validate(&self) -> Result<(), error::ConfigurationValidationError> {
230        if self.borrow_asset == self.collateral_asset.clone().coerce() {
231            return Err(error::must_not_equal("borrow_asset", "collateral_asset"));
232        }
233
234        if self.borrow_mcr_maintenance <= 1u32
235            || self.borrow_mcr_maintenance < self.borrow_mcr_liquidation
236        {
237            return Err(error::out_of_bounds("borrow_mcr_maintenance"));
238        }
239
240        if self.borrow_mcr_liquidation <= 1u32 {
241            return Err(error::out_of_bounds("borrow_mcr_liquidation"));
242        }
243
244        if self.borrow_asset_maximum_usage_ratio.is_zero()
245            || self.borrow_asset_maximum_usage_ratio > 1u32
246        {
247            return Err(error::out_of_bounds("borrow_asset_maximum_usage_ratio"));
248        }
249
250        if self.borrow_interest_rate_strategy.at(Decimal::ONE) > APY_LIMIT {
251            return Err(error::out_of_bounds("borrow_interest_rate_strategy"));
252        }
253
254        if self.supply_withdrawal_range.minimum > self.supply_range.minimum {
255            return Err(error::out_of_bounds("supply_withdrawal_range.minimum"));
256        }
257
258        if let Fee::Flat(amount) = self.supply_withdrawal_fee.fee {
259            if amount > self.supply_withdrawal_range.minimum {
260                return Err(error::out_of_bounds("supply_withdrawal_fee.fee"));
261            }
262        }
263
264        if self.liquidation_maximum_spread >= 1u32
265            || self.borrow_mcr_liquidation * (Decimal::ONE - self.liquidation_maximum_spread)
266                <= Decimal::ONE
267        {
268            return Err(error::out_of_bounds("liquidation_maximum_spread"));
269        }
270
271        Ok(())
272    }
273
274    pub fn borrow_status(
275        &self,
276        collateralization_ratio: Option<Decimal>,
277        started_at_block_timestamp_ms: Option<impl Into<u64>>,
278        block_timestamp_ms: u64,
279    ) -> BorrowStatus {
280        if started_at_block_timestamp_ms.is_some_and(|started_at| {
281            !self.is_within_maximum_borrow_duration(started_at.into(), block_timestamp_ms)
282        }) {
283            return BorrowStatus::Liquidation(LiquidationReason::Expiration);
284        }
285
286        if let Some(cr) = collateralization_ratio {
287            if cr < self.borrow_mcr_liquidation {
288                return BorrowStatus::Liquidation(LiquidationReason::Undercollateralization);
289            }
290
291            if cr < self.borrow_mcr_maintenance {
292                return BorrowStatus::MaintenanceRequired;
293            }
294        }
295
296        BorrowStatus::Healthy
297    }
298
299    fn is_within_maximum_borrow_duration(
300        &self,
301        started_at_block_timestamp_ms: u64,
302        block_timestamp_ms: u64,
303    ) -> bool {
304        let Some(U64(maximum_duration_ms)) = self.borrow_maximum_duration_ms else {
305            return true;
306        };
307        block_timestamp_ms
308            .checked_sub(started_at_block_timestamp_ms)
309            .is_none_or(|duration_ms| duration_ms <= maximum_duration_ms)
310    }
311
312    pub fn minimum_acceptable_liquidation_amount(
313        &self,
314        amount: CollateralAssetAmount,
315        price_pair: &PricePair,
316    ) -> Option<BorrowAssetAmount> {
317        ((1u32 - self.liquidation_maximum_spread) * price_pair.convert(amount))
318            .to_u128_ceil()
319            .map(BorrowAssetAmount::new)
320    }
321
322    pub fn single_snapshot_maximum_interest(&self) -> Decimal {
323        self.borrow_interest_rate_strategy.at(Decimal::ONE)
324            * self.time_chunk_configuration.duration_ms()
325            * YEAR_PER_MS
326    }
327
328    pub fn supply_yield_rate_from_interest(&self, snapshot: &Snapshot) -> Decimal {
329        if snapshot.borrow_asset_deposited_active.is_zero() {
330            return Decimal::ZERO;
331        }
332        let deposited: Decimal = snapshot.borrow_asset_deposited_active.into();
333        let borrowed: Decimal = snapshot.borrow_asset_borrowed.into();
334        let supply_weight: Decimal = self.yield_weights.supply.get().into();
335        let total_weight: Decimal = self.yield_weights.total_weight().get().into();
336
337        snapshot.interest_rate * borrowed * supply_weight / deposited / total_weight
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use near_sdk::{
344        json_types::U128,
345        serde_json::{self, json},
346    };
347    use rstest::rstest;
348    use templar_primitives::dec;
349
350    use crate::{fee::TimeBasedFeeFunction, oracle::pyth::PriceIdentifier};
351
352    use super::*;
353
354    #[rstest]
355    #[case(1, 0)]
356    #[case(0, 0)]
357    #[case(u128::MAX, 0)]
358    #[case(u128::MAX, u128::MAX - 1)]
359    #[case(500, 10)]
360    #[should_panic = "Invalid range specified"]
361    fn invalid_amount_range(#[case] min: u128, #[case] max: u128) {
362        ValidAmountRange::<BorrowAsset>::try_from((min, Some(max))).unwrap();
363    }
364
365    #[rstest]
366    #[case(1, 0)]
367    #[case(0, 0)]
368    #[case(u128::MAX, 0)]
369    #[case(u128::MAX, u128::MAX - 1)]
370    #[case(500, 10)]
371    #[should_panic = "Invalid range specified"]
372    fn invalid_amount_range_json(#[case] min: u128, #[case] max: u128) {
373        serde_json::from_value::<ValidAmountRange<BorrowAsset>>(json!({
374            "minimum": U128(min),
375            "maximum": U128(max),
376        }))
377        .unwrap();
378    }
379
380    #[rstest]
381    #[case(1, 1)]
382    #[case(0, u128::MAX)]
383    #[case(1, u128::MAX)]
384    #[case(u128::MAX, u128::MAX)]
385    #[case(u128::MAX - 1, u128::MAX)]
386    #[case(10, 500)]
387    fn valid_amount_range(#[case] min: u128, #[case] max: u128) {
388        ValidAmountRange::<BorrowAsset>::try_from((min, Some(max))).unwrap();
389    }
390
391    #[rstest]
392    #[case(1, 1)]
393    #[case(0, u128::MAX)]
394    #[case(1, u128::MAX)]
395    #[case(u128::MAX, u128::MAX)]
396    #[case(u128::MAX - 1, u128::MAX)]
397    #[case(10, 500)]
398    fn valid_amount_range_json(#[case] min: u128, #[case] max: u128) {
399        serde_json::from_value::<ValidAmountRange<BorrowAsset>>(json!({
400            "minimum": U128(min),
401            "maximum": U128(max),
402        }))
403        .unwrap();
404    }
405
406    /// A baseline configuration that passes [`MarketConfiguration::validate`].
407    /// Validation tests mutate a single field to drive it out of bounds.
408    fn valid_configuration() -> MarketConfiguration {
409        MarketConfiguration {
410            time_chunk_configuration: TimeChunkConfiguration::new(600_000),
411            borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
412            collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
413            price_oracle_configuration: PriceOracleConfiguration {
414                account_id: "pyth-oracle.near".parse().unwrap(),
415                collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
416                collateral_asset_decimals: 24,
417                borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
418                borrow_asset_decimals: 24,
419                price_maximum_age_s: 60,
420            },
421            borrow_mcr_maintenance: dec!("1.25"),
422            borrow_mcr_liquidation: dec!("1.2"),
423            borrow_asset_maximum_usage_ratio: dec!("0.99"),
424            borrow_origination_fee: Fee::zero(),
425            borrow_interest_rate_strategy: InterestRateStrategy::linear(dec!("0.1"), dec!("0.1"))
426                .unwrap(),
427            borrow_maximum_duration_ms: None,
428            borrow_range: (1, None).try_into().unwrap(),
429            supply_range: (1, None).try_into().unwrap(),
430            supply_withdrawal_range: (1, None).try_into().unwrap(),
431            supply_withdrawal_fee: TimeBasedFee::zero(),
432            yield_weights: YieldWeights::new_with_supply_weight(9)
433                .with_static("revenue.tmplr.near".parse().unwrap(), 1),
434            protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
435            liquidation_maximum_spread: dec!("0.05"),
436        }
437    }
438
439    #[test]
440    fn valid_configuration_passes_validation() {
441        valid_configuration().validate().unwrap();
442    }
443
444    #[test]
445    fn borrow_asset_is_collateral_asset() {
446        let mut c = valid_configuration();
447        c.borrow_asset = c.collateral_asset.clone().coerce();
448        assert_eq!(
449            c.validate().unwrap_err().to_string(),
450            "Invalid configuration field `borrow_asset`: must not equal `collateral_asset`",
451        );
452    }
453
454    #[test]
455    fn borrow_interest_rate_strategy_exceed_apy_limit() {
456        let mut c = valid_configuration();
457        c.borrow_interest_rate_strategy =
458            InterestRateStrategy::linear(dec!("0"), dec!("100001")).unwrap();
459        assert_eq!(
460            c.validate().unwrap_err().to_string(),
461            "Invalid configuration field `borrow_interest_rate_strategy`: out of bounds",
462        );
463    }
464
465    #[test]
466    fn borrow_mcr_maintenance_less_than_1() {
467        let mut c = valid_configuration();
468        c.borrow_mcr_maintenance = dec!(".99");
469        assert_eq!(
470            c.validate().unwrap_err().to_string(),
471            "Invalid configuration field `borrow_mcr_maintenance`: out of bounds",
472        );
473    }
474
475    #[test]
476    fn borrow_mcr_maintenance_less_than_borrow_mcr_liquidation() {
477        let mut c = valid_configuration();
478        c.borrow_mcr_maintenance = dec!("1.2");
479        c.borrow_mcr_liquidation = dec!("1.200000001");
480        assert_eq!(
481            c.validate().unwrap_err().to_string(),
482            "Invalid configuration field `borrow_mcr_maintenance`: out of bounds",
483        );
484    }
485
486    #[test]
487    fn borrow_mcr_liquidation_less_than_1() {
488        let mut c = valid_configuration();
489        c.borrow_mcr_liquidation = dec!(".99");
490        assert_eq!(
491            c.validate().unwrap_err().to_string(),
492            "Invalid configuration field `borrow_mcr_liquidation`: out of bounds",
493        );
494    }
495
496    #[test]
497    fn borrow_asset_maximum_usage_ratio_is_zero() {
498        let mut c = valid_configuration();
499        c.borrow_asset_maximum_usage_ratio = dec!("0");
500        assert_eq!(
501            c.validate().unwrap_err().to_string(),
502            "Invalid configuration field `borrow_asset_maximum_usage_ratio`: out of bounds",
503        );
504    }
505
506    #[test]
507    fn borrow_asset_maximum_usage_ratio_greater_than_1() {
508        let mut c = valid_configuration();
509        c.borrow_asset_maximum_usage_ratio = dec!("1.0001");
510        assert_eq!(
511            c.validate().unwrap_err().to_string(),
512            "Invalid configuration field `borrow_asset_maximum_usage_ratio`: out of bounds",
513        );
514    }
515
516    #[test]
517    fn withdrawal_minimum_greater_than_supply_minimum() {
518        let mut c = valid_configuration();
519        c.supply_range = (1, None).try_into().unwrap();
520        c.supply_withdrawal_range = (2, None).try_into().unwrap();
521        assert_eq!(
522            c.validate().unwrap_err().to_string(),
523            "Invalid configuration field `supply_withdrawal_range.minimum`: out of bounds",
524        );
525    }
526
527    #[test]
528    fn withdrawal_fee_greater_than_withdrawal_minimum() {
529        let mut c = valid_configuration();
530        c.supply_range = (2, None).try_into().unwrap();
531        c.supply_withdrawal_range = (2, None).try_into().unwrap();
532        c.supply_withdrawal_fee = TimeBasedFee {
533            fee: Fee::Flat(100.into()),
534            duration: 100.into(),
535            behavior: TimeBasedFeeFunction::Linear,
536        };
537        assert_eq!(
538            c.validate().unwrap_err().to_string(),
539            "Invalid configuration field `supply_withdrawal_fee.fee`: out of bounds",
540        );
541    }
542
543    #[test]
544    fn liquidation_maximum_spread_greater_than_1() {
545        let mut c = valid_configuration();
546        c.liquidation_maximum_spread = dec!("2");
547        assert_eq!(
548            c.validate().unwrap_err().to_string(),
549            "Invalid configuration field `liquidation_maximum_spread`: out of bounds",
550        );
551    }
552
553    #[test]
554    fn liquidation_maximum_spread_mcr_underflow() {
555        let mut c = valid_configuration();
556        c.borrow_mcr_maintenance = dec!("1.5");
557        c.borrow_mcr_liquidation = dec!("1.1");
558        c.liquidation_maximum_spread = dec!("0.1");
559        assert_eq!(
560            c.validate().unwrap_err().to_string(),
561            "Invalid configuration field `liquidation_maximum_spread`: out of bounds",
562        );
563    }
564
565    // `valid_configuration` has maintenance MCR 1.25, liquidation MCR 1.2, and
566    // no maximum borrow duration.
567
568    #[test]
569    fn borrow_status_healthy_at_or_above_maintenance() {
570        let c = valid_configuration();
571        assert_eq!(
572            c.borrow_status(Some(dec!("1.5")), None::<u64>, 0),
573            BorrowStatus::Healthy,
574        );
575        // The maintenance threshold itself is healthy (comparison is strict).
576        assert_eq!(
577            c.borrow_status(Some(dec!("1.25")), None::<u64>, 0),
578            BorrowStatus::Healthy,
579        );
580    }
581
582    #[test]
583    fn borrow_status_maintenance_required_between_thresholds() {
584        let c = valid_configuration();
585        assert_eq!(
586            c.borrow_status(Some(dec!("1.24")), None::<u64>, 0),
587            BorrowStatus::MaintenanceRequired,
588        );
589        // The liquidation threshold itself is not yet liquidatable.
590        assert_eq!(
591            c.borrow_status(Some(dec!("1.2")), None::<u64>, 0),
592            BorrowStatus::MaintenanceRequired,
593        );
594    }
595
596    #[test]
597    fn borrow_status_liquidation_when_undercollateralized() {
598        let c = valid_configuration();
599        assert_eq!(
600            c.borrow_status(Some(dec!("1.19")), None::<u64>, 0),
601            BorrowStatus::Liquidation(LiquidationReason::Undercollateralization),
602        );
603    }
604
605    #[test]
606    fn borrow_status_healthy_without_ratio_or_duration_limit() {
607        let c = valid_configuration();
608        assert_eq!(
609            c.borrow_status(None, None::<u64>, 1_000),
610            BorrowStatus::Healthy,
611        );
612    }
613
614    #[test]
615    fn borrow_status_liquidation_on_expiration_overrides_ratio() {
616        let mut c = valid_configuration();
617        c.borrow_maximum_duration_ms = Some(1_000.into());
618        // Past the maximum duration: expiration liquidation regardless of a
619        // healthy collateralization ratio.
620        assert_eq!(
621            c.borrow_status(Some(dec!("5")), Some(0u64), 2_000),
622            BorrowStatus::Liquidation(LiquidationReason::Expiration),
623        );
624        // Within the maximum duration: the healthy ratio stands.
625        assert_eq!(
626            c.borrow_status(Some(dec!("5")), Some(0u64), 500),
627            BorrowStatus::Healthy,
628        );
629    }
630
631    #[test]
632    fn single_snapshot_maximum_interest() {
633        let c = valid_configuration();
634
635        let actual = c.single_snapshot_maximum_interest();
636
637        let apr = dec!("0.1");
638        let single_snapshot_duration_ms = dec!("600000");
639        let expected =
640            apr * single_snapshot_duration_ms / (1000u32 * 60 * 60 * 24) / dec!("365.2425");
641
642        assert!(actual.abs_diff(expected) < Decimal::ONE.mul_pow10(-34).unwrap());
643    }
644}