Skip to main content

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