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
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 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}