templar_common/
fee.rs

1use near_sdk::{json_types::U64, near};
2use templar_primitives::number::Decimal;
3
4use crate::asset::{AssetClass, FungibleAssetAmount};
5
6#[derive(Clone, Debug, PartialEq, Eq)]
7#[near(serializers = [json, borsh])]
8pub enum Fee<T: AssetClass> {
9    Flat(FungibleAssetAmount<T>),
10    Proportional(Decimal),
11}
12
13impl<T: AssetClass> Fee<T> {
14    pub fn zero() -> Self {
15        Self::Flat(FungibleAssetAmount::zero())
16    }
17
18    pub fn of(&self, amount: FungibleAssetAmount<T>) -> Option<FungibleAssetAmount<T>> {
19        match self {
20            Fee::Flat(f) => Some(*f),
21            Fee::Proportional(factor) => (factor * u128::from(amount))
22                .to_u128_ceil()
23                .map(FungibleAssetAmount::new),
24        }
25    }
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29#[near(serializers = [json, borsh])]
30pub struct TimeBasedFee<T: AssetClass> {
31    pub fee: Fee<T>,
32    pub duration: U64,
33    pub behavior: TimeBasedFeeFunction,
34}
35
36impl<T: AssetClass> TimeBasedFee<T> {
37    pub fn zero() -> Self {
38        Self {
39            fee: Fee::Flat(0.into()),
40            duration: 0.into(),
41            behavior: TimeBasedFeeFunction::Fixed,
42        }
43    }
44}
45
46#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
47#[near(serializers = [json, borsh])]
48pub enum TimeBasedFeeFunction {
49    Fixed,
50    Linear,
51}
52
53impl<T: AssetClass> TimeBasedFee<T> {
54    pub fn of(
55        &self,
56        amount: FungibleAssetAmount<T>,
57        duration: u64,
58    ) -> Option<FungibleAssetAmount<T>> {
59        let base_fee = self.fee.of(amount)?;
60
61        if self.duration.0 == 0 {
62            return Some(0.into());
63        }
64
65        match self.behavior {
66            TimeBasedFeeFunction::Fixed => {
67                if duration >= self.duration.0 {
68                    Some(0.into())
69                } else {
70                    Some(base_fee)
71                }
72            }
73            TimeBasedFeeFunction::Linear => {
74                (Decimal::from(self.duration.0.saturating_sub(duration)) * u128::from(base_fee)
75                    / Decimal::from(self.duration.0))
76                .to_u128_ceil()
77                .map(FungibleAssetAmount::new)
78            }
79        }
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use templar_primitives::dec;
86
87    use crate::asset::BorrowAsset;
88
89    use super::{TimeBasedFeeFunction::*, *};
90
91    type Amount = FungibleAssetAmount<BorrowAsset>;
92
93    fn time_based(
94        fee: Fee<BorrowAsset>,
95        duration_ms: u64,
96        behavior: TimeBasedFeeFunction,
97    ) -> TimeBasedFee<BorrowAsset> {
98        TimeBasedFee {
99            fee,
100            duration: duration_ms.into(),
101            behavior,
102        }
103    }
104
105    #[test]
106    fn flat_fee_is_constant() {
107        assert_eq!(
108            Fee::<BorrowAsset>::Flat(100.into()).of(1_000.into()),
109            Some(Amount::new(100)),
110        );
111    }
112
113    #[test]
114    fn proportional_fee_rounds_up() {
115        // 0.1% of 1005 = 1.005, rounded up to 2.
116        assert_eq!(
117            Fee::<BorrowAsset>::Proportional(dec!("0.001")).of(1_005.into()),
118            Some(Amount::new(2)),
119        );
120    }
121
122    #[test]
123    fn fixed_before_expiry_charges_full_fee() {
124        let f = time_based(Fee::Flat(100.into()), 1000 * 60 * 60 * 24 * 30, Fixed);
125        assert_eq!(f.of(1_000.into(), 10_000), Some(Amount::new(100)));
126    }
127
128    #[test]
129    fn fixed_at_or_after_expiry_charges_nothing() {
130        let f = time_based(Fee::Flat(100.into()), 1000, Fixed);
131        assert_eq!(f.of(1_000.into(), 1000), Some(Amount::new(0)));
132        assert_eq!(f.of(1_000.into(), 2000), Some(Amount::new(0)));
133    }
134
135    #[test]
136    fn zero_configured_duration_is_always_free() {
137        let f = time_based(Fee::Flat(100.into()), 0, Fixed);
138        assert_eq!(f.of(1_000.into(), 0), Some(Amount::new(0)));
139    }
140
141    #[test]
142    fn linear_interpolates_toward_expiry_and_rounds_up() {
143        let f = time_based(Fee::Flat(100.into()), 1000, Linear);
144        // At the start the full fee applies; it decays linearly to zero at expiry.
145        assert_eq!(f.of(1_000.into(), 0), Some(Amount::new(100)));
146        // remaining 750/1000 of 100 = 75.
147        assert_eq!(f.of(1_000.into(), 250), Some(Amount::new(75)));
148        // remaining 749/1000 of 100 = 74.9, rounded up to 75.
149        assert_eq!(f.of(1_000.into(), 251), Some(Amount::new(75)));
150    }
151
152    #[test]
153    fn linear_past_expiry_saturates_to_zero() {
154        let f = time_based(Fee::Flat(100.into()), 1000, Linear);
155        assert_eq!(f.of(1_000.into(), 5000), Some(Amount::new(0)));
156    }
157}