diff --git a/CHANGELOG.md b/CHANGELOG.md index 75bbd97a..f02f83c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ * The corresponding environment "ic" is defined implicitly which can be overwritten by user configuration. * The `--mainnet` and `--ic` flags are removed. Use `-n/--network ic`, `-e/--environment ic` instead. * feat: Allow overriding the implicit `local` network and environment. +* chore: get rid of `TCYCLES` mentions and replace them with `cycles` +* feat: Add `icp cycles transfer` as replacement for `icp token cycles transfer` +* chore!: remove support for `cycles` in `icp token`. Use `icp cycles` instead +* chore!: Change display format of token and cycles amounts +* feat: Token and cycles amounts now support new formats. Valid examples: `1_000`, `1k`, `1.5m`, `1_234.5b`, `4T` * feat: Allow installing WASMs that are larger than 2MB * feat: Add `icp identity account-id` command to display the ICP ledger account identifier * Supports `--of-principal` flag to convert a specific principal instead of the current identity diff --git a/Cargo.lock b/Cargo.lock index 9e750596..788c5f51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -499,9 +499,9 @@ checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" [[package]] name = "bigdecimal" -version = "0.4.8" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a22f228ab7a1b23027ccc6c350b72868017af7ea8356fbdf19f8d991c690013" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" dependencies = [ "autocfg", "libm", @@ -3218,6 +3218,9 @@ dependencies = [ "k256", "lazy_static", "nix 0.30.1", + "num-bigint", + "num-integer", + "num-traits", "p256", "pem 3.0.5", "phf", diff --git a/Cargo.toml b/Cargo.toml index aaaac847..6272e6b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ publish = false anyhow = "1.0.100" async-dropper = { version = "0.3.0", features = ["tokio", "simple"] } async-trait = "0.1.88" -bigdecimal = "0.4.8" +bigdecimal = "0.4.10" bip32 = "0.5.0" bollard = "0.19.4" byte-unit = "5.1.6" @@ -59,6 +59,9 @@ keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "sy lazy_static = "1.5.0" mockall = "0.13.1" notify = "8.2.0" +num-bigint = "0.4.6" +num-integer = "0.1.46" +num-traits = "0.2.19" p256 = { version = "0.13.2", features = ["pem", "pkcs8", "std"] } pathdiff = { version = "0.2.3", features = ["camino"] } pem = "3.0.5" diff --git a/crates/icp-canister-interfaces/src/cycles_ledger.rs b/crates/icp-canister-interfaces/src/cycles_ledger.rs index 9a090e34..70283353 100644 --- a/crates/icp-canister-interfaces/src/cycles_ledger.rs +++ b/crates/icp-canister-interfaces/src/cycles_ledger.rs @@ -1,4 +1,3 @@ -use bigdecimal::BigDecimal; use candid::{CandidType, Nat, Principal}; use serde::Deserialize; @@ -214,9 +213,9 @@ impl WithdrawError { WithdrawError::TooOld => "created_at_time is too old.".to_string(), WithdrawError::InsufficientFunds { balance } => { format!( - "Insufficient cycles. Requested: {}T cycles, balance: {}T cycles.", - BigDecimal::new(requested_amount.into(), CYCLES_LEDGER_DECIMALS), - BigDecimal::from_biguint(balance.0.clone(), CYCLES_LEDGER_DECIMALS) + "Insufficient cycles. Requested: {} cycles, balance: {} cycles.", + Nat::from(requested_amount), // Convert to Nat to get underscores in the output + balance.0 ) } } diff --git a/crates/icp-canister-interfaces/src/icp_ledger.rs b/crates/icp-canister-interfaces/src/icp_ledger.rs index dda4faa0..647f90ed 100644 --- a/crates/icp-canister-interfaces/src/icp_ledger.rs +++ b/crates/icp-canister-interfaces/src/icp_ledger.rs @@ -2,7 +2,7 @@ use candid::Principal; /// 0.0001 ICP, a.k.a. 10k e8s pub const ICP_LEDGER_BLOCK_FEE_E8S: u64 = 10_000; - +pub const ICP_LEDGER_SYMBOL: &str = "ICP"; pub const ICP_LEDGER_CID: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai"; pub const ICP_LEDGER_PRINCIPAL: Principal = Principal::from_slice(&[0, 0, 0, 0, 0, 0, 0, 2, 1, 1]); diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index aa5bd7b9..b3e198ca 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -42,6 +42,9 @@ indicatif.workspace = true itertools.workspace = true k256.workspace = true lazy_static.workspace = true +num-bigint.workspace = true +num-integer.workspace = true +num-traits.workspace = true p256.workspace = true pem.workspace = true phf.workspace = true diff --git a/crates/icp-cli/src/commands/canister/create.rs b/crates/icp-cli/src/commands/canister/create.rs index e3d08e6d..30cfe6e6 100644 --- a/crates/icp-cli/src/commands/canister/create.rs +++ b/crates/icp-cli/src/commands/canister/create.rs @@ -7,6 +7,7 @@ use icp_canister_interfaces::cycles_ledger::CanisterSettingsArg; use crate::{ commands::args, + commands::parsers::parse_cycles_amount, operations::create::CreateOperation, progress::{ProgressManager, ProgressManagerSettings}, }; @@ -49,8 +50,9 @@ pub(crate) struct CreateArgs { #[arg(long, short = 'q')] pub(crate) quiet: bool, - /// Cycles to fund canister creation (in raw cycles). - #[arg(long, default_value_t = DEFAULT_CANISTER_CYCLES)] + /// Cycles to fund canister creation. + /// Supports suffixes: k (thousand), m (million), b (billion), t (trillion). + #[arg(long, default_value_t = DEFAULT_CANISTER_CYCLES, value_parser = parse_cycles_amount)] pub(crate) cycles: u128, /// The subnet to create canisters on. diff --git a/crates/icp-cli/src/commands/canister/settings/update.rs b/crates/icp-cli/src/commands/canister/settings/update.rs index f6e2436b..5e8386a0 100644 --- a/crates/icp-cli/src/commands/canister/settings/update.rs +++ b/crates/icp-cli/src/commands/canister/settings/update.rs @@ -11,6 +11,7 @@ use std::collections::{HashMap, HashSet}; use std::io::Write; use crate::commands::args; +use crate::commands::parsers::parse_cycles_amount; #[derive(Clone, Debug, Default, Args)] pub(crate) struct ControllerOpt { @@ -101,7 +102,9 @@ pub(crate) struct UpdateArgs { #[arg(long, value_parser = freezing_threshold_parser)] freezing_threshold: Option, - #[arg(long, value_parser = reserved_cycles_limit_parser)] + /// Reserved cycles limit for the canister. + /// Supports suffixes: k (thousand), m (million), b (billion), t (trillion). + #[arg(long, value_parser = parse_cycles_amount)] reserved_cycles_limit: Option, #[arg(long, value_parser = memory_parser)] @@ -293,13 +296,6 @@ fn freezing_threshold_parser(freezing_threshold: &str) -> Result { Err("Must be a value between 0..2^64-1 inclusive".to_string()) } -fn reserved_cycles_limit_parser(reserved_cycles_limit: &str) -> Result { - if let Ok(num) = reserved_cycles_limit.parse::() { - return Ok(num); - } - Err("Must be a value between 0..2^128-1 inclusive".to_string()) -} - fn log_visibility_parser(log_visibility: &str) -> Result { match log_visibility { "public" => Ok(LogVisibility::Public), diff --git a/crates/icp-cli/src/commands/canister/top_up.rs b/crates/icp-cli/src/commands/canister/top_up.rs index 5e991aee..c3e36a85 100644 --- a/crates/icp-cli/src/commands/canister/top_up.rs +++ b/crates/icp-cli/src/commands/canister/top_up.rs @@ -4,15 +4,18 @@ use candid::{Decode, Encode, Nat}; use clap::Args; use icp::context::Context; use icp_canister_interfaces::cycles_ledger::{ - CYCLES_LEDGER_DECIMALS, CYCLES_LEDGER_PRINCIPAL, WithdrawArgs, WithdrawResponse, + CYCLES_LEDGER_PRINCIPAL, WithdrawArgs, WithdrawResponse, }; use crate::commands::args; +use crate::commands::parsers::parse_cycles_amount; +use crate::operations::token::TokenAmount; #[derive(Debug, Args)] pub(crate) struct TopUpArgs { - /// Amount of cycles to top up - #[arg(long)] + /// Amount of cycles to top up. + /// Supports suffixes: k (thousand), m (million), b (billion), t (trillion). + #[arg(long, value_parser = parse_cycles_amount)] pub(crate) amount: u128, #[command(flatten)] @@ -54,10 +57,14 @@ pub(crate) async fn exec(ctx: &Context, args: &TopUpArgs) -> Result<(), anyhow:: bail!("failed to top up: {}", err.format_error(args.amount)); } + let amount = TokenAmount { + amount: BigDecimal::new(args.amount.into(), 0), + symbol: "cycles".to_string(), + }; + let _ = ctx.term.write_line(&format!( - "Topped up canister {} with {}T cycles", - args.cmd_args.canister, - BigDecimal::new(args.amount.into(), CYCLES_LEDGER_DECIMALS) + "Topped up canister {} with {}", + args.cmd_args.canister, amount )); Ok(()) diff --git a/crates/icp-cli/src/commands/cycles/balance.rs b/crates/icp-cli/src/commands/cycles/balance.rs index ee4b8da7..0ffc75ff 100644 --- a/crates/icp-cli/src/commands/cycles/balance.rs +++ b/crates/icp-cli/src/commands/cycles/balance.rs @@ -1,7 +1,10 @@ +use bigdecimal::BigDecimal; use icp::context::Context; +use icp_canister_interfaces::cycles_ledger::CYCLES_LEDGER_PRINCIPAL; use crate::commands::token; -use crate::operations::token::balance::get_balance; +use crate::operations::token::TokenAmount; +use crate::operations::token::balance::get_raw_balance; pub(crate) async fn exec( ctx: &Context, @@ -19,13 +22,14 @@ pub(crate) async fn exec( .await?; // Get the balance from the ledger - let balance_info = get_balance(&agent, "cycles").await?; + let cycles = get_raw_balance(&agent, CYCLES_LEDGER_PRINCIPAL).await?; + let cycles_amount = TokenAmount { + amount: BigDecimal::from_biguint(cycles.0, 0), + symbol: "cycles".to_string(), + }; // Output information - let _ = ctx.term.write_line(&format!( - "Balance: {} {}", - balance_info.amount, balance_info.symbol - )); + let _ = ctx.term.write_line(&format!("Balance: {cycles_amount}")); Ok(()) } diff --git a/crates/icp-cli/src/commands/cycles/mint.rs b/crates/icp-cli/src/commands/cycles/mint.rs index 727656b8..2bf4a7b1 100644 --- a/crates/icp-cli/src/commands/cycles/mint.rs +++ b/crates/icp-cli/src/commands/cycles/mint.rs @@ -4,16 +4,19 @@ use clap::Args; use icp::context::Context; use crate::commands::args::TokenCommandArgs; +use crate::commands::parsers::{parse_cycles_amount, parse_token_amount}; use crate::operations::token::mint::mint_cycles; #[derive(Debug, Args)] pub(crate) struct MintArgs { /// Amount of ICP to mint to cycles. - #[arg(long, conflicts_with = "cycles")] + /// Supports suffixes: k (thousand), m (million), b (billion), t (trillion). + #[arg(long, conflicts_with = "cycles", value_parser = parse_token_amount)] pub(crate) icp: Option, /// Amount of cycles to mint. Automatically determines the amount of ICP needed. - #[arg(long, conflicts_with = "icp")] + /// Supports suffixes: k (thousand), m (million), b (billion), t (trillion). + #[arg(long, conflicts_with = "icp", value_parser = parse_cycles_amount)] pub(crate) cycles: Option, #[command(flatten)] @@ -42,7 +45,7 @@ pub(crate) async fn exec(ctx: &Context, args: &MintArgs) -> Result<(), anyhow::E // Display results let _ = ctx.term.write_line(&format!( - "Minted {} TCYCLES to your account, new balance: {} TCYCLES.", + "Minted {} to your account, new balance: {}.", mint_info.deposited, mint_info.new_balance )); diff --git a/crates/icp-cli/src/commands/cycles/mod.rs b/crates/icp-cli/src/commands/cycles/mod.rs index 5004aa92..91f2b56c 100644 --- a/crates/icp-cli/src/commands/cycles/mod.rs +++ b/crates/icp-cli/src/commands/cycles/mod.rs @@ -4,6 +4,7 @@ use crate::commands::token; pub(crate) mod balance; pub(crate) mod mint; +pub(crate) mod transfer; #[derive(Subcommand, Debug)] pub(crate) enum Command { @@ -12,4 +13,7 @@ pub(crate) enum Command { /// Convert icp to cycles Mint(mint::MintArgs), + + /// Transfer cycles to another principal + Transfer(transfer::TransferArgs), } diff --git a/crates/icp-cli/src/commands/cycles/transfer.rs b/crates/icp-cli/src/commands/cycles/transfer.rs new file mode 100644 index 00000000..b8122dc0 --- /dev/null +++ b/crates/icp-cli/src/commands/cycles/transfer.rs @@ -0,0 +1,55 @@ +use candid::Principal; +use clap::Args; +use icp::context::Context; +use icp_canister_interfaces::cycles_ledger::{CYCLES_LEDGER_BLOCK_FEE, CYCLES_LEDGER_PRINCIPAL}; + +use crate::commands::args::TokenCommandArgs; +use crate::commands::parsers::parse_cycles_amount; +use crate::operations::token::transfer::icrc1_transfer; + +#[derive(Debug, Args)] +pub(crate) struct TransferArgs { + /// Cycles amount to transfer. + /// Supports suffixes: k (thousand), m (million), b (billion), t (trillion). + #[arg(value_parser = parse_cycles_amount)] + pub(crate) amount: u128, + + /// The receiver of the cycles transfer + pub(crate) receiver: Principal, + + #[command(flatten)] + pub(crate) token_command_args: TokenCommandArgs, +} + +pub(crate) async fn exec(ctx: &Context, args: &TransferArgs) -> Result<(), anyhow::Error> { + let selections = args.token_command_args.selections(); + + // Agent + let agent = ctx + .get_agent( + &selections.identity, + &selections.network, + &selections.environment, + ) + .await?; + + // Execute transfer + let transfer_info = icrc1_transfer( + &agent, + CYCLES_LEDGER_PRINCIPAL, + args.amount.into(), + args.receiver, + CYCLES_LEDGER_BLOCK_FEE.into(), + 0, + "cycles".to_string(), + ) + .await?; + + // Output information + let _ = ctx.term.write_line(&format!( + "Transferred {} to {} in block {}", + transfer_info.transferred, transfer_info.receiver, transfer_info.block_index + )); + + Ok(()) +} diff --git a/crates/icp-cli/src/commands/mod.rs b/crates/icp-cli/src/commands/mod.rs index 99493446..8a28a18f 100644 --- a/crates/icp-cli/src/commands/mod.rs +++ b/crates/icp-cli/src/commands/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod environment; pub(crate) mod identity; pub(crate) mod network; pub(crate) mod new; +pub(crate) mod parsers; pub(crate) mod project; pub(crate) mod sync; pub(crate) mod token; diff --git a/crates/icp-cli/src/commands/parsers.rs b/crates/icp-cli/src/commands/parsers.rs new file mode 100644 index 00000000..58ccc5e6 --- /dev/null +++ b/crates/icp-cli/src/commands/parsers.rs @@ -0,0 +1,373 @@ +use bigdecimal::{BigDecimal, Signed, ToPrimitive}; +use num_bigint::{BigInt, BigUint}; +use num_integer::Integer; +use num_traits::{Zero, pow::Pow}; +use std::str::FromStr; + +/// Parse a token amount with support for suffixes (k, m, b, t) and underscores. +/// +/// Examples: +/// - `1` -> 1 +/// - `1000` -> 1000 +/// - `1_000` -> 1000 +/// - `1k` or `1K` -> 1000 +/// - `1m` or `1M` -> 1000000 +/// - `1b` or `1B` -> 1000000000 +/// - `1t` or `1T` -> 1000000000000 +/// - `0.5` -> 0.5 +/// - `0.5k` or `0.5K` -> 500 +pub(crate) fn parse_token_amount(input: &str) -> Result { + let input = input.trim(); + + if input.is_empty() { + return Err("Token amount cannot be empty".to_string()); + } + + // Check if the last character is a suffix + let (number_part, multiplier) = if let Some(last_char) = input.chars().last() { + match last_char.to_ascii_lowercase() { + 'k' => (&input[..input.len() - 1], 1_000u128), + 'm' => (&input[..input.len() - 1], 1_000_000u128), + 'b' => (&input[..input.len() - 1], 1_000_000_000u128), + 't' => (&input[..input.len() - 1], 1_000_000_000_000u128), + _ => (input, 1u128), + } + } else { + (input, 1u128) + }; + + // Remove underscores from the number part + let cleaned = number_part.replace('_', ""); + + // Parse as BigDecimal to maintain precision + let base = + BigDecimal::from_str(&cleaned).map_err(|_| format!("Invalid token amount: '{}'", input))?; + + // Check for negative values + if base.is_negative() { + return Err(format!("Token amount cannot be negative: '{}'", input)); + } + + // Multiply by the multiplier + let multiplier_decimal = BigDecimal::from(multiplier); + let result = base * multiplier_decimal; + + Ok(result) +} + +/// Convert a token amount (in token units) to the smallest unit amount by multiplying +/// by 10^token_decimals and checking that the result is an integer. +/// E.g. 1.5 ICP with 8 decimals = 150000000 e8s +/// +/// # Arguments +/// * `token_amount` - The token amount in token units (e.g., 1.5 ICP) +/// * `token_decimals` - The number of decimals for the token (e.g., 8 for ICP) +/// +/// # Returns +/// A `BigUint` representing the amount in the smallest unit (e.g., e8s for ICP) +/// +/// # Errors +/// Returns an error if the result is not an integer after multiplication. +pub(crate) fn to_token_unit_amount( + token_amount: BigDecimal, + token_decimals: u8, +) -> Result { + // Convert to internal representation: (mantissa, exponent) + // where value = mantissa * 10^(-exponent) + let (mantissa, exponent) = token_amount.into_bigint_and_exponent(); + + // To convert to unit amount, we need to multiply by 10^token_decimals + // mantissa * 10^(-exponent) * 10^token_decimals = mantissa * 10^(token_decimals - exponent) + let scale_adjustment = token_decimals as i64 - exponent; + + let ten = BigInt::from(10); + let result = if scale_adjustment >= 0 { + // Multiply by 10^scale_adjustment + let multiplier = ten.pow(scale_adjustment as u32); + mantissa * multiplier + } else { + // Divide by 10^(-scale_adjustment), checking for remainder + let divisor = ten.pow((-scale_adjustment) as u32); + let (quotient, remainder) = mantissa.div_rem(&divisor); + + if !remainder.is_zero() { + return Err(format!( + "Token amount cannot be represented with {} decimals (would result in fractional units)", + token_decimals + )); + } + quotient + }; + + // Convert to BigUint (should always be non-negative since we validated earlier) + result + .try_into() + .map_err(|_| "Token amount cannot be negative".to_string()) +} + +/// Parse a cycles amount with support for suffixes (k, m, b, t) and underscores. +/// Cycles have no decimal places, so the amount must be an integer. +/// +/// Examples: +/// - `1` -> 1 +/// - `1000` -> 1000 +/// - `1_000` -> 1000 +/// - `1k` or `1K` -> 1000 +/// - `1m` or `1M` -> 1000000 +/// - `1b` or `1B` -> 1000000000 +/// - `1t` or `1T` -> 1000000000000 +/// - `0.5k` or `0.5K` -> 500 +pub(crate) fn parse_cycles_amount(input: &str) -> Result { + let token_amount = parse_token_amount(input)?; + let unit_amount = to_token_unit_amount(token_amount, 0)?; + unit_amount + .to_u128() + .ok_or_else(|| format!("Cycles amount too large: '{}'", input)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Tests for parse_token_amount + #[test] + fn test_parse_token_amount_plain_numbers() { + assert_eq!( + parse_token_amount("1").unwrap(), + BigDecimal::from_str("1").unwrap() + ); + assert_eq!( + parse_token_amount("1000").unwrap(), + BigDecimal::from_str("1000").unwrap() + ); + assert_eq!( + parse_token_amount("123456789").unwrap(), + BigDecimal::from_str("123456789").unwrap() + ); + } + + #[test] + fn test_parse_token_amount_with_decimals() { + assert_eq!( + parse_token_amount("0.5").unwrap(), + BigDecimal::from_str("0.5").unwrap() + ); + assert_eq!( + parse_token_amount("1.25").unwrap(), + BigDecimal::from_str("1.25").unwrap() + ); + assert_eq!( + parse_token_amount("123.456789").unwrap(), + BigDecimal::from_str("123.456789").unwrap() + ); + } + + #[test] + fn test_parse_token_amount_with_underscores() { + assert_eq!( + parse_token_amount("1_000").unwrap(), + BigDecimal::from_str("1000").unwrap() + ); + assert_eq!( + parse_token_amount("1_000_000").unwrap(), + BigDecimal::from_str("1000000").unwrap() + ); + } + + #[test] + fn test_parse_token_amount_with_suffixes() { + assert_eq!( + parse_token_amount("1k").unwrap(), + BigDecimal::from_str("1000").unwrap() + ); + assert_eq!( + parse_token_amount("1.5m").unwrap(), + BigDecimal::from_str("1500000").unwrap() + ); + assert_eq!( + parse_token_amount("2b").unwrap(), + BigDecimal::from_str("2000000000").unwrap() + ); + assert_eq!( + parse_token_amount("0.5t").unwrap(), + BigDecimal::from_str("500000000000").unwrap() + ); + assert_eq!( + parse_token_amount("1K").unwrap(), + BigDecimal::from_str("1000").unwrap() + ); + assert_eq!( + parse_token_amount("1.5M").unwrap(), + BigDecimal::from_str("1500000").unwrap() + ); + assert_eq!( + parse_token_amount("2B").unwrap(), + BigDecimal::from_str("2000000000").unwrap() + ); + assert_eq!( + parse_token_amount("0.5T").unwrap(), + BigDecimal::from_str("500000000000").unwrap() + ); + } + + #[test] + fn test_parse_token_amount_errors() { + assert!(parse_token_amount("").is_err()); + assert!(parse_token_amount("abc").is_err()); + assert!(parse_token_amount("1.2.3").is_err()); + assert!(parse_token_amount("-1").is_err()); + } + + // Tests for to_token_unit_amount + #[test] + fn test_to_token_unit_amount_integer_result() { + // 1 ICP with 8 decimals = 100000000 e8s + let amount = BigDecimal::from_str("1").unwrap(); + let result = to_token_unit_amount(amount, 8).unwrap(); + assert_eq!(result, BigUint::from(100_000_000u128)); + + // 0.5 ICP with 8 decimals = 50000000 e8s + let amount = BigDecimal::from_str("0.5").unwrap(); + let result = to_token_unit_amount(amount, 8).unwrap(); + assert_eq!(result, BigUint::from(50_000_000u128)); + + // 1.12345678 ICP with 8 decimals = 112345678 e8s + let amount = BigDecimal::from_str("1.12345678").unwrap(); + let result = to_token_unit_amount(amount, 8).unwrap(); + assert_eq!(result, BigUint::from(112_345_678u128)); + } + + #[test] + fn test_to_token_unit_amount_zero_decimals() { + // Cycles have 0 decimals + let amount = BigDecimal::from_str("1000").unwrap(); + let result = to_token_unit_amount(amount, 0).unwrap(); + assert_eq!(result, BigUint::from(1000u128)); + } + + #[test] + fn test_to_token_unit_amount_fractional_error() { + // 1.123456789 ICP with 8 decimals would result in a fractional unit + let amount = BigDecimal::from_str("1.123456789").unwrap(); + let result = to_token_unit_amount(amount, 8); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("cannot be represented")); + } + + #[test] + fn test_to_token_unit_amount_large_numbers() { + // Very large amount + let amount = BigDecimal::from_str("1000000000000").unwrap(); + let result = to_token_unit_amount(amount, 8).unwrap(); + assert_eq!(result, BigUint::from(100_000_000_000_000_000_000u128)); + } + + // Tests for parse_cycles_amount + #[test] + fn test_parse_cycles_plain_numbers() { + assert_eq!(parse_cycles_amount("1").unwrap(), 1); + assert_eq!(parse_cycles_amount("1000").unwrap(), 1000); + assert_eq!(parse_cycles_amount("123456789").unwrap(), 123456789); + } + + #[test] + fn test_parse_cycles_with_underscores() { + assert_eq!(parse_cycles_amount("1_000").unwrap(), 1000); + assert_eq!(parse_cycles_amount("1_000_000").unwrap(), 1000000); + assert_eq!(parse_cycles_amount("123_456_789").unwrap(), 123456789); + } + + #[test] + fn test_parse_cycles_with_k_suffix() { + assert_eq!(parse_cycles_amount("1k").unwrap(), 1000); + assert_eq!(parse_cycles_amount("1K").unwrap(), 1000); + assert_eq!(parse_cycles_amount("5k").unwrap(), 5000); + assert_eq!(parse_cycles_amount("1.5k").unwrap(), 1500); + } + + #[test] + fn test_parse_cycles_with_m_suffix() { + assert_eq!(parse_cycles_amount("1m").unwrap(), 1000000); + assert_eq!(parse_cycles_amount("1M").unwrap(), 1000000); + assert_eq!(parse_cycles_amount("5m").unwrap(), 5000000); + assert_eq!(parse_cycles_amount("2.5m").unwrap(), 2500000); + } + + #[test] + fn test_parse_cycles_with_b_suffix() { + assert_eq!(parse_cycles_amount("1b").unwrap(), 1000000000); + assert_eq!(parse_cycles_amount("1B").unwrap(), 1000000000); + assert_eq!(parse_cycles_amount("3b").unwrap(), 3000000000); + assert_eq!(parse_cycles_amount("1.5b").unwrap(), 1500000000); + } + + #[test] + fn test_parse_cycles_with_t_suffix() { + assert_eq!(parse_cycles_amount("1t").unwrap(), 1000000000000); + assert_eq!(parse_cycles_amount("1T").unwrap(), 1000000000000); + assert_eq!(parse_cycles_amount("2t").unwrap(), 2000000000000); + assert_eq!(parse_cycles_amount("0.5t").unwrap(), 500000000000); + } + + #[test] + fn test_parse_cycles_with_decimal_and_underscores() { + assert_eq!(parse_cycles_amount("1_000k").unwrap(), 1000000); + assert_eq!(parse_cycles_amount("1_000_000").unwrap(), 1000000); + } + + #[test] + fn test_parse_cycles_errors() { + assert!(parse_cycles_amount("").is_err()); + assert!(parse_cycles_amount("abc").is_err()); + assert!(parse_cycles_amount("1.2.3").is_err()); + assert!(parse_cycles_amount("k").is_err()); + } + + #[test] + fn test_parse_cycles_fractional_error() { + // Cycles must be integers + let err = parse_cycles_amount("1.5").unwrap_err(); + assert!(err.contains("cannot be represented")); + } + + #[test] + fn test_parse_cycles_large_numbers() { + // Test very large numbers that would lose precision with f64 + assert_eq!( + parse_cycles_amount("340282366920938463463374607431768211455").unwrap(), + 340282366920938463463374607431768211455u128 + ); // u128::MAX + + // Large number with suffix that fits in u128 (340 trillion trillion) + assert_eq!( + parse_cycles_amount("340t").unwrap(), + 340_000_000_000_000u128 + ); + + // Another large number that would lose precision with f64 (18 digits) + assert_eq!( + parse_cycles_amount("123456789012345678901234567890").unwrap(), + 123456789012345678901234567890u128 + ); + + // Very large with decimal and suffix + assert_eq!( + parse_cycles_amount("99999999999999999999t").unwrap(), + 99999999999999999999000000000000u128 + ); + + // Decimal precision maintained (integer result) + assert_eq!(parse_cycles_amount("1.999999t").unwrap(), 1999999000000); + } + + #[test] + fn test_parse_cycles_overflow() { + // Should overflow u128::MAX (340282366920938463463374607431768211455) + let err1 = parse_cycles_amount("340282366920938463463374607431768211456").unwrap_err(); + assert!(err1.contains("Cycles amount too large")); + + // Very large number that definitely overflows + let err3 = parse_cycles_amount("999999999999999999999999999999t").unwrap_err(); + assert!(err3.contains("Cycles amount too large")); + } +} diff --git a/crates/icp-cli/src/commands/token/balance.rs b/crates/icp-cli/src/commands/token/balance.rs index 9232d72f..e398dc43 100644 --- a/crates/icp-cli/src/commands/token/balance.rs +++ b/crates/icp-cli/src/commands/token/balance.rs @@ -32,13 +32,10 @@ pub(crate) async fn exec( .await?; // Get the balance from the ledger - let balance_info = get_balance(&agent, token).await?; + let balance = get_balance(&agent, token).await?; // Output information - let _ = ctx.term.write_line(&format!( - "Balance: {} {}", - balance_info.amount, balance_info.symbol - )); + let _ = ctx.term.write_line(&format!("Balance: {balance}")); Ok(()) } diff --git a/crates/icp-cli/src/commands/token/mod.rs b/crates/icp-cli/src/commands/token/mod.rs index 29dc606a..f4554437 100644 --- a/crates/icp-cli/src/commands/token/mod.rs +++ b/crates/icp-cli/src/commands/token/mod.rs @@ -5,9 +5,9 @@ pub(crate) mod transfer; #[derive(Debug, Parser)] pub(crate) struct Command { - /// The token to execute the operation on, defaults to `icp` + /// The token or ledger canister id to execute the operation on, defaults to `icp` #[arg(default_value = "icp")] - pub(crate) token: String, + pub(crate) token_name_or_ledger_id: String, #[command(subcommand)] pub(crate) command: Commands, diff --git a/crates/icp-cli/src/commands/token/transfer.rs b/crates/icp-cli/src/commands/token/transfer.rs index 4ac65f60..21931296 100644 --- a/crates/icp-cli/src/commands/token/transfer.rs +++ b/crates/icp-cli/src/commands/token/transfer.rs @@ -4,11 +4,14 @@ use clap::Args; use icp::context::Context; use crate::commands::args::TokenCommandArgs; +use crate::commands::parsers::parse_token_amount; use crate::operations::token::transfer::transfer; #[derive(Debug, Args)] pub(crate) struct TransferArgs { - /// Token amount to transfer + /// Token amount to transfer. + /// Supports suffixes: k (thousand), m (million), b (billion), t (trillion). + #[arg(value_parser = parse_token_amount)] pub(crate) amount: BigDecimal, /// The receiver of the token transfer @@ -39,11 +42,8 @@ pub(crate) async fn exec( // Output information let _ = ctx.term.write_line(&format!( - "Transferred {} {} to {} in block {}", - transfer_info.amount, - transfer_info.symbol, - transfer_info.receiver, - transfer_info.block_index + "Transferred {} to {} in block {}", + transfer_info.transferred, transfer_info.receiver, transfer_info.block_index )); Ok(()) diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 5ddb72bc..32805968 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -278,6 +278,12 @@ async fn main() -> Result<(), Error> { .instrument(trace_span) .await? } + + commands::cycles::Command::Transfer(args) => { + commands::cycles::transfer::exec(&ctx, &args) + .instrument(trace_span) + .await? + } }, // Deploy @@ -400,13 +406,13 @@ async fn main() -> Result<(), Error> { // Token Command::Token(cmd) => match cmd.command { commands::token::Commands::Balance(args) => { - commands::token::balance::exec(&ctx, &cmd.token, &args) + commands::token::balance::exec(&ctx, &cmd.token_name_or_ledger_id, &args) .instrument(trace_span) .await? } commands::token::Commands::Transfer(args) => { - commands::token::transfer::exec(&ctx, &cmd.token, &args) + commands::token::transfer::exec(&ctx, &cmd.token_name_or_ledger_id, &args) .instrument(trace_span) .await? } diff --git a/crates/icp-cli/src/operations/token/balance.rs b/crates/icp-cli/src/operations/token/balance.rs index b0ed0106..a549f3f5 100644 --- a/crates/icp-cli/src/operations/token/balance.rs +++ b/crates/icp-cli/src/operations/token/balance.rs @@ -4,7 +4,7 @@ use ic_agent::{Agent, AgentError}; use icrc_ledger_types::icrc1::account::Account; use snafu::{ResultExt, Snafu}; -use super::TOKEN_LEDGER_CIDS; +use super::{TOKEN_LEDGER_CIDS, TokenAmount}; #[derive(Debug, Snafu)] pub enum GetBalanceError { @@ -36,11 +36,6 @@ pub enum GetBalanceError { DecodeSymbol { source: candid::Error }, } -pub struct BalanceInfo { - pub amount: BigDecimal, - pub symbol: String, -} - /// Get the token balance for a given identity /// /// This function queries an ICRC-1 compatible ledger canister to retrieve: @@ -59,9 +54,8 @@ pub struct BalanceInfo { /// /// # Returns /// -/// A `BalanceInfo` struct containing the formatted amount and token symbol -pub async fn get_balance(agent: &Agent, token: &str) -> Result { - // Obtain ledger address +pub async fn get_balance(agent: &Agent, token: &str) -> Result { + // Obtain token info let canister_id = match TOKEN_LEDGER_CIDS.get(token) { // Given token matched known token names Some(cid) => cid.to_string(), @@ -80,25 +74,7 @@ pub async fn get_balance(agent: &Agent, token: &str) -> Result Result Result { + let owner = agent + .get_principal() + .map_err(|err| GetBalanceError::GetPrincipal { err })?; + // Perform query + let resp = agent + .query(&ledger, "icrc1_balance_of") + .with_arg( + Encode!(&Account { + owner, + subaccount: None + }) + .expect("failed to encode arg"), + ) + .await + .context(QueryBalanceSnafu)?; + + // Decode response + Decode!(&resp, Nat).context(DecodeBalanceSnafu) } diff --git a/crates/icp-cli/src/operations/token/mint.rs b/crates/icp-cli/src/operations/token/mint.rs index 2e7b3cbc..9fb7561a 100644 --- a/crates/icp-cli/src/operations/token/mint.rs +++ b/crates/icp-cli/src/operations/token/mint.rs @@ -5,15 +5,17 @@ use ic_ledger_types::{ AccountIdentifier, Memo, Subaccount, Tokens, TransferArgs, TransferError, TransferResult, }; use icp_canister_interfaces::{ - cycles_ledger::{CYCLES_LEDGER_BLOCK_FEE, CYCLES_LEDGER_DECIMALS}, + cycles_ledger::CYCLES_LEDGER_BLOCK_FEE, cycles_minting_canister::{ CYCLES_MINTING_CANISTER_PRINCIPAL, ConversionRateResponse, MEMO_MINT_CYCLES, NotifyMintArgs, NotifyMintResponse, }, - icp_ledger::{ICP_LEDGER_BLOCK_FEE_E8S, ICP_LEDGER_PRINCIPAL}, + icp_ledger::{ICP_LEDGER_BLOCK_FEE_E8S, ICP_LEDGER_PRINCIPAL, ICP_LEDGER_SYMBOL}, }; use snafu::{ResultExt, Snafu}; +use super::TokenAmount; + #[derive(Debug, Snafu)] pub enum MintCyclesError { #[snafu(display("Failed to get identity principal: {message}"))] @@ -34,10 +36,10 @@ pub enum MintCyclesError { #[snafu(display("Failed ICP ledger transfer: {message}"))] TransferFailed { message: String }, - #[snafu(display("Insufficient funds: {required} ICP required, {available} ICP available."))] + #[snafu(display("Insufficient funds: {required} required, {available} available."))] InsufficientFunds { - required: BigDecimal, - available: BigDecimal, + required: TokenAmount, + available: TokenAmount, }, #[snafu(display("No amount specified. Must provide either ICP or cycles amount."))] @@ -48,8 +50,8 @@ pub enum MintCyclesError { } pub struct MintInfo { - pub deposited: BigDecimal, - pub new_balance: BigDecimal, + pub deposited: TokenAmount, + pub new_balance: TokenAmount, } /// Mint cycles from ICP @@ -70,7 +72,7 @@ pub struct MintInfo { /// /// # Returns /// -/// A `MintInfo` struct containing the deposited amount (minus fees) and new balance in TCYCLES +/// A `MintInfo` struct containing the deposited amount (minus fees) and new balance pub async fn mint_cycles( agent: &Agent, icp_amount: Option<&BigDecimal>, @@ -143,12 +145,18 @@ pub async fn mint_cycles( Err(err) => match err { TransferError::TxDuplicate { duplicate_of } => duplicate_of, TransferError::InsufficientFunds { balance } => { - let required = + let required_amount = BigDecimal::new((icp_e8s_to_deposit + ICP_LEDGER_BLOCK_FEE_E8S).into(), 8); - let available = BigDecimal::new(balance.e8s().into(), 8); + let available_amount = BigDecimal::new(balance.e8s().into(), 8); return InsufficientFundsSnafu { - required, - available, + required: TokenAmount { + amount: required_amount, + symbol: ICP_LEDGER_SYMBOL.to_string(), + }, + available: TokenAmount { + amount: available_amount, + symbol: ICP_LEDGER_SYMBOL.to_string(), + }, } .fail(); } @@ -189,15 +197,17 @@ pub async fn mint_cycles( } }; - // Calculate display values in TCYCLES (12 decimals) - let deposited = BigDecimal::new( - (minted.minted - CYCLES_LEDGER_BLOCK_FEE).into(), - CYCLES_LEDGER_DECIMALS, - ); - let new_balance = BigDecimal::new(minted.balance.into(), CYCLES_LEDGER_DECIMALS); + let deposited_amount = BigDecimal::new((minted.minted - CYCLES_LEDGER_BLOCK_FEE).into(), 0); + let new_balance_amount = BigDecimal::new(minted.balance.into(), 0); Ok(MintInfo { - deposited, - new_balance, + deposited: TokenAmount { + amount: deposited_amount, + symbol: "cycles".to_string(), + }, + new_balance: TokenAmount { + amount: new_balance_amount, + symbol: "cycles".to_string(), + }, }) } diff --git a/crates/icp-cli/src/operations/token/mod.rs b/crates/icp-cli/src/operations/token/mod.rs index 7b923e9a..44e158ea 100644 --- a/crates/icp-cli/src/operations/token/mod.rs +++ b/crates/icp-cli/src/operations/token/mod.rs @@ -1,18 +1,49 @@ -use icp_canister_interfaces::{cycles_ledger::CYCLES_LEDGER_CID, icp_ledger::ICP_LEDGER_CID}; +use bigdecimal::BigDecimal; +use candid::Nat; +use icp_canister_interfaces::icp_ledger::ICP_LEDGER_CID; +use num_bigint::ToBigInt; use phf::phf_map; +use std::fmt; pub(crate) mod balance; pub(crate) mod mint; pub(crate) mod transfer; -/// A compile-time map of token names to their corresponding ledger canister IDs. +/// A compile-time map of token names to their corresponding ledger canister ID and optional info overrides. /// /// This map provides a quick lookup for well-known tokens on the Internet Computer: /// - "icp": The Internet Computer Protocol token ledger canister -/// - "cycles": The cycles ledger canister for managing computation cycles -/// -/// The canister IDs are stored as string literals in textual format. pub(super) static TOKEN_LEDGER_CIDS: phf::Map<&'static str, &'static str> = phf_map! { "icp" => ICP_LEDGER_CID, - "cycles" => CYCLES_LEDGER_CID, }; + +/// Represents a token amount with its symbol for display purposes. +#[derive(Debug)] +pub struct TokenAmount { + pub amount: BigDecimal, + pub symbol: String, +} + +impl fmt::Display for TokenAmount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let formatted_amount = if self.amount.fractional_digit_count() == 0 { + // No decimals - format with underscores + format_integer_with_underscores(&self.amount) + } else { + // Has decimals - display as is + self.amount.to_string() + }; + write!(f, "{} {}", formatted_amount, self.symbol) + } +} + +fn format_integer_with_underscores(amount: &BigDecimal) -> String { + // Nat displays numbers with underscores + if let Some(bigint) = amount.to_bigint() + && let Some(biguint) = bigint.to_biguint() + { + return format!("{}", Nat::from(biguint)); + } + // Fallback to plain string if conversion fails + amount.to_string() +} diff --git a/crates/icp-cli/src/operations/token/transfer.rs b/crates/icp-cli/src/operations/token/transfer.rs index aa2f5e13..bedd00c7 100644 --- a/crates/icp-cli/src/operations/token/transfer.rs +++ b/crates/icp-cli/src/operations/token/transfer.rs @@ -7,7 +7,7 @@ use icrc_ledger_types::icrc1::{ }; use snafu::{ResultExt, Snafu}; -use super::TOKEN_LEDGER_CIDS; +use super::{TOKEN_LEDGER_CIDS, TokenAmount}; #[derive(Debug, Snafu)] pub enum TokenTransferError { @@ -47,13 +47,10 @@ pub enum TokenTransferError { #[snafu(display("failed to decode transfer response"))] DecodeTransferResponse { source: candid::Error }, - #[snafu(display( - "insufficient funds. balance: {balance} {symbol}, required: {required} {symbol}" - ))] + #[snafu(display("insufficient funds. balance: {balance}, required: {required}"))] InsufficientFunds { - symbol: String, - balance: BigDecimal, - required: BigDecimal, + balance: TokenAmount, + required: TokenAmount, }, #[snafu(display("transfer failed: {message}"))] @@ -62,11 +59,98 @@ pub enum TokenTransferError { pub struct TransferInfo { pub block_index: Nat, - pub amount: BigDecimal, - pub symbol: String, + pub transferred: TokenAmount, pub receiver: Principal, } +/// Execute an ICRC-1 transfer with known parameters +/// +/// This is a low-level function that performs the actual transfer operation. +/// Use `transfer()` for a higher-level interface that handles token resolution. +/// +/// # Arguments +/// +/// * `agent` - The IC agent to use for the update call +/// * `ledger_canister_id` - The principal of the ICRC-1 ledger canister +/// * `ledger_amount` - The amount to transfer in ledger units (smallest divisible unit) +/// * `receiver` - The principal to receive the tokens +/// * `fee` - The transfer fee in ledger units +/// * `decimals` - The number of decimals the token uses +/// * `symbol` - The token symbol for display purposes +/// +/// # Returns +/// +/// A `TransferInfo` struct containing transfer details including block index +pub async fn icrc1_transfer( + agent: &Agent, + ledger_canister_id: Principal, + ledger_amount: Nat, + receiver: Principal, + fee: Nat, + decimals: u32, + symbol: String, +) -> Result { + // Prepare transfer + let receiver_account = Account { + owner: receiver, + subaccount: None, + }; + + let arg = TransferArg { + amount: ledger_amount.clone(), + to: receiver_account, + from_subaccount: None, + fee: None, + created_at_time: None, + memo: None, + }; + + // Perform transfer + let resp = agent + .update(&ledger_canister_id, "icrc1_transfer") + .with_arg(Encode!(&arg).context(EncodeTransferArgSnafu)?) + .call_and_wait() + .await + .context(ExecuteTransferSnafu)?; + + // Parse response + let resp = + Decode!(&resp, Result).context(DecodeTransferResponseSnafu)?; + + // Process response + let block_index = resp.map_err(|err| match err { + Icrc1TransferError::InsufficientFunds { balance } => { + let balance_amount = BigDecimal::from_biguint(balance.0, decimals as i64); + let required_amount = + BigDecimal::from_biguint(&ledger_amount.0 + fee.0, decimals as i64); + + TokenTransferError::InsufficientFunds { + balance: TokenAmount { + amount: balance_amount, + symbol: symbol.clone(), + }, + required: TokenAmount { + amount: required_amount, + symbol: symbol.clone(), + }, + } + } + + _ => TokenTransferError::TransferFailed { + message: err.to_string(), + }, + })?; + + Ok(TransferInfo { + block_index, + transferred: TokenAmount { + amount: BigDecimal::from_biguint(ledger_amount.0, decimals as i64), + symbol, + }, + receiver, + }) +} + /// Transfer tokens to a receiver /// /// This function executes an ICRC-1 token transfer: @@ -95,7 +179,7 @@ pub async fn transfer( amount: &BigDecimal, receiver: Principal, ) -> Result { - // Obtain ledger address + // Obtain token info let canister_id = match TOKEN_LEDGER_CIDS.get(token) { // Given token matched known token names Some(cid) => cid.to_string(), @@ -154,84 +238,17 @@ pub async fn transfer( ); // Check for errors - let (Nat(fee), decimals, symbol) = (fee?, decimals? as u32, symbol?); + let (fee, decimals, symbol) = (fee?, decimals? as u32, symbol?); // Calculate units of token to transfer // Ledgers do not work in decimals and instead use the smallest non-divisible unit of the token - let ledger_amount = amount.clone() * 10u128.pow(decimals); - - // Convert amount to big decimal - let ledger_amount = ledger_amount + let ledger_amount_decimal = amount.clone() * 10u128.pow(decimals); + let ledger_amount = ledger_amount_decimal .to_bigint() .ok_or(TokenTransferError::InvalidAmount)? .to_biguint() - .ok_or(TokenTransferError::InvalidAmount)?; - - let ledger_amount = Nat::from(ledger_amount); - let display_amount = BigDecimal::from_biguint(ledger_amount.0.clone(), decimals as i64); + .ok_or(TokenTransferError::InvalidAmount) + .map(Nat::from)?; - // Prepare transfer - let receiver_account = Account { - owner: receiver, - subaccount: None, - }; - - let arg = TransferArg { - // Transfer amount - amount: ledger_amount.clone(), - - // Transfer destination - to: receiver_account, - - // Other - from_subaccount: None, - fee: None, - created_at_time: None, - memo: None, - }; - - // Perform transfer - let resp = agent - .update(&cid, "icrc1_transfer") - .with_arg(Encode!(&arg).context(EncodeTransferArgSnafu)?) - .call_and_wait() - .await - .context(ExecuteTransferSnafu)?; - - // Parse response - let resp = - Decode!(&resp, Result).context(DecodeTransferResponseSnafu)?; - - // Process response - let block_index = resp.map_err(|err| match err { - // Special case for insufficient funds - Icrc1TransferError::InsufficientFunds { balance } => { - let balance = BigDecimal::from_biguint( - balance.0, // balance - decimals as i64, // decimals - ); - - let fee_decimal = BigDecimal::from_biguint( - fee, // fee - decimals as i64, // decimals - ); - - TokenTransferError::InsufficientFunds { - symbol: symbol.clone(), - balance, - required: amount.clone() + fee_decimal, - } - } - - _ => TokenTransferError::TransferFailed { - message: err.to_string(), - }, - })?; - - Ok(TransferInfo { - block_index, - amount: display_amount, - symbol, - receiver, - }) + icrc1_transfer(agent, cid, ledger_amount, receiver, fee, decimals, symbol).await } diff --git a/crates/icp-cli/tests/canister_create_tests.rs b/crates/icp-cli/tests/canister_create_tests.rs index ef323ef0..7542933b 100644 --- a/crates/icp-cli/tests/canister_create_tests.rs +++ b/crates/icp-cli/tests/canister_create_tests.rs @@ -123,7 +123,7 @@ async fn canister_create_with_settings() { "--environment", "random-environment", "--cycles", - &format!("{}", 70 * TRILLION), /* 70 TCYCLES because compute allocation is expensive */ + &format!("{}", 70 * TRILLION), /* 70T cycles because compute allocation is expensive */ ]) .assert() .success(); @@ -203,7 +203,7 @@ async fn canister_create_with_settings_cmdline_override() { "--environment", "random-environment", "--cycles", - &format!("{}", 70 * TRILLION), /* 70 TCYCLES because compute allocation is expensive */ + &format!("{}", 70 * TRILLION), /* 70T cycles because compute allocation is expensive */ ]) .assert() .success(); diff --git a/crates/icp-cli/tests/canister_settings_tests.rs b/crates/icp-cli/tests/canister_settings_tests.rs index 7785b532..b17a213a 100644 --- a/crates/icp-cli/tests/canister_settings_tests.rs +++ b/crates/icp-cli/tests/canister_settings_tests.rs @@ -684,7 +684,7 @@ async fn canister_settings_update_miscellaneous() { "--subnet", common::SUBNET_ID, "--cycles", - &format!("{}", 120 * TRILLION), // 120 TCYCLES because compute allocation is expensive + &format!("{}", 120 * TRILLION), // 120T cycles because compute allocation is expensive "--environment", "random-environment", ]) diff --git a/crates/icp-cli/tests/canister_top_up_tests.rs b/crates/icp-cli/tests/canister_top_up_tests.rs index 1796bc71..6fb3a8bc 100644 --- a/crates/icp-cli/tests/canister_top_up_tests.rs +++ b/crates/icp-cli/tests/canister_top_up_tests.rs @@ -83,7 +83,7 @@ async fn canister_top_up() { ]) .assert() .stderr(contains( - "failed to top up: Insufficient cycles. Requested: 10.000000000000T cycles", + "failed to top up: Insufficient cycles. Requested: 10_000_000_000_000 cycles", )) .failure(); @@ -100,7 +100,7 @@ async fn canister_top_up() { &format!("{}", 10 * TRILLION), ]) .assert() - .stdout(eq("Topped up canister my-canister with 10.000000000000T cycles").trim()) + .stdout(eq("Topped up canister my-canister with 10_000_000_000_000 cycles").trim()) .success(); let new_canister_balance = mgmt.canister_status(&canister_id).await.unwrap().0.cycles; diff --git a/crates/icp-cli/tests/cycles_tests.rs b/crates/icp-cli/tests/cycles_tests.rs index e088aff7..79ca3328 100644 --- a/crates/icp-cli/tests/cycles_tests.rs +++ b/crates/icp-cli/tests/cycles_tests.rs @@ -39,7 +39,7 @@ async fn cycles_balance() { .current_dir(&project_dir) .args(["cycles", "balance", "--environment", "random-environment"]) .assert() - .stdout(contains("Balance: 0 TCYCLES")) + .stdout(contains("Balance: 0 cycles")) .success(); // Mint ICP to cycles, specify ICP amount @@ -58,7 +58,7 @@ async fn cycles_balance() { ]) .assert() .stdout(contains( - "Minted 3.519900000000 TCYCLES to your account, new balance: 3.519900000000 TCYCLES.", + "Minted 3_519_900_000_000 cycles to your account, new balance: 3_519_900_000_000 cycles.", )) .success(); @@ -75,13 +75,13 @@ async fn cycles_balance() { "cycles", "mint", "--cycles", - "1000000000", + "1T", "--environment", "random-environment", ]) .assert() .stdout(contains( - "Minted 0.001000000000 TCYCLES to your account, new balance: 0.001000000000 TCYCLES.", + "Minted 1_000_000_006_400 cycles to your account, new balance: 1_000_000_006_400 cycles.", )) .success(); ctx.icp() @@ -90,13 +90,13 @@ async fn cycles_balance() { "cycles", "mint", "--cycles", - "1500000000", + "1.5t", "--environment", "random-environment", ]) .assert() .stdout(contains( - "Minted 0.001500016000 TCYCLES to your account, new balance: 0.002500016000 TCYCLES.", + "Minted 1_500_000_025_600 cycles to your account, new balance: 2_500_000_032_000 cycles.", )) .success(); } @@ -141,7 +141,7 @@ async fn cycles_mint_with_explicit_network() { ]) .assert() .stdout(contains( - "Minted 3.519900000000 TCYCLES to your account, new balance: 3.519900000000 TCYCLES.", + "Minted 3_519_900_000_000 cycles to your account, new balance: 3_519_900_000_000 cycles.", )) .success(); } @@ -183,3 +183,75 @@ async fn cycles_mint_on_ic() { )) .failure(); } + +#[tokio::test] +async fn cycles_transfer() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + + write_string( + &project_dir.join("icp.yaml"), + &formatdoc! {r#" + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}, + ) + .expect("failed to write project manifest"); + + let _g = ctx.start_network_in(&project_dir, "random-network").await; + ctx.ping_until_healthy(&project_dir, "random-network"); + + let icp_client = clients::icp(&ctx, &project_dir, Some("random-environment".to_string())); + + icp_client.create_identity("alice"); + icp_client.use_identity("alice"); + let alice_principal = icp_client.active_principal(); + icp_client.create_identity("bob"); + icp_client.use_identity("bob"); + let bob_principal = icp_client.active_principal(); + + // Mint ICP to alice and convert to cycles + icp_client.use_identity("alice"); + clients::ledger(&ctx) + .acquire_icp(alice_principal, None, 1_000_000_000_u128) + .await; + + ctx.icp() + .current_dir(&project_dir) + .args([ + "cycles", + "mint", + "--icp", + "5", + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Transfer cycles from alice to bob + ctx.icp() + .current_dir(&project_dir) + .args([ + "cycles", + "transfer", + "2t", + &bob_principal.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .stdout(contains(format!( + "Transferred 2_000_000_000_000 cycles to {bob_principal}" + ))) + .success(); + + // Check bob's balance + icp_client.use_identity("bob"); + ctx.icp() + .current_dir(&project_dir) + .args(["cycles", "balance", "--environment", "random-environment"]) + .assert() + .stdout(contains("Balance: 2_000_000_000_000 cycles")) + .success(); +} diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 32cec2ba..b811c203 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -293,7 +293,7 @@ async fn network_seeds_preexisting_identities_icp_and_cycles_balances() { .current_dir(&project_dir) .args(["cycles", "balance", "--environment", "random-environment"]) .assert() - .stdout(contains("Balance: 1000.000000000000 TCYCLES")) + .stdout(contains("Balance: 1_000_000_000_000_000 cycles")) .success(); // Identities created after starting should have 0 cycles balance @@ -302,7 +302,7 @@ async fn network_seeds_preexisting_identities_icp_and_cycles_balances() { .current_dir(&project_dir) .args(["cycles", "balance", "--environment", "random-environment"]) .assert() - .stdout(contains("Balance: 0 TCYCLES")) + .stdout(contains("Balance: 0 cycles")) .success(); } @@ -328,7 +328,7 @@ async fn network_run_and_stop_background() { ]) .assert() .success() - .stderr(contains("Seeding ICP and TCYCLES")) + .stderr(contains("Seeding ICP and cycles")) .stdout(contains("Installed Candid UI canister with ID")); let network = ctx.wait_for_network_descriptor(&project_dir, "random-network"); diff --git a/crates/icp-cli/tests/token_tests.rs b/crates/icp-cli/tests/token_tests.rs index 1dff8d44..c58e184f 100644 --- a/crates/icp-cli/tests/token_tests.rs +++ b/crates/icp-cli/tests/token_tests.rs @@ -39,19 +39,6 @@ async fn token_balance() { .stdout(contains("Balance: 0 ICP")) .success(); - ctx.icp() - .current_dir(&project_dir) - .args([ - "token", - "cycles", - "balance", - "--environment", - "random-environment", - ]) - .assert() - .stdout(contains("Balance: 0 TCYCLES")) - .success(); - // mint icp to identity clients::ledger(&ctx) .acquire_icp(identity, None, 123456780_u128) @@ -133,41 +120,4 @@ async fn token_transfer() { icp_ledger.balance_of(bob_principal, None).await, 110_000_000_u128 ); - - // Simple cycles transfer - ctx.icp() - .current_dir(&project_dir) - .args([ - "cycles", - "mint", - "--icp", - "5", - "--environment", - "random-environment", - ]) - .assert() - .success(); - ctx.icp() - .current_dir(&project_dir) - .args([ - "token", - "cycles", - "transfer", - "2", - &bob_principal.to_string(), - "--environment", - "random-environment", - ]) - .assert() - .stdout(contains(format!( - "Transferred 2.000000000000 TCYCLES to {bob_principal}" - ))) - .success(); - icp_client.use_identity("bob"); - ctx.icp() - .current_dir(&project_dir) - .args(["cycles", "balance", "--environment", "random-environment"]) - .assert() - .stdout(contains("Balance: 2.000000000000 TCYCLES")) - .success(); } diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index f7e31006..45767a27 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -426,7 +426,7 @@ pub async fn initialize_network( seed_accounts: impl IntoIterator + Clone, candid_ui_wasm: Option<&[u8]>, ) -> Result, InitializeNetworkError> { - eprintln!("Seeding ICP and TCYCLES account balances"); + eprintln!("Seeding ICP and cycles account balances"); let agent = Agent::builder() .with_url(gateway_url.as_str()) .with_identity(AnonymousIdentity) @@ -448,12 +448,12 @@ pub async fn initialize_network( acquire_icp_to_account(&agent, account, icp_amount) }), ); - let cycles_amount = 1_000_000_000_000_000u128; // 1k TCYCLES + let cycles_amount = 1_000_000_000_000_000u128; // 1_000T cycles let display_cycles_amount = BigDecimal::new(cycles_amount.into(), 12).normalized(); let seed_cycles = join_all(seed_accounts.into_iter().map(|account| { debug!( - "Seeding {} TCYCLES to account {}", + "Seeding {}T cycles to account {}", display_cycles_amount, account ); mint_cycles_to_account(&agent, account, cycles_amount, icp_xdr_conversion_rate) @@ -651,7 +651,7 @@ async fn install_candid_ui( candid_ui_wasm: &[u8], ) -> Result { debug!("Creating canister for Candid UI"); - let amount = 10_000_000_000_000u64; // 10 TCYCLES + let amount = 10 * TRILLION; let response = agent .update(&CYCLES_LEDGER_PRINCIPAL, "create_canister") .with_arg( @@ -678,7 +678,7 @@ async fn install_candid_ui( return Err(InitializeNetworkError::CandidUI { error: format!( "Failed to create canister for Candid UI: {}", - err.format_error(amount as u128) + err.format_error(amount) ), }); } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6b723344..57b6b06c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -24,6 +24,7 @@ This document contains the help content for the `icp` command-line program. * [`icp cycles`↴](#icp-cycles) * [`icp cycles balance`↴](#icp-cycles-balance) * [`icp cycles mint`↴](#icp-cycles-mint) +* [`icp cycles transfer`↴](#icp-cycles-transfer) * [`icp deploy`↴](#icp-deploy) * [`icp environment`↴](#icp-environment) * [`icp environment list`↴](#icp-environment-list) @@ -158,7 +159,7 @@ Create a canister on a network * `--freezing-threshold ` — Optional freezing threshold in seconds. Controls how long a canister can be inactive before being frozen * `--reserved-cycles-limit ` — Optional reserved cycles limit. If set, the canister cannot consume more than this many cycles * `-q`, `--quiet` — Suppress human-readable output; print only canister IDs, one per line, to stdout -* `--cycles ` — Cycles to fund canister creation (in raw cycles) +* `--cycles ` — Cycles to fund canister creation. Supports suffixes: k (thousand), m (million), b (billion), t (trillion) Default value: `2000000000000` * `--subnet ` — The subnet to create canisters on @@ -302,7 +303,7 @@ Change a canister's settings to specified values * `--compute-allocation ` * `--memory-allocation ` * `--freezing-threshold ` -* `--reserved-cycles-limit ` +* `--reserved-cycles-limit ` — Reserved cycles limit for the canister. Supports suffixes: k (thousand), m (million), b (billion), t (trillion) * `--wasm-memory-limit ` * `--wasm-memory-threshold ` * `--log-visibility ` @@ -403,7 +404,7 @@ Top up a canister with cycles ###### **Options:** -* `--amount ` — Amount of cycles to top up +* `--amount ` — Amount of cycles to top up. Supports suffixes: k (thousand), m (million), b (billion), t (trillion) * `-n`, `--network ` — Name of the network to target, conflicts with environment argument * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as @@ -420,6 +421,7 @@ Mint and manage cycles * `balance` — Display the cycles balance * `mint` — Convert icp to cycles +* `transfer` — Transfer cycles to another principal @@ -445,8 +447,27 @@ Convert icp to cycles ###### **Options:** -* `--icp ` — Amount of ICP to mint to cycles -* `--cycles ` — Amount of cycles to mint. Automatically determines the amount of ICP needed +* `--icp ` — Amount of ICP to mint to cycles. Supports suffixes: k (thousand), m (million), b (billion), t (trillion) +* `--cycles ` — Amount of cycles to mint. Automatically determines the amount of ICP needed. Supports suffixes: k (thousand), m (million), b (billion), t (trillion) +* `-n`, `--network ` — Name of the network to target, conflicts with environment argument +* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--identity ` — The user identity to run this command as + + + +## `icp cycles transfer` + +Transfer cycles to another principal + +**Usage:** `icp cycles transfer [OPTIONS] ` + +###### **Arguments:** + +* `` — Cycles amount to transfer. Supports suffixes: k (thousand), m (million), b (billion), t (trillion) +* `` — The receiver of the cycles transfer + +###### **Options:** + * `-n`, `--network ` — Name of the network to target, conflicts with environment argument * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as @@ -899,7 +920,7 @@ Synchronize canisters Perform token transactions -**Usage:** `icp token [TOKEN] ` +**Usage:** `icp token [TOKEN_NAME_OR_LEDGER_ID] ` ###### **Subcommands:** @@ -908,7 +929,7 @@ Perform token transactions ###### **Arguments:** -* `` — The token to execute the operation on, defaults to `icp` +* `` — The token or ledger canister id to execute the operation on, defaults to `icp` Default value: `icp` @@ -932,7 +953,7 @@ Perform token transactions ###### **Arguments:** -* `` — Token amount to transfer +* `` — Token amount to transfer. Supports suffixes: k (thousand), m (million), b (billion), t (trillion) * `` — The receiver of the token transfer ###### **Options:**