templar_common/
interest_rate_strategy.rs

1use std::ops::Deref;
2
3use near_sdk::{near, require};
4use templar_primitives::number::Decimal;
5
6pub trait UsageCurve {
7    fn at(&self, usage_ratio: Decimal) -> Decimal;
8}
9
10#[derive(Clone, Debug, PartialEq, Eq)]
11#[near(serializers = [json, borsh])]
12pub enum InterestRateStrategy {
13    Linear(Linear),
14    Piecewise(Piecewise),
15    Exponential2(Exponential2),
16}
17
18impl InterestRateStrategy {
19    pub const fn zero() -> Self {
20        Self::Linear(Linear {
21            base: Decimal::ZERO,
22            top: Decimal::ZERO,
23        })
24    }
25
26    #[must_use]
27    pub fn linear(base: Decimal, top: Decimal) -> Option<Self> {
28        Some(Self::Linear(Linear::new(base, top)?))
29    }
30
31    #[must_use]
32    pub fn piecewise(
33        base: Decimal,
34        optimal: Decimal,
35        rate_1: Decimal,
36        rate_2: Decimal,
37    ) -> Option<Self> {
38        Some(Self::Piecewise(Piecewise::new(
39            base, optimal, rate_1, rate_2,
40        )?))
41    }
42
43    #[must_use]
44    pub fn exponential2(base: Decimal, top: Decimal, eccentricity: Decimal) -> Option<Self> {
45        Some(Self::Exponential2(Exponential2::new(
46            base,
47            top,
48            eccentricity,
49        )?))
50    }
51}
52
53impl Deref for InterestRateStrategy {
54    type Target = dyn UsageCurve;
55
56    fn deref(&self) -> &Self::Target {
57        match self {
58            Self::Linear(linear) => linear as &dyn UsageCurve,
59            Self::Piecewise(piecewise) => piecewise as &dyn UsageCurve,
60            Self::Exponential2(exponential2) => exponential2 as &dyn UsageCurve,
61        }
62    }
63}
64
65/// ```text,no_run
66/// r(u) = u * (t - b) + b
67/// ```
68#[derive(Debug, Clone, PartialEq, Eq)]
69#[near(serializers = [borsh, json])]
70pub struct Linear {
71    base: Decimal,
72    top: Decimal,
73}
74
75impl Linear {
76    pub fn new(base: Decimal, top: Decimal) -> Option<Self> {
77        (base <= top).then_some(Self { base, top })
78    }
79}
80
81impl UsageCurve for Linear {
82    fn at(&self, usage_ratio: Decimal) -> Decimal {
83        usage_ratio * (self.top - self.base) + self.base
84    }
85}
86
87/// ```text,no_run
88/// r(u) = {
89///     if u < o : r_1 * u + b,
90///     else     : r_2 * u + o * (r_1 - r_2) + b
91/// }
92/// ```
93#[derive(Debug, Clone, PartialEq, Eq)]
94#[near(serializers = [borsh, json])]
95#[serde(try_from = "PiecewiseParams", into = "PiecewiseParams")]
96pub struct Piecewise {
97    params: PiecewiseParams,
98    i_negative_rate_2_b: Decimal,
99}
100
101impl Piecewise {
102    pub fn new(base: Decimal, optimal: Decimal, rate_1: Decimal, rate_2: Decimal) -> Option<Self> {
103        if optimal > 1u32 {
104            return None;
105        }
106
107        if rate_1 > rate_2 {
108            return None;
109        }
110
111        Some(Self {
112            i_negative_rate_2_b: optimal * (rate_2 - rate_1) - base,
113            params: PiecewiseParams {
114                base,
115                optimal,
116                rate_1,
117                rate_2,
118            },
119        })
120    }
121}
122
123impl UsageCurve for Piecewise {
124    fn at(&self, usage_ratio: Decimal) -> Decimal {
125        require!(
126            usage_ratio <= Decimal::ONE,
127            "Invariant violation: Usage ratio cannot be over 100%.",
128        );
129
130        if usage_ratio < self.params.optimal {
131            self.params.rate_1 * usage_ratio + self.params.base
132        } else {
133            self.params.rate_2 * usage_ratio - self.i_negative_rate_2_b
134        }
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139#[near(serializers = [json, borsh])]
140pub struct PiecewiseParams {
141    base: Decimal,
142    optimal: Decimal,
143    rate_1: Decimal,
144    rate_2: Decimal,
145}
146
147impl TryFrom<PiecewiseParams> for Piecewise {
148    type Error = &'static str;
149
150    fn try_from(
151        PiecewiseParams {
152            base,
153            optimal,
154            rate_1,
155            rate_2,
156        }: PiecewiseParams,
157    ) -> Result<Self, Self::Error> {
158        Self::new(base, optimal, rate_1, rate_2).ok_or("Invalid Piecewise parameters")
159    }
160}
161
162impl From<Piecewise> for PiecewiseParams {
163    fn from(value: Piecewise) -> Self {
164        value.params
165    }
166}
167
168/// ```text,no_run
169/// r(u) = b + (t - b) * (2^ku - 1) / (2^k - 1)
170/// ```
171#[derive(Debug, Clone, PartialEq, Eq)]
172#[near(serializers = [borsh, json])]
173#[serde(try_from = "Exponential2Params", into = "Exponential2Params")]
174pub struct Exponential2 {
175    params: Exponential2Params,
176    i_factor: Decimal,
177}
178
179impl Exponential2 {
180    /// # Panics
181    /// - If 2^eccentricity overflows `Decimal`.
182    pub fn new(base: Decimal, top: Decimal, eccentricity: Decimal) -> Option<Self> {
183        if base > top {
184            return None;
185        }
186
187        if eccentricity > 24u32 || eccentricity.is_zero() {
188            return None;
189        }
190
191        #[allow(clippy::unwrap_used, reason = "Invariant checked above")]
192        Some(Self {
193            i_factor: (top - base) / (eccentricity.pow2().unwrap() - 1u32),
194            params: Exponential2Params {
195                base,
196                top,
197                eccentricity,
198            },
199        })
200    }
201}
202
203impl UsageCurve for Exponential2 {
204    fn at(&self, usage_ratio: Decimal) -> Decimal {
205        require!(
206            usage_ratio <= Decimal::ONE,
207            "Invariant violation: Usage ratio cannot be over 100%.",
208        );
209
210        #[allow(clippy::unwrap_used, reason = "Invariant checked above")]
211        (self.params.base
212            + self.i_factor * ((self.params.eccentricity * usage_ratio).pow2().unwrap() - 1u32))
213    }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217#[near(serializers = [json, borsh])]
218pub struct Exponential2Params {
219    base: Decimal,
220    top: Decimal,
221    eccentricity: Decimal,
222}
223
224impl TryFrom<Exponential2Params> for Exponential2 {
225    type Error = &'static str;
226
227    fn try_from(
228        Exponential2Params {
229            base,
230            top,
231            eccentricity,
232        }: Exponential2Params,
233    ) -> Result<Self, Self::Error> {
234        Self::new(base, top, eccentricity).ok_or("Invalid Exponential2 parameters")
235    }
236}
237
238impl From<Exponential2> for Exponential2Params {
239    fn from(value: Exponential2) -> Self {
240        value.params
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use std::ops::Div;
247
248    use templar_primitives::dec;
249
250    use super::*;
251
252    #[test]
253    fn piecewise() {
254        let s = Piecewise::new(Decimal::ZERO, dec!("0.9"), dec!("0.035"), dec!("0.6")).unwrap();
255
256        assert!(s.at(Decimal::ZERO).near_equal(Decimal::ZERO));
257        assert!(s.at(dec!("0.1")).near_equal(dec!("0.0035")));
258        assert!(s.at(dec!("0.5")).near_equal(dec!("0.0175")));
259        assert!(s.at(dec!("0.6")).near_equal(dec!("0.021")));
260        assert!(s.at(dec!("0.9")).near_equal(dec!("0.0315")));
261        assert!(s.at(dec!("0.95")).near_equal(dec!("0.0615")));
262        assert!(s.at(Decimal::ONE).near_equal(dec!("0.0915")));
263    }
264
265    // Backstop for `fuzz_decimals` (ENG-341): the harness predicts and skips
266    // the `base > optimal*(rate_2 - rate_1)` region because `Piecewise::new`
267    // computes `optimal*(rate_2 - rate_1) - base` as an unsigned `Decimal` and
268    // underflows there — and libfuzzer-sys can't tell that abort from a real
269    // crash. The abort, the symptom of the tracked bug, is asserted here so the
270    // suppressed region stays pinned. (U512 underflow panics with "arithmetic
271    // operation overflow".)
272    #[test]
273    // Match only the stable "overflow" substring, not the full toolchain- /
274    // backend-specific panic text (U512 emits "arithmetic operation overflow").
275    #[should_panic(expected = "overflow")]
276    fn piecewise_new_underflows_when_base_exceeds_cross_term() {
277        // optimal*(rate_2 - rate_1) = 0.9*(0.1 - 0.0) = 0.09; base = 0.5 > 0.09.
278        let _ = Piecewise::new(dec!("0.5"), dec!("0.9"), dec!("0.0"), dec!("0.1"));
279    }
280
281    #[test]
282    fn exponential2() {
283        let s = Exponential2::new(dec!("0.005"), dec!("0.08"), dec!("6")).unwrap();
284        assert!(s.at(Decimal::ZERO).near_equal(dec!("0.005")));
285        assert!(s.at(dec!("0.25")).near_equal(dec!(
286            "0.00717669895803117868762306839097547161564207589375463826946828509045412494"
287        )));
288        assert!(s.at(Decimal::ONE_HALF).near_equal(Decimal::ONE.div(75u32)));
289    }
290}