Skip to main content

soroban_cli/
fixed_point.rs

1use std::fmt::{self, Display};
2
3/// A fixed-point number: a raw integer `value` interpreted as scaled by
4/// `10^decimals` (e.g. a token balance in its smallest unit alongside the
5/// token's `decimals`).
6///
7/// Formatting inserts the decimal point by manipulating the digit string
8/// directly, so it never computes `10.pow(decimals)`. That means it cannot
9/// overflow regardless of how large `decimals` is — important because
10/// `decimals` is often contract-controlled and unbounded.
11#[derive(Clone, Copy, Debug)]
12pub struct FixedPoint {
13    value: i128,
14    decimals: u32,
15}
16
17impl FixedPoint {
18    #[must_use]
19    pub fn new(value: i128, decimals: u32) -> Self {
20        Self { value, decimals }
21    }
22}
23
24impl Display for FixedPoint {
25    /// Renders the value as a decimal string with trailing zeros trimmed
26    /// (e.g. `12345000` at 7 decimals → `1.2345`, `10000000` → `1`).
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        if self.decimals == 0 {
29            return write!(f, "{}", self.value);
30        }
31
32        let decimals = self.decimals as usize;
33        // `unsigned_abs` yields the magnitude as a u128, which is safe even for
34        // `i128::MIN` (whose absolute value doesn't fit in an i128).
35        let digits = self.value.unsigned_abs().to_string();
36        // Left-pad so there is at least one integer digit ahead of the
37        // `decimals` fractional digits.
38        let digits = if digits.len() <= decimals {
39            format!("{digits:0>width$}", width = decimals + 1)
40        } else {
41            digits
42        };
43
44        let point = digits.len() - decimals;
45        let integer_part = &digits[..point];
46        let fractional_part = digits[point..].trim_end_matches('0');
47        let sign = if self.value < 0 { "-" } else { "" };
48
49        if fractional_part.is_empty() {
50            write!(f, "{sign}{integer_part}")
51        } else {
52            write!(f, "{sign}{integer_part}.{fractional_part}")
53        }
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    fn fmt(value: i128, decimals: u32) -> String {
62        FixedPoint::new(value, decimals).to_string()
63    }
64
65    #[test]
66    #[allow(clippy::unreadable_literal)]
67    fn formats_typical_values() {
68        assert_eq!(fmt(0, 7), "0");
69        assert_eq!(fmt(1234567, 7), "0.1234567");
70        assert_eq!(fmt(12345000, 7), "1.2345");
71        assert_eq!(fmt(10000000, 7), "1");
72        assert_eq!(fmt(123456789012345, 7), "12345678.9012345");
73        assert_eq!(fmt(1, 7), "0.0000001");
74        assert_eq!(fmt(12345, 0), "12345");
75        assert_eq!(fmt(12345, 1), "1234.5");
76    }
77
78    #[test]
79    #[allow(clippy::unreadable_literal)]
80    fn formats_negative_values() {
81        assert_eq!(fmt(-1234567, 7), "-0.1234567");
82        assert_eq!(fmt(-12345000, 7), "-1.2345");
83        assert_eq!(fmt(-10000000, 7), "-1");
84        assert_eq!(fmt(-5, 0), "-5");
85    }
86
87    #[test]
88    fn handles_i128_bounds_without_overflow() {
89        assert_eq!(
90            fmt(i128::MIN, 18),
91            "-170141183460469231731.687303715884105728"
92        );
93        assert_eq!(
94            fmt(i128::MAX, 18),
95            "170141183460469231731.687303715884105727"
96        );
97    }
98
99    #[test]
100    fn large_decimals_do_not_overflow() {
101        // `10.pow(decimals)` would overflow i128 for decimals >= 39 (and panic
102        // with a zero divisor for >= 128); string placement handles any scale.
103        assert_eq!(fmt(1, 39), "0.000000000000000000000000000000000000001");
104        assert_eq!(fmt(123, 255).chars().take(2).collect::<String>(), "0.");
105    }
106}