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