Skip to main content

spl_token_2022_interface/extension/interest_bearing_mint/
mod.rs

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