spl_token_2022_interface/extension/interest_bearing_mint/
mod.rs

1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3use {
4    crate::{
5        extension::{Extension, ExtensionType},
6        trim_ui_amount_string,
7    },
8    bytemuck::{Pod, Zeroable},
9    solana_program_error::ProgramError,
10    spl_pod::{
11        optional_keys::OptionalNonZeroPubkey,
12        primitives::{PodI16, PodI64},
13    },
14    std::convert::TryInto,
15};
16
17/// Interest-bearing mint extension instructions
18pub mod instruction;
19
20/// Annual interest rate, expressed as basis points
21pub type BasisPoints = PodI16;
22const ONE_IN_BASIS_POINTS: f64 = 10_000.;
23const SECONDS_PER_YEAR: f64 = 60. * 60. * 24. * 365.24;
24
25/// `UnixTimestamp` expressed with an alignment-independent type
26pub type UnixTimestamp = PodI64;
27
28/// Interest-bearing extension data for mints
29///
30/// Tokens accrue interest at an annual rate expressed by `current_rate`,
31/// compounded continuously, so APY will be higher than the published interest
32/// rate.
33///
34/// To support changing the rate, the config also maintains state for the
35/// previous rate.
36#[repr(C)]
37#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
38#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
39#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)]
40pub struct InterestBearingConfig {
41    /// Authority that can set the interest rate and authority
42    pub rate_authority: OptionalNonZeroPubkey,
43    /// Timestamp of initialization, from which to base interest calculations
44    pub initialization_timestamp: UnixTimestamp,
45    /// Average rate from initialization until the last time it was updated
46    pub pre_update_average_rate: BasisPoints,
47    /// Timestamp of the last update, used to calculate the total amount accrued
48    pub last_update_timestamp: UnixTimestamp,
49    /// Current rate, since the last update
50    pub current_rate: BasisPoints,
51}
52impl InterestBearingConfig {
53    fn pre_update_timespan(&self) -> Option<i64> {
54        i64::from(self.last_update_timestamp).checked_sub(self.initialization_timestamp.into())
55    }
56
57    fn pre_update_exp(&self) -> Option<f64> {
58        let numerator = (i16::from(self.pre_update_average_rate) as i128)
59            .checked_mul(self.pre_update_timespan()? as i128)? as f64;
60        let exponent = numerator / SECONDS_PER_YEAR / ONE_IN_BASIS_POINTS;
61        Some(exponent.exp())
62    }
63
64    fn post_update_timespan(&self, unix_timestamp: i64) -> Option<i64> {
65        unix_timestamp.checked_sub(self.last_update_timestamp.into())
66    }
67
68    fn post_update_exp(&self, unix_timestamp: i64) -> Option<f64> {
69        let numerator = (i16::from(self.current_rate) as i128)
70            .checked_mul(self.post_update_timespan(unix_timestamp)? as i128)?
71            as f64;
72        let exponent = numerator / SECONDS_PER_YEAR / ONE_IN_BASIS_POINTS;
73        Some(exponent.exp())
74    }
75
76    fn total_scale(&self, decimals: u8, unix_timestamp: i64) -> Option<f64> {
77        Some(
78            self.pre_update_exp()? * self.post_update_exp(unix_timestamp)?
79                / 10_f64.powi(decimals as i32),
80        )
81    }
82
83    /// Convert a raw amount to its UI representation using the given decimals
84    /// field. Excess zeroes or unneeded decimal point are trimmed.
85    pub fn amount_to_ui_amount(
86        &self,
87        amount: u64,
88        decimals: u8,
89        unix_timestamp: i64,
90    ) -> Option<String> {
91        let scaled_amount_with_interest =
92            (amount as f64) * self.total_scale(decimals, unix_timestamp)?;
93        let ui_amount = format!("{scaled_amount_with_interest:.*}", decimals as usize);
94        Some(trim_ui_amount_string(ui_amount, decimals))
95    }
96
97    /// Try to convert a UI representation of a token amount to its raw amount
98    /// using the given decimals field
99    pub fn try_ui_amount_into_amount(
100        &self,
101        ui_amount: &str,
102        decimals: u8,
103        unix_timestamp: i64,
104    ) -> Result<u64, ProgramError> {
105        let scaled_amount = ui_amount
106            .parse::<f64>()
107            .map_err(|_| ProgramError::InvalidArgument)?;
108        let amount = scaled_amount
109            / self
110                .total_scale(decimals, unix_timestamp)
111                .ok_or(ProgramError::InvalidArgument)?;
112        if amount > (u64::MAX as f64) || amount < (u64::MIN as f64) || amount.is_nan() {
113            Err(ProgramError::InvalidArgument)
114        } else {
115            // this is important, if you round earlier, you'll get wrong "inf"
116            // answers
117            Ok(amount.round() as u64)
118        }
119    }
120
121    /// The new average rate is the time-weighted average of the current rate
122    /// and average rate, solving for r such that:
123    ///
124    /// ```text
125    /// exp(r_1 * t_1) * exp(r_2 * t_2) = exp(r * (t_1 + t_2))
126    ///
127    /// r_1 * t_1 + r_2 * t_2 = r * (t_1 + t_2)
128    ///
129    /// r = (r_1 * t_1 + r_2 * t_2) / (t_1 + t_2)
130    /// ```
131    pub fn time_weighted_average_rate(&self, current_timestamp: i64) -> Option<i16> {
132        let initialization_timestamp = i64::from(self.initialization_timestamp) as i128;
133        let last_update_timestamp = i64::from(self.last_update_timestamp) as i128;
134
135        let r_1 = i16::from(self.pre_update_average_rate) as i128;
136        let t_1 = last_update_timestamp.checked_sub(initialization_timestamp)?;
137        let r_2 = i16::from(self.current_rate) as i128;
138        let t_2 = (current_timestamp as i128).checked_sub(last_update_timestamp)?;
139        let total_timespan = t_1.checked_add(t_2)?;
140        let average_rate = if total_timespan == 0 {
141            // happens in testing situations, just use the new rate since the earlier
142            // one was never practically used
143            r_2
144        } else {
145            r_1.checked_mul(t_1)?
146                .checked_add(r_2.checked_mul(t_2)?)?
147                .checked_div(total_timespan)?
148        };
149        average_rate.try_into().ok()
150    }
151}
152impl Extension for InterestBearingConfig {
153    const TYPE: ExtensionType = ExtensionType::InterestBearingConfig;
154}
155
156#[cfg(test)]
157mod tests {
158    use {super::*, proptest::prelude::*};
159
160    const INT_SECONDS_PER_YEAR: i64 = 6 * 6 * 24 * 36524;
161    const TEST_DECIMALS: u8 = 2;
162
163    #[test]
164    fn seconds_per_year() {
165        assert_eq!(SECONDS_PER_YEAR, 31_556_736.);
166        assert_eq!(INT_SECONDS_PER_YEAR, 31_556_736);
167    }
168
169    #[test]
170    fn specific_amount_to_ui_amount() {
171        const ONE: u64 = 1_000_000_000_000_000_000;
172        // constant 5%
173        let config = InterestBearingConfig {
174            rate_authority: OptionalNonZeroPubkey::default(),
175            initialization_timestamp: 0.into(),
176            pre_update_average_rate: 500.into(),
177            last_update_timestamp: INT_SECONDS_PER_YEAR.into(),
178            current_rate: 500.into(),
179        };
180        // 1 year at 5% gives a total of exp(0.05) = 1.0512710963760241
181        let ui_amount = config
182            .amount_to_ui_amount(ONE, 18, INT_SECONDS_PER_YEAR)
183            .unwrap();
184        assert_eq!(ui_amount, "1.051271096376024117");
185        // with 1 decimal place
186        let ui_amount = config
187            .amount_to_ui_amount(ONE, 19, INT_SECONDS_PER_YEAR)
188            .unwrap();
189        assert_eq!(ui_amount, "0.1051271096376024117");
190        // with 10 decimal places
191        let ui_amount = config
192            .amount_to_ui_amount(ONE, 28, INT_SECONDS_PER_YEAR)
193            .unwrap();
194        assert_eq!(ui_amount, "0.0000000001051271096376024175"); // different digits at the end!
195
196        // huge amount with 10 decimal places
197        let ui_amount = config
198            .amount_to_ui_amount(10_000_000_000, 10, INT_SECONDS_PER_YEAR)
199            .unwrap();
200        assert_eq!(ui_amount, "1.0512710964");
201
202        // negative
203        let config = InterestBearingConfig {
204            rate_authority: OptionalNonZeroPubkey::default(),
205            initialization_timestamp: 0.into(),
206            pre_update_average_rate: PodI16::from(-500),
207            last_update_timestamp: INT_SECONDS_PER_YEAR.into(),
208            current_rate: PodI16::from(-500),
209        };
210        // 1 year at -5% gives a total of exp(-0.05) = 0.951229424500714
211        let ui_amount = config
212            .amount_to_ui_amount(ONE, 18, INT_SECONDS_PER_YEAR)
213            .unwrap();
214        assert_eq!(ui_amount, "0.951229424500713905");
215
216        // net out
217        let config = InterestBearingConfig {
218            rate_authority: OptionalNonZeroPubkey::default(),
219            initialization_timestamp: 0.into(),
220            pre_update_average_rate: PodI16::from(-500),
221            last_update_timestamp: INT_SECONDS_PER_YEAR.into(),
222            current_rate: PodI16::from(500),
223        };
224        // 1 year at -5% and 1 year at 5% gives a total of 1
225        let ui_amount = config
226            .amount_to_ui_amount(1, 0, INT_SECONDS_PER_YEAR * 2)
227            .unwrap();
228        assert_eq!(ui_amount, "1");
229
230        // huge values
231        let config = InterestBearingConfig {
232            rate_authority: OptionalNonZeroPubkey::default(),
233            initialization_timestamp: 0.into(),
234            pre_update_average_rate: PodI16::from(500),
235            last_update_timestamp: INT_SECONDS_PER_YEAR.into(),
236            current_rate: PodI16::from(500),
237        };
238        let ui_amount = config
239            .amount_to_ui_amount(u64::MAX, 0, INT_SECONDS_PER_YEAR * 2)
240            .unwrap();
241        assert_eq!(ui_amount, "20386805083448098816");
242        let ui_amount = config
243            .amount_to_ui_amount(u64::MAX, 0, INT_SECONDS_PER_YEAR * 10_000)
244            .unwrap();
245        // there's an underflow risk, but it works!
246        assert_eq!(ui_amount, "258917064265813826192025834755112557504850551118283225815045099303279643822914042296793377611277551888244755303462190670431480816358154467489350925148558569427069926786360814068189956495940285398273555561779717914539956777398245259214848");
247    }
248
249    #[test]
250    fn specific_ui_amount_to_amount() {
251        // constant 5%
252        let config = InterestBearingConfig {
253            rate_authority: OptionalNonZeroPubkey::default(),
254            initialization_timestamp: 0.into(),
255            pre_update_average_rate: 500.into(),
256            last_update_timestamp: INT_SECONDS_PER_YEAR.into(),
257            current_rate: 500.into(),
258        };
259        // 1 year at 5% gives a total of exp(0.05) = 1.0512710963760241
260        let amount = config
261            .try_ui_amount_into_amount("1.0512710963760241", 0, INT_SECONDS_PER_YEAR)
262            .unwrap();
263        assert_eq!(1, amount);
264        // with 1 decimal place
265        let amount = config
266            .try_ui_amount_into_amount("0.10512710963760241", 1, INT_SECONDS_PER_YEAR)
267            .unwrap();
268        assert_eq!(amount, 1);
269        // with 10 decimal places
270        let amount = config
271            .try_ui_amount_into_amount("0.00000000010512710963760242", 10, INT_SECONDS_PER_YEAR)
272            .unwrap();
273        assert_eq!(amount, 1);
274
275        // huge amount with 10 decimal places
276        let amount = config
277            .try_ui_amount_into_amount("1.0512710963760241", 10, INT_SECONDS_PER_YEAR)
278            .unwrap();
279        assert_eq!(amount, 10_000_000_000);
280
281        // negative
282        let config = InterestBearingConfig {
283            rate_authority: OptionalNonZeroPubkey::default(),
284            initialization_timestamp: 0.into(),
285            pre_update_average_rate: PodI16::from(-500),
286            last_update_timestamp: INT_SECONDS_PER_YEAR.into(),
287            current_rate: PodI16::from(-500),
288        };
289        // 1 year at -5% gives a total of exp(-0.05) = 0.951229424500714
290        let amount = config
291            .try_ui_amount_into_amount("0.951229424500714", 0, INT_SECONDS_PER_YEAR)
292            .unwrap();
293        assert_eq!(amount, 1);
294
295        // net out
296        let config = InterestBearingConfig {
297            rate_authority: OptionalNonZeroPubkey::default(),
298            initialization_timestamp: 0.into(),
299            pre_update_average_rate: PodI16::from(-500),
300            last_update_timestamp: INT_SECONDS_PER_YEAR.into(),
301            current_rate: PodI16::from(500),
302        };
303        // 1 year at -5% and 1 year at 5% gives a total of 1
304        let amount = config
305            .try_ui_amount_into_amount("1", 0, INT_SECONDS_PER_YEAR * 2)
306            .unwrap();
307        assert_eq!(amount, 1);
308
309        // huge values
310        let config = InterestBearingConfig {
311            rate_authority: OptionalNonZeroPubkey::default(),
312            initialization_timestamp: 0.into(),
313            pre_update_average_rate: PodI16::from(500),
314            last_update_timestamp: INT_SECONDS_PER_YEAR.into(),
315            current_rate: PodI16::from(500),
316        };
317        let amount = config
318            .try_ui_amount_into_amount("20386805083448100000", 0, INT_SECONDS_PER_YEAR * 2)
319            .unwrap();
320        assert_eq!(amount, u64::MAX);
321        let amount = config
322            .try_ui_amount_into_amount("258917064265813830000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", 0, INT_SECONDS_PER_YEAR * 10_000)
323            .unwrap();
324        assert_eq!(amount, u64::MAX);
325        // scientific notation "e"
326        let amount = config
327            .try_ui_amount_into_amount("2.5891706426581383e236", 0, INT_SECONDS_PER_YEAR * 10_000)
328            .unwrap();
329        assert_eq!(amount, u64::MAX);
330        // scientific notation "E"
331        let amount = config
332            .try_ui_amount_into_amount("2.5891706426581383E236", 0, INT_SECONDS_PER_YEAR * 10_000)
333            .unwrap();
334        assert_eq!(amount, u64::MAX);
335
336        // overflow u64 fail
337        assert_eq!(
338            Err(ProgramError::InvalidArgument),
339            config.try_ui_amount_into_amount("20386805083448200001", 0, INT_SECONDS_PER_YEAR)
340        );
341
342        for fail_ui_amount in ["-0.0000000000000000000001", "inf", "-inf", "NaN"] {
343            assert_eq!(
344                Err(ProgramError::InvalidArgument),
345                config.try_ui_amount_into_amount(fail_ui_amount, 0, INT_SECONDS_PER_YEAR)
346            );
347        }
348    }
349
350    #[test]
351    fn specific_amount_to_ui_amount_no_interest() {
352        let config = InterestBearingConfig {
353            rate_authority: OptionalNonZeroPubkey::default(),
354            initialization_timestamp: 0.into(),
355            pre_update_average_rate: 0.into(),
356            last_update_timestamp: INT_SECONDS_PER_YEAR.into(),
357            current_rate: 0.into(),
358        };
359        for (amount, expected) in [(23, "0.23"), (110, "1.1"), (4200, "42"), (0, "0")] {
360            let ui_amount = config
361                .amount_to_ui_amount(amount, TEST_DECIMALS, INT_SECONDS_PER_YEAR)
362                .unwrap();
363            assert_eq!(ui_amount, expected);
364        }
365    }
366
367    #[test]
368    fn specific_ui_amount_to_amount_no_interest() {
369        let config = InterestBearingConfig {
370            rate_authority: OptionalNonZeroPubkey::default(),
371            initialization_timestamp: 0.into(),
372            pre_update_average_rate: 0.into(),
373            last_update_timestamp: INT_SECONDS_PER_YEAR.into(),
374            current_rate: 0.into(),
375        };
376        for (ui_amount, expected) in [
377            ("0.23", 23),
378            ("0.20", 20),
379            ("0.2000", 20),
380            (".2", 20),
381            ("1.1", 110),
382            ("1.10", 110),
383            ("42", 4200),
384            ("42.", 4200),
385            ("0", 0),
386        ] {
387            let amount = config
388                .try_ui_amount_into_amount(ui_amount, TEST_DECIMALS, INT_SECONDS_PER_YEAR)
389                .unwrap();
390            assert_eq!(expected, amount);
391        }
392
393        // this is invalid with normal mints, but rounding for this mint makes it ok
394        let amount = config
395            .try_ui_amount_into_amount("0.111", TEST_DECIMALS, INT_SECONDS_PER_YEAR)
396            .unwrap();
397        assert_eq!(11, amount);
398
399        // fail if invalid ui_amount passed in
400        for ui_amount in ["", ".", "0.t"] {
401            assert_eq!(
402                Err(ProgramError::InvalidArgument),
403                config.try_ui_amount_into_amount(ui_amount, TEST_DECIMALS, INT_SECONDS_PER_YEAR),
404            );
405        }
406    }
407
408    prop_compose! {
409        /// Three values in ascending order
410        fn low_middle_high()
411            (middle in 1..i64::MAX - 1)
412            (low in 0..=middle, middle in Just(middle), high in middle..=i64::MAX)
413                        -> (i64, i64, i64) {
414           (low, middle, high)
415       }
416    }
417
418    proptest! {
419        #[test]
420        fn time_weighted_average_calc(
421            current_rate in i16::MIN..i16::MAX,
422            pre_update_average_rate in i16::MIN..i16::MAX,
423            (initialization_timestamp, last_update_timestamp, current_timestamp) in low_middle_high(),
424        ) {
425            let config = InterestBearingConfig {
426                rate_authority: OptionalNonZeroPubkey::default(),
427                initialization_timestamp: initialization_timestamp.into(),
428                pre_update_average_rate: pre_update_average_rate.into(),
429                last_update_timestamp: last_update_timestamp.into(),
430                current_rate: current_rate.into(),
431            };
432            let new_rate = config.time_weighted_average_rate(current_timestamp).unwrap();
433            if pre_update_average_rate <= current_rate {
434                assert!(pre_update_average_rate <= new_rate);
435                assert!(new_rate <= current_rate);
436            } else {
437                assert!(current_rate <= new_rate);
438                assert!(new_rate <= pre_update_average_rate);
439            }
440        }
441
442        #[test]
443        fn amount_to_ui_amount(
444            current_rate in i16::MIN..i16::MAX,
445            pre_update_average_rate in i16::MIN..i16::MAX,
446            (initialization_timestamp, last_update_timestamp, current_timestamp) in low_middle_high(),
447            amount in 0..=u64::MAX,
448            decimals in 0u8..20u8,
449        ) {
450            let config = InterestBearingConfig {
451                rate_authority: OptionalNonZeroPubkey::default(),
452                initialization_timestamp: initialization_timestamp.into(),
453                pre_update_average_rate: pre_update_average_rate.into(),
454                last_update_timestamp: last_update_timestamp.into(),
455                current_rate: current_rate.into(),
456            };
457            let ui_amount = config.amount_to_ui_amount(amount, decimals, current_timestamp);
458            assert!(ui_amount.is_some());
459        }
460    }
461}