Skip to main content

r402_core/
amount.rs

1//! Human-readable currency amount parsing.
2//!
3//! This module provides [`MoneyAmount`], a type for parsing human-readable
4//! currency strings into precise decimal values suitable for conversion to
5//! on-chain token amounts.
6//!
7//! # Supported Formats
8//!
9//! - Plain numbers: `"100"`, `"0.01"`
10//! - With currency symbols: `"$10.50"`, `"€20"`
11//! - With thousand separators: `"1,000"`, `"1,000,000.50"`
12
13use std::fmt;
14use std::fmt::Display;
15use std::str::FromStr;
16
17use rust_decimal::Decimal;
18use rust_decimal::prelude::FromPrimitive;
19
20/// Strips all characters except digits, dots, and minus signs from a monetary string.
21fn strip_non_numeric(input: &str) -> String {
22    input
23        .chars()
24        .filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
25        .collect()
26}
27
28/// A parsed monetary amount with decimal precision.
29///
30/// This type represents a non-negative decimal value parsed from a
31/// human-readable string. It preserves the original precision, which
32/// is important when converting to token amounts with specific decimal places.
33///
34/// # Precision
35///
36/// The [`scale`](MoneyAmount::scale) method returns the number of decimal places,
37/// and [`mantissa`](MoneyAmount::mantissa) returns the value as an integer.
38/// For example, `"10.50"` has scale 2 and mantissa 1050.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct MoneyAmount(pub Decimal);
41
42impl MoneyAmount {
43    /// Returns the number of decimal places in the original input.
44    ///
45    /// This is used to verify that the input precision doesn't exceed
46    /// the token's decimal places.
47    #[must_use]
48    pub const fn scale(&self) -> u32 {
49        self.0.scale()
50    }
51
52    /// Returns the value as an unsigned integer (without decimal point).
53    ///
54    /// For example, `"12.34"` returns `1234`.
55    #[must_use]
56    pub const fn mantissa(&self) -> u128 {
57        self.0.mantissa().unsigned_abs()
58    }
59}
60
61/// Errors that can occur when parsing a monetary amount.
62#[derive(Debug, Clone, Copy, thiserror::Error)]
63#[non_exhaustive]
64pub enum MoneyAmountParseError {
65    /// The input string could not be parsed as a number.
66    #[error("Invalid number format")]
67    InvalidFormat,
68    /// The value is outside the allowed range.
69    #[error(
70        "Amount must be between {} and {}",
71        constants::MIN_STR,
72        constants::MAX_STR
73    )]
74    OutOfRange,
75    /// Negative values are not allowed.
76    #[error("Negative value is not allowed")]
77    Negative,
78    /// The input has more decimal places than the token supports.
79    #[error("Too big of a precision: {money} vs {token} on token")]
80    WrongPrecision {
81        /// Decimal places in the input.
82        money: u32,
83        /// Decimal places supported by the token.
84        token: u32,
85    },
86}
87
88mod constants {
89    use super::Decimal;
90
91    pub(super) const MIN_STR: &str = "0.000000001";
92    pub(super) const MAX_STR: &str = "999999999";
93
94    // 0.000000001 = 1 × 10⁻⁹
95    pub(super) const MIN: Decimal = Decimal::from_parts(1, 0, 0, false, 9);
96    // 999_999_999 × 10⁰
97    pub(super) const MAX: Decimal = Decimal::from_parts(999_999_999, 0, 0, false, 0);
98}
99
100impl MoneyAmount {
101    /// Parses a human-readable currency string into a [`MoneyAmount`].
102    ///
103    /// Currency symbols, thousand separators, and whitespace are stripped
104    /// before parsing. The result must be a non-negative number within
105    /// the allowed range.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if:
110    /// - The string cannot be parsed as a number
111    /// - The value is negative
112    /// - The value is outside the allowed range
113    pub fn parse(input: &str) -> Result<Self, MoneyAmountParseError> {
114        let cleaned = strip_non_numeric(input);
115
116        let parsed =
117            Decimal::from_str(&cleaned).map_err(|_| MoneyAmountParseError::InvalidFormat)?;
118
119        if parsed.is_sign_negative() {
120            return Err(MoneyAmountParseError::Negative);
121        }
122
123        if parsed < constants::MIN || parsed > constants::MAX {
124            return Err(MoneyAmountParseError::OutOfRange);
125        }
126
127        Ok(Self(parsed))
128    }
129}
130
131impl FromStr for MoneyAmount {
132    type Err = MoneyAmountParseError;
133
134    fn from_str(s: &str) -> Result<Self, Self::Err> {
135        Self::parse(s)
136    }
137}
138
139impl TryFrom<&str> for MoneyAmount {
140    type Error = MoneyAmountParseError;
141
142    fn try_from(value: &str) -> Result<Self, Self::Error> {
143        Self::from_str(value)
144    }
145}
146
147impl From<u128> for MoneyAmount {
148    fn from(value: u128) -> Self {
149        Self(Decimal::from(value))
150    }
151}
152
153impl TryFrom<f64> for MoneyAmount {
154    type Error = MoneyAmountParseError;
155
156    fn try_from(value: f64) -> Result<Self, Self::Error> {
157        let decimal = Decimal::from_f64(value).ok_or(MoneyAmountParseError::OutOfRange)?;
158        if decimal.is_sign_negative() {
159            return Err(MoneyAmountParseError::Negative);
160        }
161        if decimal < constants::MIN || decimal > constants::MAX {
162            return Err(MoneyAmountParseError::OutOfRange);
163        }
164        Ok(Self(decimal))
165    }
166}
167
168impl Display for MoneyAmount {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        write!(f, "{}", self.0.normalize())
171    }
172}
173
174/// A numeric type that can be constructed by scaling a mantissa by a power of 10.
175///
176/// This trait abstracts the conversion from a parsed [`MoneyAmount`] mantissa
177/// to a chain-specific numeric type (e.g., `u64` for Solana, `U256` for EVM),
178/// enabling shared parsing logic in [`MoneyAmount::to_token_amount`].
179pub trait ScaleFromMantissa: Sized {
180    /// Constructs a value equal to `mantissa × 10^scale_diff`.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`MoneyAmountParseError::OutOfRange`] if the result overflows
185    /// the target type.
186    fn from_mantissa_scaled(mantissa: u128, scale_diff: u32)
187    -> Result<Self, MoneyAmountParseError>;
188}
189
190impl ScaleFromMantissa for u64 {
191    fn from_mantissa_scaled(
192        mantissa: u128,
193        scale_diff: u32,
194    ) -> Result<Self, MoneyAmountParseError> {
195        let multiplier = 10u64
196            .checked_pow(scale_diff)
197            .ok_or(MoneyAmountParseError::OutOfRange)?;
198        let digits = Self::try_from(mantissa).map_err(|_| MoneyAmountParseError::OutOfRange)?;
199        digits
200            .checked_mul(multiplier)
201            .ok_or(MoneyAmountParseError::OutOfRange)
202    }
203}
204
205impl ScaleFromMantissa for u128 {
206    fn from_mantissa_scaled(
207        mantissa: u128,
208        scale_diff: u32,
209    ) -> Result<Self, MoneyAmountParseError> {
210        let multiplier = 10u128
211            .checked_pow(scale_diff)
212            .ok_or(MoneyAmountParseError::OutOfRange)?;
213        mantissa
214            .checked_mul(multiplier)
215            .ok_or(MoneyAmountParseError::OutOfRange)
216    }
217}
218
219impl MoneyAmount {
220    /// Converts this amount to a token-specific integer scaled to the given decimal places.
221    ///
222    /// This is the shared logic used by chain-specific token deployments (EVM, Solana)
223    /// to convert human-readable amounts into on-chain token units.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if:
228    /// - The input precision exceeds the token's decimal places
229    /// - The scaled value overflows the target type `T`
230    pub fn to_token_amount<T: ScaleFromMantissa>(
231        self,
232        decimals: u8,
233    ) -> Result<T, MoneyAmountParseError> {
234        let scale = self.scale();
235        let token_scale = u32::from(decimals);
236        if scale > token_scale {
237            return Err(MoneyAmountParseError::WrongPrecision {
238                money: scale,
239                token: token_scale,
240            });
241        }
242        let scale_diff = token_scale - scale;
243        T::from_mantissa_scaled(self.mantissa(), scale_diff)
244    }
245}