r402 0.13.0

Core types for the x402 payment protocol.
Documentation
//! Human-readable currency amount parsing.
//!
//! This module provides [`MoneyAmount`], a type for parsing human-readable
//! currency strings into precise decimal values suitable for conversion to
//! on-chain token amounts.
//!
//! # Supported Formats
//!
//! - Plain numbers: `"100"`, `"0.01"`
//! - With currency symbols: `"$10.50"`, `"€20"`
//! - With thousand separators: `"1,000"`, `"1,000,000.50"`

use std::fmt;
use std::fmt::Display;
use std::str::FromStr;

use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive;

/// Strips all characters except digits, dots, and minus signs from a monetary string.
fn strip_non_numeric(input: &str) -> String {
    input
        .chars()
        .filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
        .collect()
}

/// A parsed monetary amount with decimal precision.
///
/// This type represents a non-negative decimal value parsed from a
/// human-readable string. It preserves the original precision, which
/// is important when converting to token amounts with specific decimal places.
///
/// # Precision
///
/// The [`scale`](MoneyAmount::scale) method returns the number of decimal places,
/// and [`mantissa`](MoneyAmount::mantissa) returns the value as an integer.
/// For example, `"10.50"` has scale 2 and mantissa 1050.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MoneyAmount(pub Decimal);

impl MoneyAmount {
    /// Returns the number of decimal places in the original input.
    ///
    /// This is used to verify that the input precision doesn't exceed
    /// the token's decimal places.
    #[must_use]
    pub const fn scale(&self) -> u32 {
        self.0.scale()
    }

    /// Returns the value as an unsigned integer (without decimal point).
    ///
    /// For example, `"12.34"` returns `1234`.
    #[must_use]
    pub const fn mantissa(&self) -> u128 {
        self.0.mantissa().unsigned_abs()
    }
}

/// Errors that can occur when parsing a monetary amount.
#[derive(Debug, Clone, Copy, thiserror::Error)]
#[non_exhaustive]
pub enum MoneyAmountParseError {
    /// The input string could not be parsed as a number.
    #[error("Invalid number format")]
    InvalidFormat,
    /// The value is outside the allowed range.
    #[error(
        "Amount must be between {} and {}",
        constants::MIN_STR,
        constants::MAX_STR
    )]
    OutOfRange,
    /// Negative values are not allowed.
    #[error("Negative value is not allowed")]
    Negative,
    /// The input has more decimal places than the token supports.
    #[error("Too big of a precision: {money} vs {token} on token")]
    WrongPrecision {
        /// Decimal places in the input.
        money: u32,
        /// Decimal places supported by the token.
        token: u32,
    },
}

mod constants {
    use super::Decimal;

    pub(super) const MIN_STR: &str = "0.000000001";
    pub(super) const MAX_STR: &str = "999999999";

    // 0.000000001 = 1 × 10⁻⁹
    pub(super) const MIN: Decimal = Decimal::from_parts(1, 0, 0, false, 9);
    // 999_999_999 × 10⁰
    pub(super) const MAX: Decimal = Decimal::from_parts(999_999_999, 0, 0, false, 0);
}

impl MoneyAmount {
    /// Parses a human-readable currency string into a [`MoneyAmount`].
    ///
    /// Currency symbols, thousand separators, and whitespace are stripped
    /// before parsing. The result must be a non-negative number within
    /// the allowed range.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The string cannot be parsed as a number
    /// - The value is negative
    /// - The value is outside the allowed range
    pub fn parse(input: &str) -> Result<Self, MoneyAmountParseError> {
        let cleaned = strip_non_numeric(input);

        let parsed =
            Decimal::from_str(&cleaned).map_err(|_| MoneyAmountParseError::InvalidFormat)?;

        if parsed.is_sign_negative() {
            return Err(MoneyAmountParseError::Negative);
        }

        if parsed < constants::MIN || parsed > constants::MAX {
            return Err(MoneyAmountParseError::OutOfRange);
        }

        Ok(Self(parsed))
    }
}

impl FromStr for MoneyAmount {
    type Err = MoneyAmountParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::parse(s)
    }
}

impl TryFrom<&str> for MoneyAmount {
    type Error = MoneyAmountParseError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::from_str(value)
    }
}

impl From<u128> for MoneyAmount {
    fn from(value: u128) -> Self {
        Self(Decimal::from(value))
    }
}

impl TryFrom<f64> for MoneyAmount {
    type Error = MoneyAmountParseError;

    fn try_from(value: f64) -> Result<Self, Self::Error> {
        let decimal = Decimal::from_f64(value).ok_or(MoneyAmountParseError::OutOfRange)?;
        if decimal.is_sign_negative() {
            return Err(MoneyAmountParseError::Negative);
        }
        if decimal < constants::MIN || decimal > constants::MAX {
            return Err(MoneyAmountParseError::OutOfRange);
        }
        Ok(Self(decimal))
    }
}

impl Display for MoneyAmount {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0.normalize())
    }
}

/// A numeric type that can be constructed by scaling a mantissa by a power of 10.
///
/// This trait abstracts the conversion from a parsed [`MoneyAmount`] mantissa
/// to a chain-specific numeric type (e.g., `u64` for Solana, `U256` for EVM),
/// enabling shared parsing logic in [`MoneyAmount::to_token_amount`].
pub trait ScaleFromMantissa: Sized {
    /// Constructs a value equal to `mantissa × 10^scale_diff`.
    ///
    /// # Errors
    ///
    /// Returns [`MoneyAmountParseError::OutOfRange`] if the result overflows
    /// the target type.
    fn from_mantissa_scaled(mantissa: u128, scale_diff: u32)
    -> Result<Self, MoneyAmountParseError>;
}

impl ScaleFromMantissa for u64 {
    fn from_mantissa_scaled(
        mantissa: u128,
        scale_diff: u32,
    ) -> Result<Self, MoneyAmountParseError> {
        let multiplier = 10u64
            .checked_pow(scale_diff)
            .ok_or(MoneyAmountParseError::OutOfRange)?;
        let digits = Self::try_from(mantissa).map_err(|_| MoneyAmountParseError::OutOfRange)?;
        digits
            .checked_mul(multiplier)
            .ok_or(MoneyAmountParseError::OutOfRange)
    }
}

impl ScaleFromMantissa for u128 {
    fn from_mantissa_scaled(
        mantissa: u128,
        scale_diff: u32,
    ) -> Result<Self, MoneyAmountParseError> {
        let multiplier = 10u128
            .checked_pow(scale_diff)
            .ok_or(MoneyAmountParseError::OutOfRange)?;
        mantissa
            .checked_mul(multiplier)
            .ok_or(MoneyAmountParseError::OutOfRange)
    }
}

impl MoneyAmount {
    /// Converts this amount to a token-specific integer scaled to the given decimal places.
    ///
    /// This is the shared logic used by chain-specific token deployments (EVM, Solana)
    /// to convert human-readable amounts into on-chain token units.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The input precision exceeds the token's decimal places
    /// - The scaled value overflows the target type `T`
    pub fn to_token_amount<T: ScaleFromMantissa>(
        self,
        decimals: u8,
    ) -> Result<T, MoneyAmountParseError> {
        let scale = self.scale();
        let token_scale = u32::from(decimals);
        if scale > token_scale {
            return Err(MoneyAmountParseError::WrongPrecision {
                money: scale,
                token: token_scale,
            });
        }
        let scale_diff = token_scale - scale;
        T::from_mantissa_scaled(self.mantissa(), scale_diff)
    }
}