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            return Err(error::out_of_bounds("liquidation_maximum_spread"));
266        }
267
268        Ok(())
269    }
270
271    pub fn borrow_status(
272        &self,
273        collateralization_ratio: Option<Decimal>,
274        started_at_block_timestamp_ms: Option<impl Into<u64>>,
275        block_timestamp_ms: u64,
276    ) -> BorrowStatus {
277        if started_at_block_timestamp_ms.is_some_and(|started_at| {
278            !self.is_within_maximum_borrow_duration(started_at.into(), block_timestamp_ms)
279        }) {
280            return BorrowStatus::Liquidation(LiquidationReason::Expiration);
281        }
282
283        if let Some(cr) = collateralization_ratio {
284            if cr < self.borrow_mcr_liquidation {
285                return BorrowStatus::Liquidation(LiquidationReason::Undercollateralization);
286            }
287
288            if cr < self.borrow_mcr_maintenance {
289                return BorrowStatus::MaintenanceRequired;
290            }
291        }
292
293        BorrowStatus::Healthy
294    }
295
296    fn is_within_maximum_borrow_duration(
297        &self,
298        started_at_block_timestamp_ms: u64,
299        block_timestamp_ms: u64,
300    ) -> bool {
301        let Some(U64(maximum_duration_ms)) = self.borrow_maximum_duration_ms else {
302            return true;
303        };
304        block_timestamp_ms
305            .checked_sub(started_at_block_timestamp_ms)
306            .is_none_or(|duration_ms| duration_ms <= maximum_duration_ms)
307    }
308
309    pub fn minimum_acceptable_liquidation_amount(
310        &self,
311        amount: CollateralAssetAmount,
312        price_pair: &PricePair,
313    ) -> Option<BorrowAssetAmount> {
314        ((1u32 - self.liquidation_maximum_spread) * price_pair.convert(amount))
315            .to_u128_ceil()
316            .map(BorrowAssetAmount::new)
317    }
318
319    pub fn single_snapshot_maximum_interest(&self) -> Decimal {
320        self.borrow_interest_rate_strategy.at(Decimal::ONE)
321            * self.time_chunk_configuration.duration_ms()
322            * YEAR_PER_MS
323    }
324
325    pub fn supply_yield_rate_from_interest(&self, snapshot: &Snapshot) -> Decimal {
326        if snapshot.borrow_asset_deposited_active.is_zero() {
327            return Decimal::ZERO;
328        }
329        let deposited: Decimal = snapshot.borrow_asset_deposited_active.into();
330        let borrowed: Decimal = snapshot.borrow_asset_borrowed.into();
331        let supply_weight: Decimal = self.yield_weights.supply.get().into();
332        let total_weight: Decimal = self.yield_weights.total_weight().get().into();
333
334        snapshot.interest_rate * borrowed * supply_weight / deposited / total_weight
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use near_sdk::{
341        json_types::U128,
342        serde_json::{self, json},
343    };
344    use rstest::rstest;
345
346    use crate::{dec, oracle::pyth::PriceIdentifier};
347
348    use super::*;
349
350    #[rstest]
351    #[case(1, 0)]
352    #[case(0, 0)]
353    #[case(u128::MAX, 0)]
354    #[case(u128::MAX, u128::MAX - 1)]
355    #[case(500, 10)]
356    #[should_panic = "Invalid range specified"]
357    fn invalid_amount_range(#[case] min: u128, #[case] max: u128) {
358        ValidAmountRange::<BorrowAsset>::try_from((min, Some(max))).unwrap();
359    }
360
361    #[rstest]
362    #[case(1, 0)]
363    #[case(0, 0)]
364    #[case(u128::MAX, 0)]
365    #[case(u128::MAX, u128::MAX - 1)]
366    #[case(500, 10)]
367    #[should_panic = "Invalid range specified"]
368    fn invalid_amount_range_json(#[case] min: u128, #[case] max: u128) {
369        serde_json::from_value::<ValidAmountRange<BorrowAsset>>(json!({
370            "minimum": U128(min),
371            "maximum": U128(max),
372        }))
373        .unwrap();
374    }
375
376    #[rstest]
377    #[case(1, 1)]
378    #[case(0, u128::MAX)]
379    #[case(1, u128::MAX)]
380    #[case(u128::MAX, u128::MAX)]
381    #[case(u128::MAX - 1, u128::MAX)]
382    #[case(10, 500)]
383    fn valid_amount_range(#[case] min: u128, #[case] max: u128) {
384        ValidAmountRange::<BorrowAsset>::try_from((min, Some(max))).unwrap();
385    }
386
387    #[rstest]
388    #[case(1, 1)]
389    #[case(0, u128::MAX)]
390    #[case(1, u128::MAX)]
391    #[case(u128::MAX, u128::MAX)]
392    #[case(u128::MAX - 1, u128::MAX)]
393    #[case(10, 500)]
394    fn valid_amount_range_json(#[case] min: u128, #[case] max: u128) {
395        serde_json::from_value::<ValidAmountRange<BorrowAsset>>(json!({
396            "minimum": U128(min),
397            "maximum": U128(max),
398        }))
399        .unwrap();
400    }
401
402    #[test]
403    fn single_snapshot_maximum_interest() {
404        let c = MarketConfiguration {
405            time_chunk_configuration: TimeChunkConfiguration::new(600_000),
406            borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
407            collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
408            price_oracle_configuration: PriceOracleConfiguration {
409                account_id: "pyth-oracle.near".parse().unwrap(),
410                collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
411                collateral_asset_decimals: 24,
412                borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
413                borrow_asset_decimals: 24,
414                price_maximum_age_s: 60,
415            },
416            borrow_mcr_maintenance: dec!("1.25"),
417            borrow_mcr_liquidation: dec!("1.2"),
418            borrow_asset_maximum_usage_ratio: dec!("0.99"),
419            borrow_origination_fee: Fee::zero(),
420            borrow_interest_rate_strategy: InterestRateStrategy::linear(dec!("0.1"), dec!("0.1"))
421                .unwrap(),
422            borrow_maximum_duration_ms: None,
423            borrow_range: (1, None).try_into().unwrap(),
424            supply_range: (1, None).try_into().unwrap(),
425            supply_withdrawal_range: (1, None).try_into().unwrap(),
426            supply_withdrawal_fee: TimeBasedFee::zero(),
427            yield_weights: YieldWeights::new_with_supply_weight(9)
428                .with_static("revenue.tmplr.near".parse().unwrap(), 1),
429            protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
430            liquidation_maximum_spread: dec!("0.05"),
431        };
432
433        let actual = c.single_snapshot_maximum_interest();
434
435        let apr = dec!("0.1");
436        let single_snapshot_duration_ms = dec!("600000");
437        let expected =
438            apr * single_snapshot_duration_ms / (1000u32 * 60 * 60 * 24) / dec!("365.2425");
439
440        assert!(actual.abs_diff(expected) < Decimal::ONE.mul_pow10(-34).unwrap());
441    }
442}