safe_token_2022/extension/interest_bearing_mint/
mod.rs

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