diff --git a/CHANGELOG.md b/CHANGELOG.md index c555d19e59..5a53c82f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +* [fix][rust] `tokens_to_base_units` now returns `TokenParseError::AmountTooLarge` instead of `TokenParseError::ParseU64` when the combined integer+fractional string overflows `u64::MAX` ([#2506](https://github.com/0xMiden/rust-sdk/pull/2506)). + ### Breaking Changes * [BREAKING][removal][rust] Removed `Client::try_get_account`. Use `Client::get_account` and handle the `None` case, or `Client::account_reader` for existence checks and single-field reads that don't need the full materialized account ([#2362](https://github.com/0xMiden/rust-sdk/pull/2362)). diff --git a/bin/miden-cli/Cargo.toml b/bin/miden-cli/Cargo.toml index 6585334916..51a56f33db 100644 --- a/bin/miden-cli/Cargo.toml +++ b/bin/miden-cli/Cargo.toml @@ -25,6 +25,8 @@ testing = ["miden-client/testing"] # Workspace dependencies miden-client = { features = ["tonic"], workspace = true } miden-client-sqlite-store = { workspace = true } +miden-protocol = { workspace = true } +miden-standards = { workspace = true } miden-debug = { optional = true, workspace = true } # External dependencies diff --git a/bin/miden-cli/src/commands/account.rs b/bin/miden-cli/src/commands/account.rs index 25dd6a8060..7d5ab72919 100644 --- a/bin/miden-cli/src/commands/account.rs +++ b/bin/miden-cli/src/commands/account.rs @@ -17,7 +17,7 @@ use miden_client::asset::{Asset, TokenSymbol}; use miden_client::rpc::domain::account::GetAccountRequest; use miden_client::rpc::{GrpcClient, NodeRpcClient, VerifyingRpcClient}; use miden_client::transaction::{AccountComponentInterface, AccountInterface}; -use miden_client::utils::base_units_to_tokens; +use crate::utils::base_units_to_tokens; use miden_client::vm::{Package, PackageExport}; use miden_client::{Client, PrettyPrint, Word, ZERO}; diff --git a/bin/miden-cli/src/utils.rs b/bin/miden-cli/src/utils.rs index 51a4f86c42..f1f03c7647 100644 --- a/bin/miden-cli/src/utils.rs +++ b/bin/miden-cli/src/utils.rs @@ -5,7 +5,6 @@ use miden_client::account::{AccountId, FaucetMetadata}; use miden_client::address::{Address, AddressId}; use miden_client::asset::{Asset, FungibleAsset}; use miden_client::transaction::{ExecutedTransaction, InputNote}; -use miden_client::utils::{base_units_to_tokens, tokens_to_base_units}; use miden_client::vm::MIN_STACK_DEPTH; use miden_client::{Client, Felt, WORD_SIZE, Word}; use serde::Deserialize; @@ -15,6 +14,78 @@ use crate::commands::account::DEFAULT_ACCOUNT_ID_KEY; use crate::config::{CliConfig, get_global_miden_dir, get_local_miden_dir}; use crate::errors::CliError; +use core::num::{IntErrorKind, ParseIntError}; + +use miden_standards::account::faucets::FungibleFaucet; + +/// Errors that can occur when parsing a token represented as a decimal number in +/// a string into base units. +#[derive(thiserror::Error, Debug)] +pub(crate) enum TokenParseError { + #[error("Number of decimals {0} must be less than or equal to {max_decimals}", max_decimals = FungibleFaucet::MAX_DECIMALS)] + MaxDecimals(u8), + #[error("More than one decimal point")] + MultipleDecimalPoints, + #[error("Failed to parse u64")] + ParseU64(#[source] ParseIntError), + #[error("Amount has more than {0} decimal places")] + TooManyDecimals(u8), + #[error("Amount is too large")] + AmountTooLarge, + #[error("Amount is not a valid asset amount")] + InvalidAmount(#[source] miden_protocol::errors::AssetError), +} + + +/// Converts an amount in the faucet base units to the token's decimals. +pub(crate) fn base_units_to_tokens(units: miden_client::asset::AssetAmount, decimals: u8) -> String { + let units_str = units.as_u64().to_string(); + let len = units_str.len(); + if decimals == 0 { return units_str; } + if decimals as usize >= len { + "0.".to_owned() + &"0".repeat(decimals as usize - len) + &units_str + } else { + let integer_part = &units_str[..len - decimals as usize]; + let fractional_part = &units_str[len - decimals as usize..]; + format!("{integer_part}.{fractional_part}") + } +} + +/// Converts a decimal number string into base units. +pub(crate) fn tokens_to_base_units( + decimal_str: &str, + n_decimals: u8, +) -> Result { + if n_decimals > FungibleFaucet::MAX_DECIMALS { + return Err(TokenParseError::MaxDecimals(n_decimals)); + } + let parts: Vec<&str> = decimal_str.split('.').collect(); + if parts.len() > 2 { + return Err(TokenParseError::MultipleDecimalPoints); + } + for part in &parts { + part.parse::().map_err(|e| match e.kind() { + IntErrorKind::PosOverflow => TokenParseError::AmountTooLarge, + _ => TokenParseError::ParseU64(e), + })?; + } + let integer_part = parts[0]; + let mut fractional_part = if parts.len() > 1 { + parts[1].trim_end_matches('0').to_string() + } else { + String::new() + }; + if fractional_part.len() > n_decimals.into() { + return Err(TokenParseError::TooManyDecimals(n_decimals)); + } + while fractional_part.len() < n_decimals.into() { + fractional_part.push('0'); + } + let combined = format!("{}{}", integer_part, &fractional_part[0..n_decimals.into()]); + let units = combined.parse::().map_err(|_| TokenParseError::AmountTooLarge)?; + miden_client::asset::AssetAmount::new(units).map_err(TokenParseError::InvalidAmount) +} + pub(crate) const SHARED_TOKEN_DOCUMENTATION: &str = "There are two accepted formats for the asset: - `::` where `` is in the faucet base units. - `::` where `` is a decimal number representing the quantity of diff --git a/crates/rust-client/src/utils.rs b/crates/rust-client/src/utils.rs index eb74ad051a..b94bb1fb54 100644 --- a/crates/rust-client/src/utils.rs +++ b/crates/rust-client/src/utils.rs @@ -1,13 +1,7 @@ //! Provides various utilities that are commonly used throughout the Miden //! client library. -use alloc::string::{String, ToString}; -use alloc::vec::Vec; -use core::num::ParseIntError; -use miden_protocol::asset::AssetAmount; -use miden_protocol::errors::AssetError; -use miden_standards::account::faucets::FungibleFaucet; pub use miden_tx::utils::serde::{ ByteReader, ByteWriter, @@ -17,143 +11,3 @@ pub use miden_tx::utils::serde::{ }; pub use miden_tx::utils::sync::{LazyLock, RwLock, RwLockReadGuard, RwLockWriteGuard}; pub use miden_tx::utils::{ToHex, bytes_to_hex_string, hex_to_bytes}; - -use crate::alloc::borrow::ToOwned; - -/// Converts an amount in the faucet base units to the token's decimals. -/// -/// This is meant for display purposes only. -pub fn base_units_to_tokens(units: AssetAmount, decimals: u8) -> String { - let units_str = units.as_u64().to_string(); - let len = units_str.len(); - - if decimals == 0 { - return units_str; - } - - if decimals as usize >= len { - // Handle cases where the number of decimals is greater than the length of units - "0.".to_owned() + &"0".repeat(decimals as usize - len) + &units_str - } else { - // Insert the decimal point at the correct position - let integer_part = &units_str[..len - decimals as usize]; - let fractional_part = &units_str[len - decimals as usize..]; - format!("{integer_part}.{fractional_part}") - } -} - -/// Errors that can occur when parsing a token represented as a decimal number in -/// a string into base units. -#[derive(thiserror::Error, Debug)] -pub enum TokenParseError { - #[error("Number of decimals {0} must be less than or equal to {max_decimals}", max_decimals = FungibleFaucet::MAX_DECIMALS)] - MaxDecimals(u8), - #[error("More than one decimal point")] - MultipleDecimalPoints, - #[error("Failed to parse u64")] - ParseU64(#[source] ParseIntError), - #[error("Amount has more than {0} decimal places")] - TooManyDecimals(u8), - #[error("Amount is not a valid asset amount")] - InvalidAmount(#[source] AssetError), -} - -/// Converts a decimal number, represented as a string, into an integer by shifting -/// the decimal point to the right by a specified number of decimal places. -pub fn tokens_to_base_units( - decimal_str: &str, - n_decimals: u8, -) -> Result { - if n_decimals > FungibleFaucet::MAX_DECIMALS { - return Err(TokenParseError::MaxDecimals(n_decimals)); - } - - // Split the string on the decimal point - let parts: Vec<&str> = decimal_str.split('.').collect(); - - if parts.len() > 2 { - return Err(TokenParseError::MultipleDecimalPoints); - } - - // Validate that the parts are valid numbers - for part in &parts { - part.parse::().map_err(TokenParseError::ParseU64)?; - } - - // Get the integer part - let integer_part = parts[0]; - - // Get the fractional part; remove trailing zeros - let mut fractional_part = if parts.len() > 1 { - parts[1].trim_end_matches('0').to_string() - } else { - String::new() - }; - - // Check if the fractional part has more than N decimals - if fractional_part.len() > n_decimals.into() { - return Err(TokenParseError::TooManyDecimals(n_decimals)); - } - - // Add extra zeros if the fractional part is shorter than N decimals - while fractional_part.len() < n_decimals.into() { - fractional_part.push('0'); - } - - // Combine the integer and padded fractional part - let combined = format!("{}{}", integer_part, &fractional_part[0..n_decimals.into()]); - - // Convert the combined string to an integer - let units = combined.parse::().map_err(TokenParseError::ParseU64)?; - - AssetAmount::new(units).map_err(TokenParseError::InvalidAmount) -} - -// TESTS -// ================================================================================================ - -#[cfg(test)] -mod tests { - use miden_protocol::asset::AssetAmount; - - use crate::utils::{TokenParseError, base_units_to_tokens, tokens_to_base_units}; - - fn amount(units: u64) -> AssetAmount { - AssetAmount::new(units).unwrap() - } - - #[test] - fn convert_tokens_to_base_units() { - assert_eq!(tokens_to_base_units("9223372.034707292160", 12).unwrap(), AssetAmount::MAX); - assert_eq!(tokens_to_base_units("7531.2468", 8).unwrap(), amount(753_124_680_000)); - assert_eq!(tokens_to_base_units("7531.2468", 4).unwrap(), amount(75_312_468)); - assert_eq!(tokens_to_base_units("0", 3).unwrap(), AssetAmount::ZERO); - assert_eq!(tokens_to_base_units("1234", 8).unwrap(), amount(123_400_000_000)); - assert_eq!(tokens_to_base_units("1", 0).unwrap(), amount(1)); - assert!(matches!( - tokens_to_base_units("1.1", 0), - Err(TokenParseError::TooManyDecimals(0)) - ),); - assert!(matches!( - tokens_to_base_units("18446744.073709551615", 11), - Err(TokenParseError::TooManyDecimals(11)) - ),); - assert!(matches!(tokens_to_base_units("123u3.23", 4), Err(TokenParseError::ParseU64(_))),); - assert!(matches!(tokens_to_base_units("2.k3", 4), Err(TokenParseError::ParseU64(_))),); - assert_eq!(tokens_to_base_units("12.345000", 4).unwrap(), amount(123_450)); - assert!(tokens_to_base_units("0.0001.00000001", 12).is_err()); - // Parses as a u64 but exceeds the maximum representable asset amount. - assert!(matches!( - tokens_to_base_units("18446744.073709551615", 12), - Err(TokenParseError::InvalidAmount(_)) - ),); - } - - #[test] - fn convert_base_units_to_tokens() { - assert_eq!(base_units_to_tokens(AssetAmount::MAX, 12), "9223372.034707292160"); - assert_eq!(base_units_to_tokens(amount(753_124_680_000), 8), "7531.24680000"); - assert_eq!(base_units_to_tokens(amount(75_312_468), 4), "7531.2468"); - assert_eq!(base_units_to_tokens(amount(75_312_468), 0), "75312468"); - } -}