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::{AmountRange, 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    /// Repaid interest and fees available to pay supplier yield. Caps the
30    /// yield leg of every withdrawal and is shared market-wide,
31    /// first-come-first-served.
32    #[serde(default)]
33    pub paid_to_fees: BorrowAssetAmount,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
37#[near(serializers = [json, borsh])]
38pub struct YieldWeights {
39    pub supply: NonZeroU16,
40    pub r#static: HashMap<AccountId, u16>,
41}
42
43impl YieldWeights {
44    /// # Panics
45    /// - If `supply` is zero.
46    #[allow(clippy::unwrap_used, reason = "Only used during initial construction")]
47    pub fn new_with_supply_weight(supply: u16) -> Self {
48        Self {
49            supply: supply.try_into().unwrap(),
50            r#static: HashMap::new(),
51        }
52    }
53
54    #[must_use]
55    pub fn with_static(mut self, account_id: AccountId, weight: u16) -> Self {
56        self.r#static.insert(account_id, weight);
57        self
58    }
59
60    pub fn total_weight(&self) -> NonZeroU16 {
61        self.r#static
62            .values()
63            .try_fold(self.supply, |a, b| a.checked_add(*b))
64            .unwrap_or_else(|| crate::panic_with_message("Total weight overflow"))
65    }
66
67    pub fn static_share(&self, account_id: &AccountId) -> Decimal {
68        self.r#static
69            .get(account_id)
70            .map_or(Decimal::ZERO, |weight| {
71                Decimal::from(*weight) / u16::from(self.total_weight())
72            })
73    }
74}
75
76/// Parsed from the string parameter `msg` passed by `*_transfer_call` to
77/// `*_on_transfer` calls.
78#[derive(Debug)]
79#[near(serializers = [json])]
80pub enum DepositMsg {
81    /// Add the attached tokens to the sender's supply position's deposit.
82    Supply,
83    /// Add the attached tokens to the sender's borrow position's collateral
84    /// deposit.
85    Collateralize,
86    /// Use the attached tokens to pay down the sender's borrow position's
87    /// liability (sans fees).
88    Repay,
89    /// Use the attached tokens to pay down a specified borrow position's
90    /// liability (sans fees).
91    RepayAccount(RepayAccountMsg),
92    /// Liquidate an account that is below the configured liquidation
93    /// collateralization ratio threshold.
94    Liquidate(LiquidateMsg),
95}
96
97impl DepositMsg {
98    pub fn expects_borrow_asset(&self) -> bool {
99        match self {
100            Self::Supply | Self::Repay | Self::RepayAccount(..) | Self::Liquidate(..) => true,
101            Self::Collateralize => false,
102        }
103    }
104}
105
106/// Indicate an account to repay.
107#[derive(Debug)]
108#[near(serializers = [json])]
109pub struct RepayAccountMsg {
110    pub account_id: AccountId,
111}
112
113/// Indicate an account to liquidate.
114#[derive(Debug)]
115#[near(serializers = [json])]
116pub struct LiquidateMsg {
117    pub account_id: AccountId,
118    /// How much collateral to liquidate?
119    /// Attempts to liquidate the whole position if `None`.
120    pub amount: Option<CollateralAssetAmount>,
121}
122
123#[derive(Clone, Debug)]
124#[near(serializers = [json, borsh])]
125pub struct Withdrawal {
126    pub account_id: AccountId,
127    pub amount_to_account: BorrowAssetAmount,
128    pub amount_to_fees: BorrowAssetAmount,
129}
130
131#[cfg(test)]
132mod tests {
133    use near_sdk::{
134        json_types::U128,
135        serde_json::{self, json, Value},
136    };
137
138    use super::*;
139
140    /// Parse the wire `msg` shape, assert re-serializing reproduces it exactly,
141    /// and return the parsed message. This is the regression guard for the
142    /// `msg` strings passed to `ft_transfer_call`/`mt_transfer_call`: the
143    /// contract deserializes `msg` into [`DepositMsg`] through this same path.
144    fn roundtrip(wire: &Value) -> DepositMsg {
145        let parsed: DepositMsg = serde_json::from_value(wire.clone()).unwrap();
146        assert_eq!(&serde_json::to_value(&parsed).unwrap(), wire);
147        parsed
148    }
149
150    fn borrow_asset_metrics_json(paid_to_fees: Option<Value>) -> Value {
151        let mut metrics = json!({
152            "available": "90",
153            "deposited_active": "100",
154            "deposited_incoming": { "7": "10" },
155            "borrowed": "10",
156        });
157        if let Some(paid_to_fees) = paid_to_fees {
158            metrics["paid_to_fees"] = paid_to_fees;
159        }
160        metrics
161    }
162
163    #[test]
164    fn deposit_msg_supply() {
165        let msg = roundtrip(&json!("Supply"));
166        assert!(matches!(msg, DepositMsg::Supply));
167        assert!(msg.expects_borrow_asset());
168    }
169
170    #[test]
171    fn deposit_msg_collateralize() {
172        let msg = roundtrip(&json!("Collateralize"));
173        assert!(matches!(msg, DepositMsg::Collateralize));
174        assert!(!msg.expects_borrow_asset());
175    }
176
177    #[test]
178    fn deposit_msg_repay() {
179        let msg = roundtrip(&json!("Repay"));
180        assert!(matches!(msg, DepositMsg::Repay));
181        assert!(msg.expects_borrow_asset());
182    }
183
184    #[test]
185    fn deposit_msg_repay_account() {
186        let msg = roundtrip(&json!({ "RepayAccount": { "account_id": "borrow_user.near" } }));
187        let DepositMsg::RepayAccount(RepayAccountMsg { account_id }) = &msg else {
188            panic!("expected RepayAccount, got {msg:?}");
189        };
190        assert_eq!(account_id.as_str(), "borrow_user.near");
191        assert!(msg.expects_borrow_asset());
192    }
193
194    #[test]
195    fn deposit_msg_liquidate() {
196        let msg = roundtrip(&json!({
197            "Liquidate": { "account_id": "borrow_user.near", "amount": U128(1_000_000) },
198        }));
199        let DepositMsg::Liquidate(LiquidateMsg { account_id, amount }) = &msg else {
200            panic!("expected Liquidate, got {msg:?}");
201        };
202        assert_eq!(account_id.as_str(), "borrow_user.near");
203        assert_eq!(*amount, Some(CollateralAssetAmount::new(1_000_000)));
204        assert!(msg.expects_borrow_asset());
205    }
206
207    #[test]
208    fn deposit_msg_liquidate_whole_position() {
209        // Omitting `amount` liquidates the whole position. (A `None` amount
210        // re-serializes as `"amount": null`, so this case is parse-only.)
211        let msg: DepositMsg =
212            serde_json::from_value(json!({ "Liquidate": { "account_id": "borrow_user.near" } }))
213                .unwrap();
214        let DepositMsg::Liquidate(LiquidateMsg { amount, .. }) = &msg else {
215            panic!("expected Liquidate, got {msg:?}");
216        };
217        assert_eq!(*amount, None);
218    }
219
220    #[test]
221    fn borrow_asset_metrics_legacy_json() {
222        let metrics: BorrowAssetMetrics =
223            serde_json::from_value(borrow_asset_metrics_json(None)).unwrap();
224
225        assert_eq!(u128::from(metrics.available), 90);
226        assert_eq!(u128::from(metrics.deposited_active), 100);
227        assert_eq!(u128::from(metrics.deposited_incoming[&7]), 10);
228        assert_eq!(u128::from(metrics.borrowed), 10);
229        assert!(metrics.paid_to_fees.is_zero());
230    }
231
232    #[test]
233    fn borrow_asset_metrics_paid_to_fees_json() {
234        for expected_paid_to_fees in [0, u128::MAX] {
235            let paid_to_fees = expected_paid_to_fees.to_string();
236            let metrics: BorrowAssetMetrics =
237                serde_json::from_value(borrow_asset_metrics_json(Some(json!(paid_to_fees))))
238                    .unwrap();
239
240            assert_eq!(u128::from(metrics.paid_to_fees), expected_paid_to_fees);
241            assert_eq!(
242                serde_json::to_value(&metrics).unwrap()["paid_to_fees"],
243                paid_to_fees,
244            );
245        }
246    }
247
248    #[test]
249    fn borrow_asset_metrics_rejects_malformed_paid_to_fees() {
250        for paid_to_fees in [Value::Null, json!("not-an-amount")] {
251            assert!(
252                serde_json::from_value::<BorrowAssetMetrics>(borrow_asset_metrics_json(Some(
253                    paid_to_fees
254                ),))
255                .is_err()
256            );
257        }
258    }
259}