Skip to main content

tronz_primitives/
amount.rs

1//! TRX amount type.
2//!
3//! TRON denominates value in *sun*, where `1 TRX = 1_000_000 sun`. [`Trx`]
4//! wraps an `i64` sun value to match the protobuf `sint64` representation.
5
6use core::{
7    fmt,
8    ops::{Add, Sub},
9    str::FromStr,
10};
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::AmountError;
15
16/// Maximum sun value that fits in TRON's signed 64-bit amount fields.
17const MAX_SUN: u64 = i64::MAX as u64;
18
19/// Number of sun in one TRX.
20pub const SUN_PER_TRX: i64 = 1_000_000;
21
22/// An amount of TRX, stored internally as `i64` sun.
23///
24/// User-facing constructors enforce non-negative amounts. Negative values remain
25/// representable only so malformed protobuf or serialized data can be inspected
26/// and round-tripped without panicking; arithmetic operations reject them.
27#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize)]
28#[serde(transparent)]
29pub struct Trx(i64);
30
31impl Trx {
32    /// Zero TRX.
33    pub const ZERO: Trx = Trx(0);
34
35    /// Construct directly from a sun value without validation.
36    ///
37    /// This bypasses the non-negative amount invariant. It exists for protobuf
38    /// round-tripping and malformed on-chain data handling; do not use it for
39    /// user input, balances, transfers, or contract calls. Prefer
40    /// [`Trx::from_sun`] for raw user-facing input.
41    #[doc(hidden)]
42    pub const fn from_sun_unchecked(sun: i64) -> Self {
43        Self(sun)
44    }
45
46    /// Construct from a raw sun value, rejecting negatives.
47    pub const fn from_sun(sun: i64) -> Result<Self, AmountError> {
48        if sun < 0 {
49            return Err(AmountError::Negative(sun));
50        }
51        Ok(Self(sun))
52    }
53
54    /// The raw sun value.
55    pub const fn as_sun(self) -> i64 {
56        self.0
57    }
58
59    /// Checked addition. Returns `None` on `i64` overflow or if either operand
60    /// is negative.
61    pub fn checked_add(self, rhs: Trx) -> Option<Trx> {
62        if self.0 < 0 || rhs.0 < 0 {
63            return None;
64        }
65        self.0.checked_add(rhs.0).filter(|&v| v >= 0).map(Trx)
66    }
67
68    /// Checked subtraction. Returns `None` on `i64` overflow, if either operand
69    /// is negative, or if the result would be negative.
70    pub fn checked_sub(self, rhs: Trx) -> Option<Trx> {
71        if self.0 < 0 || rhs.0 < 0 {
72            return None;
73        }
74        self.0.checked_sub(rhs.0).filter(|&v| v >= 0).map(Trx)
75    }
76}
77
78/// Parse a decimal TRX string (e.g. `"1.5"` or `"100"`) into sun.
79///
80/// The accepted syntax mirrors alloy's `parse_units` with 6 decimal places:
81/// leading decimal points and `_` separators are accepted, an empty string is
82/// zero, and fractional digits beyond sun precision are truncated. Negative
83/// values remain invalid because native TRX amounts are non-negative.
84///
85/// # Examples
86///
87/// ```
88/// use tronz_primitives::Trx;
89///
90/// assert_eq!("1".parse::<Trx>().unwrap().as_sun(), 1_000_000);
91/// assert_eq!("1.5".parse::<Trx>().unwrap().as_sun(), 1_500_000);
92/// assert_eq!(".5".parse::<Trx>().unwrap().as_sun(), 500_000);
93/// assert_eq!("0.000001".parse::<Trx>().unwrap().as_sun(), 1);
94/// assert_eq!("1.0000009".parse::<Trx>().unwrap().as_sun(), 1_000_000);
95/// assert!("-1".parse::<Trx>().is_err());
96/// ```
97impl FromStr for Trx {
98    type Err = AmountError;
99
100    fn from_str(s: &str) -> Result<Self, Self::Err> {
101        if s.starts_with('-') || !s.is_ascii() {
102            return Err(AmountError::ParseError(s.to_owned()));
103        }
104
105        let mut normalized = s.to_owned();
106        let decimal_len = if let Some(decimal_index) = normalized.find('.') {
107            normalized.remove(decimal_index);
108            normalized[decimal_index..].len()
109        } else {
110            0
111        };
112
113        // Match alloy's `parse_units`: discard fractional digits beyond the
114        // selected unit precision rather than rounding or returning an error.
115        if decimal_len > 6 {
116            normalized.truncate(normalized.len() - (decimal_len - 6));
117        }
118
119        let mut value = 0u64;
120        for byte in normalized.bytes() {
121            if byte == b'_' {
122                continue;
123            }
124            let digit = match byte {
125                b'0'..=b'9' => (byte - b'0') as u64,
126                _ => return Err(AmountError::ParseError(s.to_owned())),
127            };
128            value = value
129                .checked_mul(10)
130                .and_then(|v| v.checked_add(digit))
131                .ok_or_else(|| AmountError::ParseError(s.to_owned()))?;
132        }
133
134        let scale = 6usize.saturating_sub(decimal_len);
135        let value = value
136            .checked_mul(10u64.pow(scale as u32))
137            .filter(|&v| v <= MAX_SUN)
138            .ok_or_else(|| AmountError::ParseError(s.to_owned()))?;
139        Ok(Self(value as i64))
140    }
141}
142
143/// Parse a decimal TRX string into a [`Trx`] amount.
144///
145/// Free-function alias for [`str::parse::<Trx>()`](Trx::from_str), mirroring
146/// alloy's [`parse_ether`](https://docs.rs/alloy-primitives/latest/alloy_primitives/utils/fn.parse_ether.html)
147/// for callers who prefer that style.
148///
149/// ```
150/// use tronz_primitives::parse_trx;
151///
152/// assert_eq!(parse_trx("1.5").unwrap().as_sun(), 1_500_000);
153/// ```
154pub fn parse_trx(s: &str) -> Result<Trx, AmountError> {
155    s.parse()
156}
157
158/// Format a [`Trx`] amount as a decimal string with exactly 6 fractional digits.
159///
160/// Free-function alias for [`Trx`]'s [`Display`](fmt::Display), mirroring alloy's
161/// [`format_ether`](https://docs.rs/alloy-primitives/latest/alloy_primitives/utils/fn.format_ether.html).
162///
163/// ```
164/// use tronz_primitives::{Trx, format_trx};
165///
166/// let amount = Trx::from_sun(1_500_000).unwrap();
167/// assert_eq!(format_trx(amount), "1.500000");
168/// ```
169pub fn format_trx(amount: Trx) -> String {
170    amount.to_string()
171}
172
173impl Add for Trx {
174    type Output = Trx;
175    /// # Panics
176    ///
177    /// Panics on `i64` overflow or a negative result. Use
178    /// [`Trx::checked_add`] for a non-panicking alternative.
179    fn add(self, rhs: Trx) -> Trx {
180        self.checked_add(rhs).expect("TRX addition overflows or contains a negative operand")
181    }
182}
183
184impl Sub for Trx {
185    type Output = Trx;
186    /// # Panics
187    ///
188    /// Panics on `i64` overflow or a negative result. Use
189    /// [`Trx::checked_sub`] for a non-panicking alternative.
190    fn sub(self, rhs: Trx) -> Trx {
191        self.checked_sub(rhs)
192            .expect("TRX subtraction underflows, overflows, or contains a negative operand")
193    }
194}
195
196/// Formats the amount as a fixed-precision decimal TRX string, exactly (no
197/// `f64`), mirroring alloy's `format_units` behavior.
198///
199/// ```
200/// use tronz_primitives::Trx;
201///
202/// assert_eq!(Trx::from_sun(1_500_000).unwrap().to_string(), "1.500000");
203/// assert_eq!("100".parse::<Trx>().unwrap().to_string(), "100.000000");
204/// assert_eq!(Trx::from_sun(1).unwrap().to_string(), "0.000001");
205/// assert_eq!("1.5".parse::<Trx>().unwrap().to_string(), "1.500000");
206/// ```
207impl fmt::Display for Trx {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        let abs = self.0.unsigned_abs();
210        let whole = abs / SUN_PER_TRX as u64;
211        let frac = abs % SUN_PER_TRX as u64;
212        let sign = if self.0 < 0 { "-" } else { "" };
213        write!(f, "{sign}{whole}.{frac:06}")
214    }
215}
216
217impl fmt::Debug for Trx {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        write!(f, "Trx({} sun)", self.0)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn sun(value: i64) -> Trx {
228        Trx::from_sun(value).unwrap()
229    }
230
231    #[test]
232    fn conversions() {
233        assert_eq!("1".parse::<Trx>().unwrap().as_sun(), 1_000_000);
234        assert_eq!("1.5".parse::<Trx>().unwrap().as_sun(), 1_500_000);
235    }
236
237    #[test]
238    fn rejects_negative() {
239        assert!(Trx::from_sun(-1).is_err());
240        assert!("-1".parse::<Trx>().is_err());
241    }
242
243    #[test]
244    fn unchecked_allows_negative() {
245        assert_eq!(Trx::from_sun_unchecked(-5).as_sun(), -5);
246    }
247
248    #[test]
249    fn arithmetic() {
250        let a = "1".parse::<Trx>().unwrap();
251        let b = "0.5".parse::<Trx>().unwrap();
252        assert_eq!((a + b).as_sun(), 1_500_000);
253        assert_eq!((a - b).as_sun(), 500_000);
254        assert_eq!(a.checked_add(b), Some(Trx::from_sun(1_500_000).unwrap()));
255    }
256
257    #[test]
258    fn parse_valid() {
259        assert_eq!("1".parse::<Trx>().unwrap().as_sun(), 1_000_000);
260        assert_eq!("1.5".parse::<Trx>().unwrap().as_sun(), 1_500_000);
261        assert_eq!(".5".parse::<Trx>().unwrap().as_sun(), 500_000);
262        assert_eq!("0.000001".parse::<Trx>().unwrap().as_sun(), 1);
263        assert_eq!("100".parse::<Trx>().unwrap().as_sun(), 100_000_000);
264        assert_eq!("1.000000".parse::<Trx>().unwrap().as_sun(), 1_000_000);
265        assert_eq!("1_000".parse::<Trx>().unwrap().as_sun(), 1_000_000_000);
266        assert_eq!("1.".parse::<Trx>().unwrap().as_sun(), 1_000_000);
267        assert_eq!("".parse::<Trx>().unwrap(), Trx::ZERO);
268    }
269
270    #[test]
271    fn parse_invalid() {
272        assert!("-1".parse::<Trx>().is_err());
273        assert!("abc".parse::<Trx>().is_err());
274        assert!("1.abc".parse::<Trx>().is_err());
275        assert!(" 1 ".parse::<Trx>().is_err());
276        assert!("+1".parse::<Trx>().is_err());
277        assert!("1.金额".parse::<Trx>().is_err());
278    }
279
280    #[test]
281    fn parse_truncates_beyond_sun_precision() {
282        assert_eq!("1.1234567".parse::<Trx>().unwrap().as_sun(), 1_123_456);
283        assert_eq!("0.0000009".parse::<Trx>().unwrap(), Trx::ZERO);
284    }
285
286    #[test]
287    fn display_is_exact() {
288        assert_eq!(sun(1_500_000).to_string(), "1.500000");
289        assert_eq!("100".parse::<Trx>().unwrap().to_string(), "100.000000");
290        assert_eq!(sun(1).to_string(), "0.000001");
291        assert_eq!(Trx::ZERO.to_string(), "0.000000");
292        assert_eq!(Trx::from_sun_unchecked(-1_500_000).to_string(), "-1.500000");
293    }
294
295    #[test]
296    fn display_parse_round_trip() {
297        for &sun in &[0, 1, 1_000_000, 1_500_000, 100_000_000, 123_456] {
298            let t = Trx::from_sun(sun).unwrap();
299            assert_eq!(t.to_string().parse::<Trx>().unwrap(), t);
300        }
301    }
302
303    #[test]
304    fn alloy_style_helpers() {
305        assert_eq!(parse_trx("1.5").unwrap().as_sun(), 1_500_000);
306        assert_eq!(format_trx(sun(1_500_000)), "1.500000");
307    }
308
309    #[test]
310    fn matches_alloy_unit_helpers_within_tron_range() {
311        for input in ["", ".5", "1.", "1_000", "1.1234567", "9223372036854.775807"] {
312            let alloy = alloy_primitives::utils::parse_units(input, 6).unwrap();
313            let expected = u64::try_from(alloy).unwrap();
314            assert_eq!(input.parse::<Trx>().unwrap().as_sun(), expected as i64);
315        }
316
317        for amount in [Trx::ZERO, sun(1), sun(1_500_000), "100".parse().unwrap()] {
318            let alloy = alloy_primitives::utils::format_units(amount.as_sun(), 6).unwrap();
319            assert_eq!(amount.to_string(), alloy);
320        }
321    }
322
323    #[test]
324    fn parse_accepts_max_i64_sun() {
325        let max = "9223372036854.775807".parse::<Trx>().unwrap();
326        assert_eq!(max.as_sun(), i64::MAX);
327    }
328
329    #[test]
330    fn parse_rejects_above_max_i64_sun() {
331        assert!("9223372036854.775808".parse::<Trx>().is_err());
332    }
333
334    #[test]
335    fn checked_sub_rejects_negative() {
336        assert!(Trx::ZERO.checked_sub(sun(1)).is_none());
337    }
338
339    #[test]
340    fn checked_arithmetic_rejects_negative_operands() {
341        let negative = Trx::from_sun_unchecked(-5);
342        assert!(sun(10).checked_add(negative).is_none());
343        assert!(negative.checked_add(sun(10)).is_none());
344        assert!(sun(10).checked_sub(negative).is_none());
345        assert!(negative.checked_sub(sun(10)).is_none());
346    }
347}