templar_common/market/
mod.rs

1use std::collections::HashMap;
2use std::num::NonZeroU16;
3
4use near_sdk::{near, AccountId};
5use templar_primitives::number::Decimal;
6
7use crate::asset::{BorrowAssetAmount, CollateralAssetAmount};
8mod configuration;
9pub use configuration::{MarketConfiguration, ValidAmountRange, APY_LIMIT};
10mod external;
11pub use external::*;
12mod r#impl;
13pub use r#impl::*;
14mod price_oracle_configuration;
15pub use price_oracle_configuration::PriceOracleConfiguration;
16
17pub mod error {
18    pub use super::configuration::error::*;
19    pub use super::price_oracle_configuration::error::*;
20}
21
22#[derive(Clone, Debug)]
23#[near(serializers = [borsh, json])]
24pub struct BorrowAssetMetrics {
25    pub available: BorrowAssetAmount,
26    pub deposited_active: BorrowAssetAmount,
27    pub deposited_incoming: HashMap<u32, BorrowAssetAmount>,
28    pub borrowed: BorrowAssetAmount,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32#[near(serializers = [json, borsh])]
33pub struct YieldWeights {
34    pub supply: NonZeroU16,
35    pub r#static: HashMap<AccountId, u16>,
36}
37
38impl YieldWeights {
39    /// # Panics
40    /// - If `supply` is zero.
41    #[allow(clippy::unwrap_used, reason = "Only used during initial construction")]
42    pub fn new_with_supply_weight(supply: u16) -> Self {
43        Self {
44            supply: supply.try_into().unwrap(),
45            r#static: HashMap::new(),
46        }
47    }
48
49    #[must_use]
50    pub fn with_static(mut self, account_id: AccountId, weight: u16) -> Self {
51        self.r#static.insert(account_id, weight);
52        self
53    }
54
55    pub fn total_weight(&self) -> NonZeroU16 {
56        self.r#static
57            .values()
58            .try_fold(self.supply, |a, b| a.checked_add(*b))
59            .unwrap_or_else(|| crate::panic_with_message("Total weight overflow"))
60    }
61
62    pub fn static_share(&self, account_id: &AccountId) -> Decimal {
63        self.r#static
64            .get(account_id)
65            .map_or(Decimal::ZERO, |weight| {
66                Decimal::from(*weight) / u16::from(self.total_weight())
67            })
68    }
69}
70
71/// Parsed from the string parameter `msg` passed by `*_transfer_call` to
72/// `*_on_transfer` calls.
73#[derive(Debug)]
74#[near(serializers = [json])]
75pub enum DepositMsg {
76    /// Add the attached tokens to the sender's supply position's deposit.
77    Supply,
78    /// Add the attached tokens to the sender's borrow position's collateral
79    /// deposit.
80    Collateralize,
81    /// Use the attached tokens to pay down the sender's borrow position's
82    /// liability (sans fees).
83    Repay,
84    /// Use the attached tokens to pay down a specified borrow position's
85    /// liability (sans fees).
86    RepayAccount(RepayAccountMsg),
87    /// Liquidate an account that is below the configured liquidation
88    /// collateralization ratio threshold.
89    Liquidate(LiquidateMsg),
90}
91
92impl DepositMsg {
93    pub fn expects_borrow_asset(&self) -> bool {
94        match self {
95            Self::Supply | Self::Repay | Self::RepayAccount(..) | Self::Liquidate(..) => true,
96            Self::Collateralize => false,
97        }
98    }
99}
100
101/// Indicate an account to repay.
102#[derive(Debug)]
103#[near(serializers = [json])]
104pub struct RepayAccountMsg {
105    pub account_id: AccountId,
106}
107
108/// Indicate an account to liquidate.
109#[derive(Debug)]
110#[near(serializers = [json])]
111pub struct LiquidateMsg {
112    pub account_id: AccountId,
113    /// How much collateral to liquidate?
114    /// Attempts to liquidate the whole position if `None`.
115    pub amount: Option<CollateralAssetAmount>,
116}
117
118#[derive(Clone, Debug)]
119#[near(serializers = [json, borsh])]
120pub struct Withdrawal {
121    pub account_id: AccountId,
122    pub amount_to_account: BorrowAssetAmount,
123    pub amount_to_fees: BorrowAssetAmount,
124}
125
126#[cfg(test)]
127mod tests {
128    use near_sdk::{
129        json_types::U128,
130        serde_json::{self, json, Value},
131    };
132
133    use super::*;
134
135    /// Parse the wire `msg` shape, assert re-serializing reproduces it exactly,
136    /// and return the parsed message. This is the regression guard for the
137    /// `msg` strings passed to `ft_transfer_call`/`mt_transfer_call`: the
138    /// contract deserializes `msg` into [`DepositMsg`] through this same path.
139    fn roundtrip(wire: &Value) -> DepositMsg {
140        let parsed: DepositMsg = serde_json::from_value(wire.clone()).unwrap();
141        assert_eq!(&serde_json::to_value(&parsed).unwrap(), wire);
142        parsed
143    }
144
145    #[test]
146    fn deposit_msg_supply() {
147        let msg = roundtrip(&json!("Supply"));
148        assert!(matches!(msg, DepositMsg::Supply));
149        assert!(msg.expects_borrow_asset());
150    }
151
152    #[test]
153    fn deposit_msg_collateralize() {
154        let msg = roundtrip(&json!("Collateralize"));
155        assert!(matches!(msg, DepositMsg::Collateralize));
156        assert!(!msg.expects_borrow_asset());
157    }
158
159    #[test]
160    fn deposit_msg_repay() {
161        let msg = roundtrip(&json!("Repay"));
162        assert!(matches!(msg, DepositMsg::Repay));
163        assert!(msg.expects_borrow_asset());
164    }
165
166    #[test]
167    fn deposit_msg_repay_account() {
168        let msg = roundtrip(&json!({ "RepayAccount": { "account_id": "borrow_user.near" } }));
169        let DepositMsg::RepayAccount(RepayAccountMsg { account_id }) = &msg else {
170            panic!("expected RepayAccount, got {msg:?}");
171        };
172        assert_eq!(account_id.as_str(), "borrow_user.near");
173        assert!(msg.expects_borrow_asset());
174    }
175
176    #[test]
177    fn deposit_msg_liquidate() {
178        let msg = roundtrip(&json!({
179            "Liquidate": { "account_id": "borrow_user.near", "amount": U128(1_000_000) },
180        }));
181        let DepositMsg::Liquidate(LiquidateMsg { account_id, amount }) = &msg else {
182            panic!("expected Liquidate, got {msg:?}");
183        };
184        assert_eq!(account_id.as_str(), "borrow_user.near");
185        assert_eq!(*amount, Some(CollateralAssetAmount::new(1_000_000)));
186        assert!(msg.expects_borrow_asset());
187    }
188
189    #[test]
190    fn deposit_msg_liquidate_whole_position() {
191        // Omitting `amount` liquidates the whole position. (A `None` amount
192        // re-serializes as `"amount": null`, so this case is parse-only.)
193        let msg: DepositMsg =
194            serde_json::from_value(json!({ "Liquidate": { "account_id": "borrow_user.near" } }))
195                .unwrap();
196        let DepositMsg::Liquidate(LiquidateMsg { amount, .. }) = &msg else {
197            panic!("expected Liquidate, got {msg:?}");
198        };
199        assert_eq!(*amount, None);
200    }
201}