spl_token_2022/extension/interest_bearing_mint/
mod.rs

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