tempo-x402 3.5.0

x402 payment protocol library for Tempo blockchain (EIP-712, TIP-20, wallet, client)
Documentation
//! [`SchemeServer`](crate::scheme::SchemeServer) implementation for the Tempo blockchain.
//!
//! [`TempoSchemeServer`] parses human-readable price strings (e.g. `"$0.001"`)
//! into on-chain token amounts using integer-only arithmetic (no floating point).

use crate::constants::ChainConfig;
use crate::error::X402Error;
use crate::scheme::SchemeServer;
use alloy::primitives::Address;

/// Server-side scheme: parses prices and builds payment requirements.
pub struct TempoSchemeServer {
    config: ChainConfig,
}

impl TempoSchemeServer {
    pub fn new() -> Self {
        Self {
            config: ChainConfig::default(),
        }
    }

    pub fn with_chain_config(config: ChainConfig) -> Self {
        Self { config }
    }
}

impl Default for TempoSchemeServer {
    fn default() -> Self {
        Self::new()
    }
}

impl SchemeServer for TempoSchemeServer {
    fn parse_price(&self, price: &str) -> Result<(String, Address), X402Error> {
        // Validate input: only allow optional leading '$', digits, and a single '.'
        let stripped = price.strip_prefix('$').unwrap_or(price);
        if stripped.is_empty() {
            return Err(X402Error::InvalidPayment(format!(
                "invalid price '{price}': no numeric content"
            )));
        }
        // Reject any characters that aren't digits or '.'
        if !stripped.chars().all(|c| c.is_ascii_digit() || c == '.') {
            return Err(X402Error::InvalidPayment(format!(
                "invalid price '{price}': contains invalid characters (only digits, '.', and optional leading '$' allowed)"
            )));
        }
        // Reject multiple decimal points
        if stripped.matches('.').count() > 1 {
            return Err(X402Error::InvalidPayment(format!(
                "invalid price '{price}': multiple decimal points"
            )));
        }
        let cleaned = stripped.to_string();

        if cleaned.is_empty() {
            return Err(X402Error::InvalidPayment(format!(
                "invalid price '{price}': no numeric content"
            )));
        }

        // Validate token_decimals is safe for u64 arithmetic
        if self.config.token_decimals > 18 {
            return Err(X402Error::ConfigError(format!(
                "token_decimals {} exceeds maximum 18",
                self.config.token_decimals
            )));
        }

        // Integer-only parsing: split on decimal point, compute from parts.
        // No f64 anywhere in the pipeline.
        let amount = match cleaned.split_once('.') {
            Some((integer_part, fractional_part)) => {
                let integer: u64 = if integer_part.is_empty() {
                    0
                } else {
                    integer_part.parse::<u64>().map_err(|e| {
                        X402Error::InvalidPayment(format!(
                            "invalid price '{price}': integer part: {e}"
                        ))
                    })?
                };

                // Pad or truncate fractional part to TOKEN_DECIMALS digits
                let decimals = self.config.token_decimals as usize;
                let frac_str = if fractional_part.len() >= decimals {
                    &fractional_part[..decimals]
                } else {
                    // Pad with trailing zeros -- we'll handle this inline
                    fractional_part
                };

                let fractional: u64 = if frac_str.is_empty() {
                    0
                } else {
                    frac_str.parse::<u64>().map_err(|e| {
                        X402Error::InvalidPayment(format!(
                            "invalid price '{price}': fractional part: {e}"
                        ))
                    })?
                };

                // Scale fractional part if it had fewer digits than TOKEN_DECIMALS
                let actual_digits = frac_str.len();
                let scale = if actual_digits < decimals {
                    10u64.pow((decimals - actual_digits) as u32)
                } else {
                    1
                };

                let multiplier = 10u64.pow(self.config.token_decimals);
                let integer_part = integer.checked_mul(multiplier).ok_or_else(|| {
                    X402Error::InvalidPayment(format!("invalid price '{price}': overflow"))
                })?;
                let fractional_part = fractional.checked_mul(scale).ok_or_else(|| {
                    X402Error::InvalidPayment(format!("invalid price '{price}': overflow"))
                })?;
                integer_part.checked_add(fractional_part).ok_or_else(|| {
                    X402Error::InvalidPayment(format!("invalid price '{price}': overflow"))
                })?
            }
            None => {
                // No decimal point -- treat as whole number
                let integer: u64 = cleaned.parse::<u64>().map_err(|e| {
                    X402Error::InvalidPayment(format!("invalid price '{price}': {e}"))
                })?;
                let multiplier = 10u64.pow(self.config.token_decimals);
                integer.checked_mul(multiplier).ok_or_else(|| {
                    X402Error::InvalidPayment(format!("invalid price '{price}': overflow"))
                })?
            }
        };

        Ok((amount.to_string(), self.config.default_token))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::constants::DEFAULT_TOKEN;

    #[test]
    fn test_parse_dollar_price() {
        let server = TempoSchemeServer::new();
        let (amount, asset) = server.parse_price("$0.001").unwrap();
        assert_eq!(amount, "1000");
        assert_eq!(asset, DEFAULT_TOKEN);
    }

    #[test]
    fn test_parse_numeric_price() {
        let server = TempoSchemeServer::new();
        let (amount, _) = server.parse_price("0.01").unwrap();
        assert_eq!(amount, "10000");
    }

    #[test]
    fn test_parse_whole_dollar() {
        let server = TempoSchemeServer::new();
        let (amount, _) = server.parse_price("$1").unwrap();
        assert_eq!(amount, "1000000");
    }

    #[test]
    fn test_parse_large_amount() {
        let server = TempoSchemeServer::new();
        let (amount, _) = server.parse_price("$100.50").unwrap();
        assert_eq!(amount, "100500000");
    }

    #[test]
    fn test_parse_six_decimals() {
        let server = TempoSchemeServer::new();
        let (amount, _) = server.parse_price("0.000001").unwrap();
        assert_eq!(amount, "1");
    }

    #[test]
    fn test_parse_truncates_beyond_decimals() {
        let server = TempoSchemeServer::new();
        // 7 decimal digits -- should truncate to 6
        let (amount, _) = server.parse_price("0.0000019").unwrap();
        assert_eq!(amount, "1");
    }

    #[test]
    fn test_parse_empty_fails() {
        let server = TempoSchemeServer::new();
        assert!(server.parse_price("$").is_err());
    }

    #[test]
    fn test_parse_overflow_fails() {
        let server = TempoSchemeServer::new();
        // This would overflow u64 when multiplied by 10^6
        assert!(server.parse_price("$99999999999999999999").is_err());
    }
}