templar_common/market/
configuration.rs

1use std::{io::ErrorKind, ops::Deref};
2
3use near_sdk::{borsh, json_types::U64, near, AccountId};
4
5use crate::{
6    asset::{
7        AssetClass, BorrowAsset, BorrowAssetAmount, CollateralAsset, CollateralAssetAmount,
8        FungibleAsset, FungibleAssetAmount,
9    },
10    borrow::{BorrowStatus, LiquidationReason},
11    fee::{Fee, TimeBasedFee},
12    interest_rate_strategy::InterestRateStrategy,
13    number::Decimal,
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
349    use crate::{dec, oracle::pyth::PriceIdentifier};
350
351    use super::*;
352
353    #[rstest]
354    #[case(1, 0)]
355    #[case(0, 0)]
356    #[case(u128::MAX, 0)]
357    #[case(u128::MAX, u128::MAX - 1)]
358    #[case(500, 10)]
359    #[should_panic = "Invalid range specified"]
360    fn invalid_amount_range(#[case] min: u128, #[case] max: u128) {
361        ValidAmountRange::<BorrowAsset>::try_from((min, Some(max))).unwrap();
362    }
363
364    #[rstest]
365    #[case(1, 0)]
366    #[case(0, 0)]
367    #[case(u128::MAX, 0)]
368    #[case(u128::MAX, u128::MAX - 1)]
369    #[case(500, 10)]
370    #[should_panic = "Invalid range specified"]
371    fn invalid_amount_range_json(#[case] min: u128, #[case] max: u128) {
372        serde_json::from_value::<ValidAmountRange<BorrowAsset>>(json!({
373            "minimum": U128(min),
374            "maximum": U128(max),
375        }))
376        .unwrap();
377    }
378
379    #[rstest]
380    #[case(1, 1)]
381    #[case(0, u128::MAX)]
382    #[case(1, u128::MAX)]
383    #[case(u128::MAX, u128::MAX)]
384    #[case(u128::MAX - 1, u128::MAX)]
385    #[case(10, 500)]
386    fn valid_amount_range(#[case] min: u128, #[case] max: u128) {
387        ValidAmountRange::<BorrowAsset>::try_from((min, Some(max))).unwrap();
388    }
389
390    #[rstest]
391    #[case(1, 1)]
392    #[case(0, u128::MAX)]
393    #[case(1, u128::MAX)]
394    #[case(u128::MAX, u128::MAX)]
395    #[case(u128::MAX - 1, u128::MAX)]
396    #[case(10, 500)]
397    fn valid_amount_range_json(#[case] min: u128, #[case] max: u128) {
398        serde_json::from_value::<ValidAmountRange<BorrowAsset>>(json!({
399            "minimum": U128(min),
400            "maximum": U128(max),
401        }))
402        .unwrap();
403    }
404
405    #[test]
406    fn single_snapshot_maximum_interest() {
407        let c = MarketConfiguration {
408            time_chunk_configuration: TimeChunkConfiguration::new(600_000),
409            borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
410            collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
411            price_oracle_configuration: PriceOracleConfiguration {
412                account_id: "pyth-oracle.near".parse().unwrap(),
413                collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
414                collateral_asset_decimals: 24,
415                borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
416                borrow_asset_decimals: 24,
417                price_maximum_age_s: 60,
418            },
419            borrow_mcr_maintenance: dec!("1.25"),
420            borrow_mcr_liquidation: dec!("1.2"),
421            borrow_asset_maximum_usage_ratio: dec!("0.99"),
422            borrow_origination_fee: Fee::zero(),
423            borrow_interest_rate_strategy: InterestRateStrategy::linear(dec!("0.1"), dec!("0.1"))
424                .unwrap(),
425            borrow_maximum_duration_ms: None,
426            borrow_range: (1, None).try_into().unwrap(),
427            supply_range: (1, None).try_into().unwrap(),
428            supply_withdrawal_range: (1, None).try_into().unwrap(),
429            supply_withdrawal_fee: TimeBasedFee::zero(),
430            yield_weights: YieldWeights::new_with_supply_weight(9)
431                .with_static("revenue.tmplr.near".parse().unwrap(), 1),
432            protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
433            liquidation_maximum_spread: dec!("0.05"),
434        };
435
436        let actual = c.single_snapshot_maximum_interest();
437
438        let apr = dec!("0.1");
439        let single_snapshot_duration_ms = dec!("600000");
440        let expected =
441            apr * single_snapshot_duration_ms / (1000u32 * 60 * 60 * 24) / dec!("365.2425");
442
443        assert!(actual.abs_diff(expected) < Decimal::ONE.mul_pow10(-34).unwrap());
444    }
445}