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///
28/// Parses from and displays as a decimal TRX string with six fractional digits;
29/// see [`parse_trx`] and [`format_trx`].
30#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize)]
31#[serde(transparent)]
32pub struct Trx(i64);
33
34impl Trx {
35    /// Zero TRX.
36    pub const ZERO: Trx = Trx(0);
37
38    /// Construct from a sun value without rejecting negatives.
39    ///
40    /// For protobuf round-tripping and malformed on-chain data only; prefer
41    /// [`Trx::from_sun`] for user input, balances, transfers, and contract calls.
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
78impl FromStr for Trx {
79    type Err = AmountError;
80
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        if s.starts_with('-') || !s.is_ascii() {
83            return Err(AmountError::ParseError(s.to_owned()));
84        }
85
86        let mut normalized = s.to_owned();
87        let decimal_len = if let Some(decimal_index) = normalized.find('.') {
88            normalized.remove(decimal_index);
89            normalized[decimal_index..].len()
90        } else {
91            0
92        };
93
94        // Match alloy's `parse_units`: discard fractional digits beyond the
95        // selected unit precision rather than rounding or returning an error.
96        if decimal_len > 6 {
97            normalized.truncate(normalized.len() - (decimal_len - 6));
98        }
99
100        let mut value = 0u64;
101        for byte in normalized.bytes() {
102            if byte == b'_' {
103                continue;
104            }
105            let digit = match byte {
106                b'0'..=b'9' => (byte - b'0') as u64,
107                _ => return Err(AmountError::ParseError(s.to_owned())),
108            };
109            value = value
110                .checked_mul(10)
111                .and_then(|v| v.checked_add(digit))
112                .ok_or_else(|| AmountError::ParseError(s.to_owned()))?;
113        }
114
115        let scale = 6usize.saturating_sub(decimal_len);
116        let value = value
117            .checked_mul(10u64.pow(scale as u32))
118            .filter(|&v| v <= MAX_SUN)
119            .ok_or_else(|| AmountError::ParseError(s.to_owned()))?;
120        Ok(Self(value as i64))
121    }
122}
123
124/// Parse a decimal TRX string (e.g. `"1.5"` or `"100"`) into a [`Trx`] amount.
125///
126/// Free-function alias for [`str::parse::<Trx>()`](Trx::from_str), mirroring
127/// alloy's [`parse_ether`](https://docs.rs/alloy-primitives/latest/alloy_primitives/utils/fn.parse_ether.html)
128/// for callers who prefer that style.
129///
130/// The accepted syntax mirrors alloy's `parse_units` with 6 decimal places:
131/// leading decimal points and `_` separators are accepted, an empty string is
132/// zero, and fractional digits beyond sun precision are truncated. Negative
133/// values remain invalid because native TRX amounts are non-negative.
134///
135/// ```
136/// use tronz_primitives::{Trx, parse_trx};
137///
138/// assert_eq!(parse_trx("1.5").unwrap().as_sun(), 1_500_000);
139/// assert_eq!(parse_trx(".5").unwrap().as_sun(), 500_000);
140/// assert_eq!(parse_trx("0.000001").unwrap().as_sun(), 1);
141/// assert_eq!(parse_trx("1.0000009").unwrap().as_sun(), 1_000_000);
142/// assert_eq!("1".parse::<Trx>().unwrap().as_sun(), 1_000_000);
143/// assert!(parse_trx("-1").is_err());
144/// ```
145pub fn parse_trx(s: &str) -> Result<Trx, AmountError> {
146    s.parse()
147}
148
149/// Format a [`Trx`] amount as a decimal string with exactly 6 fractional digits.
150///
151/// Free-function alias for [`Trx`]'s [`Display`](fmt::Display), mirroring alloy's
152/// [`format_ether`](https://docs.rs/alloy-primitives/latest/alloy_primitives/utils/fn.format_ether.html).
153/// The conversion is exact — no `f64` is involved — matching `format_units`.
154///
155/// ```
156/// use tronz_primitives::{Trx, format_trx};
157///
158/// assert_eq!(format_trx(Trx::from_sun(1_500_000).unwrap()), "1.500000");
159/// assert_eq!(format_trx(Trx::from_sun(1).unwrap()), "0.000001");
160/// assert_eq!("100".parse::<Trx>().unwrap().to_string(), "100.000000");
161/// ```
162pub fn format_trx(amount: Trx) -> String {
163    amount.to_string()
164}
165
166impl Add for Trx {
167    type Output = Trx;
168    /// # Panics
169    ///
170    /// Panics on `i64` overflow or a negative result. Use
171    /// [`Trx::checked_add`] for a non-panicking alternative.
172    fn add(self, rhs: Trx) -> Trx {
173        self.checked_add(rhs).expect("TRX addition overflows or contains a negative operand")
174    }
175}
176
177impl Sub for Trx {
178    type Output = Trx;
179    /// # Panics
180    ///
181    /// Panics on `i64` overflow or a negative result. Use
182    /// [`Trx::checked_sub`] for a non-panicking alternative.
183    fn sub(self, rhs: Trx) -> Trx {
184        self.checked_sub(rhs)
185            .expect("TRX subtraction underflows, overflows, or contains a negative operand")
186    }
187}
188
189impl fmt::Display for Trx {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        let abs = self.0.unsigned_abs();
192        let whole = abs / SUN_PER_TRX as u64;
193        let frac = abs % SUN_PER_TRX as u64;
194        let sign = if self.0 < 0 { "-" } else { "" };
195        write!(f, "{sign}{whole}.{frac:06}")
196    }
197}
198
199impl fmt::Debug for Trx {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        write!(f, "Trx({} sun)", self.0)
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn sun(value: i64) -> Trx {
210        Trx::from_sun(value).unwrap()
211    }
212
213    #[test]
214    fn conversions() {
215        assert_eq!("1".parse::<Trx>().unwrap().as_sun(), 1_000_000);
216        assert_eq!("1.5".parse::<Trx>().unwrap().as_sun(), 1_500_000);
217    }
218
219    #[test]
220    fn rejects_negative() {
221        assert!(Trx::from_sun(-1).is_err());
222        assert!("-1".parse::<Trx>().is_err());
223    }
224
225    #[test]
226    fn unchecked_allows_negative() {
227        assert_eq!(Trx::from_sun_unchecked(-5).as_sun(), -5);
228    }
229
230    #[test]
231    fn arithmetic() {
232        let a = "1".parse::<Trx>().unwrap();
233        let b = "0.5".parse::<Trx>().unwrap();
234        assert_eq!((a + b).as_sun(), 1_500_000);
235        assert_eq!((a - b).as_sun(), 500_000);
236        assert_eq!(a.checked_add(b), Some(Trx::from_sun(1_500_000).unwrap()));
237    }
238
239    #[test]
240    fn parse_valid() {
241        assert_eq!("1".parse::<Trx>().unwrap().as_sun(), 1_000_000);
242        assert_eq!("1.5".parse::<Trx>().unwrap().as_sun(), 1_500_000);
243        assert_eq!(".5".parse::<Trx>().unwrap().as_sun(), 500_000);
244        assert_eq!("0.000001".parse::<Trx>().unwrap().as_sun(), 1);
245        assert_eq!("100".parse::<Trx>().unwrap().as_sun(), 100_000_000);
246        assert_eq!("1.000000".parse::<Trx>().unwrap().as_sun(), 1_000_000);
247        assert_eq!("1_000".parse::<Trx>().unwrap().as_sun(), 1_000_000_000);
248        assert_eq!("1.".parse::<Trx>().unwrap().as_sun(), 1_000_000);
249        assert_eq!("".parse::<Trx>().unwrap(), Trx::ZERO);
250    }
251
252    #[test]
253    fn parse_invalid() {
254        assert!("-1".parse::<Trx>().is_err());
255        assert!("abc".parse::<Trx>().is_err());
256        assert!("1.abc".parse::<Trx>().is_err());
257        assert!(" 1 ".parse::<Trx>().is_err());
258        assert!("+1".parse::<Trx>().is_err());
259        assert!("1.金额".parse::<Trx>().is_err());
260    }
261
262    #[test]
263    fn parse_truncates_beyond_sun_precision() {
264        assert_eq!("1.1234567".parse::<Trx>().unwrap().as_sun(), 1_123_456);
265        assert_eq!("0.0000009".parse::<Trx>().unwrap(), Trx::ZERO);
266    }
267
268    #[test]
269    fn display_is_exact() {
270        assert_eq!(sun(1_500_000).to_string(), "1.500000");
271        assert_eq!("100".parse::<Trx>().unwrap().to_string(), "100.000000");
272        assert_eq!(sun(1).to_string(), "0.000001");
273        assert_eq!(Trx::ZERO.to_string(), "0.000000");
274        assert_eq!(Trx::from_sun_unchecked(-1_500_000).to_string(), "-1.500000");
275    }
276
277    #[test]
278    fn display_parse_round_trip() {
279        for &sun in &[0, 1, 1_000_000, 1_500_000, 100_000_000, 123_456] {
280            let t = Trx::from_sun(sun).unwrap();
281            assert_eq!(t.to_string().parse::<Trx>().unwrap(), t);
282        }
283    }
284
285    #[test]
286    fn alloy_style_helpers() {
287        assert_eq!(parse_trx("1.5").unwrap().as_sun(), 1_500_000);
288        assert_eq!(format_trx(sun(1_500_000)), "1.500000");
289    }
290
291    #[test]
292    fn matches_alloy_unit_helpers_within_tron_range() {
293        for input in ["", ".5", "1.", "1_000", "1.1234567", "9223372036854.775807"] {
294            let alloy = alloy_primitives::utils::parse_units(input, 6).unwrap();
295            let expected = u64::try_from(alloy).unwrap();
296            assert_eq!(input.parse::<Trx>().unwrap().as_sun(), expected as i64);
297        }
298
299        for amount in [Trx::ZERO, sun(1), sun(1_500_000), "100".parse().unwrap()] {
300            let alloy = alloy_primitives::utils::format_units(amount.as_sun(), 6).unwrap();
301            assert_eq!(amount.to_string(), alloy);
302        }
303    }
304
305    #[test]
306    fn parse_accepts_max_i64_sun() {
307        let max = "9223372036854.775807".parse::<Trx>().unwrap();
308        assert_eq!(max.as_sun(), i64::MAX);
309    }
310
311    #[test]
312    fn parse_rejects_above_max_i64_sun() {
313        assert!("9223372036854.775808".parse::<Trx>().is_err());
314    }
315
316    #[test]
317    fn checked_sub_rejects_negative() {
318        assert!(Trx::ZERO.checked_sub(sun(1)).is_none());
319    }
320
321    #[test]
322    fn checked_arithmetic_rejects_negative_operands() {
323        let negative = Trx::from_sun_unchecked(-5);
324        assert!(sun(10).checked_add(negative).is_none());
325        assert!(negative.checked_add(sun(10)).is_none());
326        assert!(sun(10).checked_sub(negative).is_none());
327        assert!(negative.checked_sub(sun(10)).is_none());
328    }
329}