spl_token_2022_interface/extension/scaled_ui_amount/
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::{optional_keys::OptionalNonZeroPubkey, primitives::PodI64},
11};
12
13/// Scaled UI amount extension instructions
14pub mod instruction;
15
16/// `UnixTimestamp` expressed with an alignment-independent type
17pub type UnixTimestamp = PodI64;
18
19/// `f64` type that can be used in `Pod`s
20#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
21#[cfg_attr(feature = "serde", serde(from = "f64", into = "f64"))]
22#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)]
23#[repr(transparent)]
24pub struct PodF64(pub [u8; 8]);
25impl PodF64 {
26    fn from_primitive(n: f64) -> Self {
27        Self(n.to_le_bytes())
28    }
29}
30impl From<f64> for PodF64 {
31    fn from(n: f64) -> Self {
32        Self::from_primitive(n)
33    }
34}
35impl From<PodF64> for f64 {
36    fn from(pod: PodF64) -> Self {
37        Self::from_le_bytes(pod.0)
38    }
39}
40
41/// Scaled UI amount extension data for mints
42#[repr(C)]
43#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
44#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
45#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)]
46pub struct ScaledUiAmountConfig {
47    /// Authority that can set the scaling amount and authority
48    pub authority: OptionalNonZeroPubkey,
49    /// Amount to multiply raw amounts by, outside of the decimal
50    pub multiplier: PodF64,
51    /// Unix timestamp at which `new_multiplier` comes into effective
52    pub new_multiplier_effective_timestamp: UnixTimestamp,
53    /// Next multiplier, once `new_multiplier_effective_timestamp` is reached
54    pub new_multiplier: PodF64,
55}
56impl ScaledUiAmountConfig {
57    fn current_multiplier(&self, unix_timestamp: i64) -> f64 {
58        if unix_timestamp >= self.new_multiplier_effective_timestamp.into() {
59            self.new_multiplier.into()
60        } else {
61            self.multiplier.into()
62        }
63    }
64
65    fn total_multiplier(&self, decimals: u8, unix_timestamp: i64) -> f64 {
66        self.current_multiplier(unix_timestamp) / 10_f64.powi(decimals as i32)
67    }
68
69    /// Convert a raw amount to its UI representation using the given decimals
70    /// field.
71    ///
72    /// The value is converted to a float and then truncated towards 0. Excess
73    /// zeroes or unneeded decimal point are trimmed.
74    pub fn amount_to_ui_amount(
75        &self,
76        amount: u64,
77        decimals: u8,
78        unix_timestamp: i64,
79    ) -> Option<String> {
80        let scaled_amount = (amount as f64) * self.current_multiplier(unix_timestamp);
81        let truncated_amount = scaled_amount.trunc() / 10_f64.powi(decimals as i32);
82        let ui_amount = format!("{truncated_amount:.*}", decimals as usize);
83        Some(trim_ui_amount_string(ui_amount, decimals))
84    }
85
86    /// Try to convert a UI representation of a token amount to its raw amount
87    /// using the given decimals field.
88    ///
89    /// The string is parsed to a float, scaled, and then truncated towards 0
90    /// before being converted to a fixed-point number.
91    pub fn try_ui_amount_into_amount(
92        &self,
93        ui_amount: &str,
94        decimals: u8,
95        unix_timestamp: i64,
96    ) -> Result<u64, ProgramError> {
97        let scaled_amount = ui_amount
98            .parse::<f64>()
99            .map_err(|_| ProgramError::InvalidArgument)?;
100        let amount = scaled_amount / self.total_multiplier(decimals, unix_timestamp);
101        if amount > (u64::MAX as f64) || amount < (u64::MIN as f64) || amount.is_nan() {
102            Err(ProgramError::InvalidArgument)
103        } else {
104            // this is important, if you truncate earlier, you'll get wrong "inf"
105            // answers
106            Ok(amount.trunc() as u64)
107        }
108    }
109}
110impl Extension for ScaledUiAmountConfig {
111    const TYPE: ExtensionType = ExtensionType::ScaledUiAmount;
112}
113
114#[cfg(test)]
115mod tests {
116    use {super::*, proptest::prelude::*};
117
118    const TEST_DECIMALS: u8 = 2;
119
120    #[test]
121    fn multiplier_choice() {
122        let multiplier = 5.0;
123        let new_multiplier = 10.0;
124        let new_multiplier_effective_timestamp = 1;
125        let config = ScaledUiAmountConfig {
126            multiplier: PodF64::from(multiplier),
127            new_multiplier: PodF64::from(new_multiplier),
128            new_multiplier_effective_timestamp: UnixTimestamp::from(
129                new_multiplier_effective_timestamp,
130            ),
131            ..Default::default()
132        };
133        assert_eq!(
134            config.total_multiplier(0, new_multiplier_effective_timestamp),
135            new_multiplier
136        );
137        assert_eq!(
138            config.total_multiplier(0, new_multiplier_effective_timestamp - 1),
139            multiplier
140        );
141        assert_eq!(config.total_multiplier(0, 0), multiplier);
142        assert_eq!(config.total_multiplier(0, i64::MIN), multiplier);
143        assert_eq!(config.total_multiplier(0, i64::MAX), new_multiplier);
144    }
145
146    #[test]
147    fn specific_amount_to_ui_amount() {
148        // 5x
149        let config = ScaledUiAmountConfig {
150            multiplier: PodF64::from(5.0),
151            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
152            ..Default::default()
153        };
154        let ui_amount = config.amount_to_ui_amount(1, 0, 0).unwrap();
155        assert_eq!(ui_amount, "5");
156        // with 1 decimal place
157        let ui_amount = config.amount_to_ui_amount(1, 1, 0).unwrap();
158        assert_eq!(ui_amount, "0.5");
159        // with 10 decimal places
160        let ui_amount = config.amount_to_ui_amount(1, 10, 0).unwrap();
161        assert_eq!(ui_amount, "0.0000000005");
162
163        // huge amount with 10 decimal places
164        let ui_amount = config.amount_to_ui_amount(10_000_000_000, 10, 0).unwrap();
165        assert_eq!(ui_amount, "5");
166
167        // huge values
168        let config = ScaledUiAmountConfig {
169            multiplier: PodF64::from(f64::MAX),
170            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
171            ..Default::default()
172        };
173        let ui_amount = config.amount_to_ui_amount(u64::MAX, 0, 0).unwrap();
174        assert_eq!(ui_amount, "inf");
175
176        // truncation
177        let config = ScaledUiAmountConfig {
178            multiplier: PodF64::from(0.99),
179            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
180            ..Default::default()
181        };
182        // This is really 0.99999... but it gets truncated
183        let ui_amount = config.amount_to_ui_amount(101, 2, 0).unwrap();
184        assert_eq!(ui_amount, "0.99");
185    }
186
187    #[test]
188    fn specific_ui_amount_to_amount() {
189        // constant 5x
190        let config = ScaledUiAmountConfig {
191            multiplier: 5.0.into(),
192            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
193            ..Default::default()
194        };
195        let amount = config.try_ui_amount_into_amount("5.0", 0, 0).unwrap();
196        assert_eq!(1, amount);
197        // with 1 decimal place
198        let amount = config
199            .try_ui_amount_into_amount("0.500000000", 1, 0)
200            .unwrap();
201        assert_eq!(amount, 1);
202        // with 10 decimal places
203        let amount = config
204            .try_ui_amount_into_amount("0.00000000050000000000000000", 10, 0)
205            .unwrap();
206        assert_eq!(amount, 1);
207
208        // huge amount with 10 decimal places
209        let amount = config
210            .try_ui_amount_into_amount("5.0000000000000000", 10, 0)
211            .unwrap();
212        assert_eq!(amount, 10_000_000_000);
213
214        // huge values
215        let config = ScaledUiAmountConfig {
216            multiplier: 5.0.into(),
217            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
218            ..Default::default()
219        };
220        let amount = config
221            .try_ui_amount_into_amount("92233720368547758075", 0, 0)
222            .unwrap();
223        assert_eq!(amount, u64::MAX);
224        let config = ScaledUiAmountConfig {
225            multiplier: f64::MAX.into(),
226            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
227            ..Default::default()
228        };
229        // scientific notation "e"
230        let amount = config
231            .try_ui_amount_into_amount("1.7976931348623157e308", 0, 0)
232            .unwrap();
233        assert_eq!(amount, 1);
234        let config = ScaledUiAmountConfig {
235            multiplier: 9.745314011399998e288.into(),
236            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
237            ..Default::default()
238        };
239        let amount = config
240            .try_ui_amount_into_amount("1.7976931348623157e308", 0, 0)
241            .unwrap();
242        assert_eq!(amount, u64::MAX);
243        // scientific notation "E"
244        let amount = config
245            .try_ui_amount_into_amount("1.7976931348623157E308", 0, 0)
246            .unwrap();
247        assert_eq!(amount, u64::MAX);
248
249        // this is unfortunate, but underflows can happen due to floats
250        let config = ScaledUiAmountConfig {
251            multiplier: 1.0.into(),
252            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
253            ..Default::default()
254        };
255        assert_eq!(
256            u64::MAX,
257            config
258                .try_ui_amount_into_amount("18446744073709551616", 0, 0)
259                .unwrap() // u64::MAX + 1
260        );
261
262        // overflow u64 fail
263        let config = ScaledUiAmountConfig {
264            multiplier: 0.1.into(),
265            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
266            ..Default::default()
267        };
268        assert_eq!(
269            Err(ProgramError::InvalidArgument),
270            config.try_ui_amount_into_amount("18446744073709551615", 0, 0) // u64::MAX + 1
271        );
272
273        for fail_ui_amount in ["-0.0000000000000000000001", "inf", "-inf", "NaN"] {
274            assert_eq!(
275                Err(ProgramError::InvalidArgument),
276                config.try_ui_amount_into_amount(fail_ui_amount, 0, 0)
277            );
278        }
279
280        // truncation
281        let config = ScaledUiAmountConfig {
282            multiplier: PodF64::from(0.99),
283            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
284            ..Default::default()
285        };
286        // There are a few possibilities for what "0.99" means, it could be 101
287        // or 100 underlying tokens, but the result gives the fewest possible
288        // tokens that give that UI amount.
289        let amount = config.try_ui_amount_into_amount("0.99", 2, 0).unwrap();
290        assert_eq!(amount, 100);
291    }
292
293    #[test]
294    fn specific_amount_to_ui_amount_no_scale() {
295        let config = ScaledUiAmountConfig {
296            multiplier: 1.0.into(),
297            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
298            ..Default::default()
299        };
300        for (amount, expected) in [(23, "0.23"), (110, "1.1"), (4200, "42"), (0, "0")] {
301            let ui_amount = config
302                .amount_to_ui_amount(amount, TEST_DECIMALS, 0)
303                .unwrap();
304            assert_eq!(ui_amount, expected);
305        }
306    }
307
308    #[test]
309    fn specific_ui_amount_to_amount_no_scale() {
310        let config = ScaledUiAmountConfig {
311            multiplier: 1.0.into(),
312            new_multiplier_effective_timestamp: UnixTimestamp::from(1),
313            ..Default::default()
314        };
315        for (ui_amount, expected) in [
316            ("0.23", 23),
317            ("0.20", 20),
318            ("0.2000", 20),
319            (".2", 20),
320            ("1.1", 110),
321            ("1.10", 110),
322            ("42", 4200),
323            ("42.", 4200),
324            ("0", 0),
325        ] {
326            let amount = config
327                .try_ui_amount_into_amount(ui_amount, TEST_DECIMALS, 0)
328                .unwrap();
329            assert_eq!(expected, amount);
330        }
331
332        // this is invalid with normal mints, but rounding for this mint makes it ok
333        let amount = config
334            .try_ui_amount_into_amount("0.111", TEST_DECIMALS, 0)
335            .unwrap();
336        assert_eq!(11, amount);
337
338        // fail if invalid ui_amount passed in
339        for ui_amount in ["", ".", "0.t"] {
340            assert_eq!(
341                Err(ProgramError::InvalidArgument),
342                config.try_ui_amount_into_amount(ui_amount, TEST_DECIMALS, 0),
343            );
344        }
345    }
346
347    proptest! {
348        #[test]
349        fn amount_to_ui_amount(
350            scale in 0f64..=f64::MAX,
351            amount in 0..=u64::MAX,
352            decimals in 0u8..20u8,
353        ) {
354            let config = ScaledUiAmountConfig {
355                multiplier: scale.into(),
356                new_multiplier_effective_timestamp: UnixTimestamp::from(1),
357                ..Default::default()
358            };
359            let ui_amount = config.amount_to_ui_amount(amount, decimals, 0);
360            assert!(ui_amount.is_some());
361        }
362    }
363}