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
22pub 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#[derive(Clone, Debug, PartialEq, Eq)]
112#[near(serializers = [json, borsh])]
113pub struct MarketConfiguration {
114 pub time_chunk_configuration: TimeChunkConfiguration,
120 pub borrow_asset: FungibleAsset<BorrowAsset>,
122 pub collateral_asset: FungibleAsset<CollateralAsset>,
124 pub price_oracle_configuration: PriceOracleConfiguration,
127 pub borrow_mcr_maintenance: Decimal,
132 pub borrow_mcr_liquidation: Decimal,
137 pub borrow_asset_maximum_usage_ratio: Decimal,
141 pub borrow_origination_fee: Fee<BorrowAsset>,
146 pub borrow_interest_rate_strategy: InterestRateStrategy,
148 pub borrow_maximum_duration_ms: Option<U64>,
152 pub borrow_range: ValidAmountRange<BorrowAsset>,
154 pub supply_range: ValidAmountRange<BorrowAsset>,
156 pub supply_withdrawal_range: ValidAmountRange<BorrowAsset>,
158 pub supply_withdrawal_fee: TimeBasedFee<BorrowAsset>,
161 pub yield_weights: YieldWeights,
165 pub protocol_account_id: AccountId,
171 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 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 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 #[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 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 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 assert_eq!(
621 c.borrow_status(Some(dec!("5")), Some(0u64), 2_000),
622 BorrowStatus::Liquidation(LiquidationReason::Expiration),
623 );
624 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}