From e1d5feb69545a6e3f57d0ab95426e7f9de5c3bc0 Mon Sep 17 00:00:00 2001 From: Vivienne Date: Wed, 20 Aug 2025 10:26:30 +0200 Subject: [PATCH 1/7] add mainnet detection to network --- bin/icp-cli/src/options.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bin/icp-cli/src/options.rs b/bin/icp-cli/src/options.rs index d661def97..02f31b2b0 100644 --- a/bin/icp-cli/src/options.rs +++ b/bin/icp-cli/src/options.rs @@ -41,4 +41,14 @@ impl EnvironmentOpt { // Otherwise, default to `local` self.environment.as_deref().unwrap_or(ENVIRONMENT_LOCAL) } + + pub fn is_mainnet(&self) -> bool { + self.name() == ENVIRONMENT_IC + } + + /// Refers to `aaaaa-aa:provisional_create_canister_with_cycles` and `aaaaa-aa:provisional_top_up_canister` + pub fn supports_provisional_api(&self) -> bool { + // Provisional API is not supported on mainnet. In the future, maybe it is also not supported on e.g. UTOPIA networks. + !self.is_mainnet() + } } From c24d42e4c2fb5910025bb3321b37f293e58f8e43 Mon Sep 17 00:00:00 2001 From: Vivienne Date: Fri, 22 Aug 2025 11:23:41 +0200 Subject: [PATCH 2/7] add token balance command --- Cargo.lock | 213 ++++++++++++++++-- Cargo.toml | 7 +- bin/icp-cli/Cargo.toml | 5 + bin/icp-cli/src/commands.rs | 6 + bin/icp-cli/src/commands/identity.rs | 6 + .../src/commands/identity/account_id.rs | 33 +++ bin/icp-cli/src/commands/token.rs | 46 ++++ bin/icp-cli/src/commands/token/balance.rs | 131 +++++++++++ bin/icp-cli/src/options.rs | 10 - bin/icp-cli/tests/common/context.rs | 43 +++- .../tests/common/context/icp_ledger.rs | 37 +++ bin/icp-cli/tests/common/mod.rs | 3 + bin/icp-cli/tests/network_tests.rs | 1 + bin/icp-cli/tests/token_tests.rs | 70 ++++++ lib/icp-network/Cargo.toml | 1 + lib/icp-network/src/config.rs | 2 + lib/icp-network/src/managed/pocketic.rs | 16 +- lib/icp-network/src/managed/run.rs | 2 + 18 files changed, 593 insertions(+), 39 deletions(-) create mode 100644 bin/icp-cli/src/commands/identity/account_id.rs create mode 100644 bin/icp-cli/src/commands/token.rs create mode 100644 bin/icp-cli/src/commands/token/balance.rs create mode 100644 bin/icp-cli/tests/common/context/icp_ledger.rs create mode 100644 bin/icp-cli/tests/token_tests.rs diff --git a/Cargo.lock b/Cargo.lock index ad3b83f9e..c2bea3d1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -299,6 +299,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base64" version = "0.13.1" @@ -323,6 +329,19 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "bigdecimal" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a22f228ab7a1b23027ccc6c350b72868017af7ea8356fbdf19f8d991c690013" +dependencies = [ + "autocfg", + "libm", + "num-bigint 0.4.6", + "num-integer", + "num-traits", +] + [[package]] name = "binread" version = "2.2.0" @@ -558,9 +577,9 @@ dependencies = [ [[package]] name = "candid" -version = "0.10.14" +version = "0.10.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d90f5a1426d0489283a0bd5da9ed406fb3e69597e0d823dcb88a1965bb58d2" +checksum = "eaac522d18020d5fbc8320ecb12a9b13b2137ae31133da2d42fa256a825507c4" dependencies = [ "anyhow", "binread", @@ -581,9 +600,9 @@ dependencies = [ [[package]] name = "candid_derive" -version = "0.6.6" +version = "0.10.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3de398570c386726e7a59d9887b68763c481477f9a043fb998a2e09d428df1a9" +checksum = "8a1b4fddbd462182050989068d53604a91a3d0f117c3c8316c6818023df00add" dependencies = [ "lazy_static", "proc-macro2", @@ -1914,7 +1933,7 @@ dependencies = [ "http", "http-body", "ic-certification", - "ic-transport-types 0.40.1", + "ic-transport-types", "ic-verify-bls-signature", "k256 0.13.4", "leb128", @@ -1971,6 +1990,47 @@ dependencies = [ "walkdir", ] +[[package]] +name = "ic-cdk" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3694c302426834b1300c095b43dee60aaf07264ca07c8f6456fc6b898c99c72f" +dependencies = [ + "candid", + "ic-cdk-executor", + "ic-cdk-macros", + "ic-error-types", + "ic-management-canister-types", + "ic0", + "serde", + "serde_bytes", + "slotmap", + "thiserror 2.0.12", +] + +[[package]] +name = "ic-cdk-executor" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99f4ee8930fd2e491177e2eb7fff53ee1c407c13b9582bdc7d6920cf83109a2d" +dependencies = [ + "ic0", + "slotmap", +] + +[[package]] +name = "ic-cdk-macros" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2cd13805284d5f422012c392a132757c74bd1bb2f7baeda2f3fca5cbe3d8ce7" +dependencies = [ + "candid", + "darling", + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "ic-certification" version = "3.0.3" @@ -1983,6 +2043,17 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "ic-error-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbeeb3d91aa179d6496d7293becdacedfc413c825cac79fd54ea1906f003ee55" +dependencies = [ + "serde", + "strum 0.26.3", + "strum_macros 0.26.4", +] + [[package]] name = "ic-identity-hsm" version = "0.40.1" @@ -1998,32 +2069,38 @@ dependencies = [ ] [[package]] -name = "ic-management-canister-types" -version = "0.3.1" +name = "ic-ledger-types" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98554c2d8a30c00b6bfda18062fdcef21215cad07a52d8b8b1eb3130e51bfe71" +checksum = "feb52826a353b583012628af6da762b52672350686c3275234febfadeca965ea" dependencies = [ "candid", + "crc32fast", + "hex", + "ic-cdk", "serde", "serde_bytes", + "sha2 0.10.9", ] [[package]] -name = "ic-transport-types" -version = "0.39.3" +name = "ic-management-canister-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "979ee7bee5a67150a4c090fb012c93c294a528b4a867bad9a15cc6d01cb4227f" +checksum = "95f3af3543f6d0cbdecd2dcdfd4737ada2bd42d935cc787eec22090c96492c76" dependencies = [ "candid", - "hex", - "ic-certification", - "leb128", "serde", "serde_bytes", - "serde_cbor", - "serde_repr", - "sha2 0.10.9", - "thiserror 2.0.12", +] + +[[package]] +name = "ic-stable-structures" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d30d4cf17aff1024e13133897048bcba580e063c9000571ab766ca37e2996f4" +dependencies = [ + "ic_principal", ] [[package]] @@ -2080,6 +2157,12 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "ic0" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8877193e1921b5fd16accb0305eb46016868cd1935b05c05eca0ec007b943272" + [[package]] name = "ic_bls12_381" version = "0.10.1" @@ -2150,6 +2233,7 @@ name = "icp-cli" version = "0.1.0" dependencies = [ "assert_cmd", + "bigdecimal", "bip32 0.5.3", "camino", "camino-tempfile", @@ -2161,6 +2245,7 @@ dependencies = [ "hex", "httptest", "ic-agent", + "ic-ledger-types", "ic-utils", "icp-adapter", "icp-canister", @@ -2169,11 +2254,13 @@ dependencies = [ "icp-identity", "icp-network", "icp-project", + "icrc-ledger-types", "itertools 0.14.0", "k256 0.13.4", "nix", "pem", "pkcs8 0.10.2", + "pocket-ic", "predicates", "reqwest", "sec1 0.7.3", @@ -2184,6 +2271,7 @@ dependencies = [ "snafu", "tiny-bip39 2.0.0", "tokio", + "url", ] [[package]] @@ -2253,6 +2341,7 @@ dependencies = [ "snafu", "time", "tokio", + "url", "uuid", ] @@ -2274,6 +2363,42 @@ dependencies = [ "snafu", ] +[[package]] +name = "icrc-cbor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90569d2894d9536c5416943556ac6339df249f06611b3c41029196b39e0dd119" +dependencies = [ + "candid", + "minicbor", + "num-bigint 0.4.6", + "num-traits", +] + +[[package]] +name = "icrc-ledger-types" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c31beeee0e5ab964861a3d5ea2b5ed7b688b2b22400367a832b1fcf0db1fa4" +dependencies = [ + "base32", + "candid", + "crc32fast", + "hex", + "ic-stable-structures", + "icrc-cbor", + "itertools 0.12.1", + "minicbor", + "num-bigint 0.4.6", + "num-traits", + "serde", + "serde_bytes", + "sha2 0.10.9", + "strum 0.26.3", + "strum_macros 0.26.4", + "time", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -2456,6 +2581,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2613,6 +2747,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libredox" version = "0.1.3" @@ -2731,6 +2871,26 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicbor" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7005aaf257a59ff4de471a9d5538ec868a21586534fff7f85dd97d4043a6139" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1154809406efdb7982841adb6311b3d095b46f78342dd646736122fe6b19e267" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "miniz_oxide" version = "0.8.8" @@ -2915,6 +3075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3311,9 +3472,8 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "pocket-ic" -version = "9.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc8ef97f44d4a20b43695690f478f6bfc4058f3ead7d51aff79be29bcfd810c5" +version = "9.0.2" +source = "git+https://github.com/dfinity/ic.git?branch=rc--2025-08-22_03-20#abbed6b27a69f85f3ff539ed9a214faa666e1299" dependencies = [ "backoff", "base64 0.13.1", @@ -3322,7 +3482,7 @@ dependencies = [ "hex", "ic-certification", "ic-management-canister-types", - "ic-transport-types 0.39.3", + "ic-transport-types", "reqwest", "schemars", "serde", @@ -4283,6 +4443,15 @@ dependencies = [ "erased-serde", ] +[[package]] +name = "slotmap" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.0" diff --git a/Cargo.toml b/Cargo.toml index f156d3f27..f4556712d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,10 +15,11 @@ resolver = "3" [workspace.dependencies] async-trait = "0.1.88" +bigdecimal = "0.4.8" bip32 = "0.5.0" camino = { version = "1.1.9", features = ["serde1"] } camino-tempfile = "1" -candid = "0.10.14" +candid = "0.10.17" candid_parser = "0.1" clap = { version = "4.5.3", features = ["derive", "env"] } dialoguer = "0.11.0" @@ -33,7 +34,9 @@ hex = "0.4.3" httptest = "0.16.3" ic-agent = { version = "0.40.1" } ic-asset = { version = "0.24.0" } +ic-ledger-types = "0.15.0" ic-utils = { version = "0.40.1" } +icrc-ledger-types = "0.1.10" icp-adapter = { path = "lib/icp-adapter" } icp-canister = { path = "lib/icp-canister" } icp-dirs = { path = "lib/icp-dirs" } @@ -48,7 +51,7 @@ p256 = { version = "0.13.2", features = ["pem", "pkcs8", "std"] } pathdiff = { version = "0.2.3", features = ["camino"] } pem = "3.0.5" pkcs8 = { version = "0.10.2", features = ["encryption", "std"] } -pocket-ic = "9.0.0" +pocket-ic = { git = "https://github.com/dfinity/ic.git", branch = "rc--2025-08-22_03-20"} rand = "0.9.1" sec1 = { version = "0.7.3", features = ["pkcs8"] } serde = { version = "1.0", features = ["derive"] } diff --git a/bin/icp-cli/Cargo.toml b/bin/icp-cli/Cargo.toml index 5fe80de2b..709a82308 100644 --- a/bin/icp-cli/Cargo.toml +++ b/bin/icp-cli/Cargo.toml @@ -9,6 +9,7 @@ name = "icp" path = "src/main.rs" [dependencies] +bigdecimal.workspace = true bip32.workspace = true camino-tempfile.workspace = true camino.workspace = true @@ -21,6 +22,8 @@ hex.workspace = true httptest.workspace = true ic-agent.workspace = true ic-utils.workspace = true +ic-ledger-types.workspace = true +icrc-ledger-types.workspace = true icp-adapter.workspace = true icp-canister.workspace = true icp-dirs.workspace = true @@ -43,10 +46,12 @@ tokio.workspace = true assert_cmd = "2" camino-tempfile = "1" nix = { version = "0.30.1", features = ["process", "signal"] } +pocket-ic.workspace = true predicates = "3" reqwest = { workspace = true } serde_yaml.workspace = true serial_test = { version = "3.2.0", features = ["file_locks"] } +url.workspace = true [lints] workspace = true diff --git a/bin/icp-cli/src/commands.rs b/bin/icp-cli/src/commands.rs index 25f458c11..451cdd4b3 100644 --- a/bin/icp-cli/src/commands.rs +++ b/bin/icp-cli/src/commands.rs @@ -13,6 +13,7 @@ mod environment; mod identity; mod network; mod sync; +mod token; #[derive(Parser, Debug)] pub struct Cmd { @@ -29,6 +30,7 @@ pub enum Subcmd { Identity(identity::IdentityCmd), Network(network::NetworkCmd), Sync(sync::Cmd), + Token(token::Cmd), } pub async fn dispatch(ctx: &Context, cli: Cmd) -> Result<(), DispatchError> { @@ -40,6 +42,7 @@ pub async fn dispatch(ctx: &Context, cli: Cmd) -> Result<(), DispatchError> { Subcmd::Identity(opts) => identity::dispatch(ctx, opts).await?, Subcmd::Network(opts) => network::dispatch(ctx, opts).await?, Subcmd::Sync(opts) => sync::exec(ctx, opts).await?, + Subcmd::Token(opts) => token::dispatch(ctx, opts).await?, } Ok(()) } @@ -66,4 +69,7 @@ pub enum DispatchError { #[snafu(transparent)] Sync { source: sync::CommandError }, + + #[snafu(transparent)] + Token { source: token::CommandError }, } diff --git a/bin/icp-cli/src/commands/identity.rs b/bin/icp-cli/src/commands/identity.rs index 2bb24e5ea..2ccbae157 100644 --- a/bin/icp-cli/src/commands/identity.rs +++ b/bin/icp-cli/src/commands/identity.rs @@ -2,6 +2,7 @@ use crate::context::Context; use clap::{Parser, Subcommand}; use snafu::Snafu; +mod account_id; mod default; mod import; mod list; @@ -16,6 +17,7 @@ pub struct IdentityCmd { #[derive(Debug, Subcommand)] pub enum IdentitySubcmd { + AccountId(account_id::AccountIdCmd), Default(default::DefaultCmd), Import(import::ImportCmd), List(list::ListCmd), @@ -25,6 +27,7 @@ pub enum IdentitySubcmd { pub async fn dispatch(ctx: &Context, cmd: IdentityCmd) -> Result<(), IdentityCommandError> { match cmd.subcmd { + IdentitySubcmd::AccountId(subcmd) => account_id::exec(ctx, subcmd)?, IdentitySubcmd::Default(subcmd) => default::exec(ctx, subcmd)?, IdentitySubcmd::Import(subcmd) => import::exec(ctx, subcmd)?, IdentitySubcmd::List(subcmd) => list::exec(ctx, subcmd)?, @@ -36,6 +39,9 @@ pub async fn dispatch(ctx: &Context, cmd: IdentityCmd) -> Result<(), IdentityCom #[derive(Debug, Snafu)] pub enum IdentityCommandError { + #[snafu(transparent)] + AccountId { source: account_id::AccountIdError }, + #[snafu(transparent)] Default { source: default::DefaultIdentityError, diff --git a/bin/icp-cli/src/commands/identity/account_id.rs b/bin/icp-cli/src/commands/identity/account_id.rs new file mode 100644 index 000000000..d4dd6d84a --- /dev/null +++ b/bin/icp-cli/src/commands/identity/account_id.rs @@ -0,0 +1,33 @@ +use crate::context::Context; +use crate::options::IdentityOpt; +use clap::Parser; +use ic_ledger_types::{AccountIdentifier, Subaccount}; +use icp_identity::key::LoadIdentityInContextError; +use snafu::Snafu; + +#[derive(Debug, Parser)] +pub struct AccountIdCmd { + #[clap(flatten)] + pub identity: IdentityOpt, +} + +pub fn exec(ctx: &Context, cmd: AccountIdCmd) -> Result<(), AccountIdError> { + ctx.require_identity(cmd.identity.name()); + + let identity = ctx.identity()?; + let principal = identity + .sender() + .map_err(|message| AccountIdError::IdentityError { message })?; + let account = AccountIdentifier::new(&principal, &Subaccount([0; 32])); + println!("{account}"); + Ok(()) +} + +#[derive(Debug, Snafu)] +pub enum AccountIdError { + #[snafu(transparent)] + LoadIdentity { source: LoadIdentityInContextError }, + + #[snafu(display("failed to load identity principal: {message}"))] + IdentityError { message: String }, +} diff --git a/bin/icp-cli/src/commands/token.rs b/bin/icp-cli/src/commands/token.rs new file mode 100644 index 000000000..dc3c362f5 --- /dev/null +++ b/bin/icp-cli/src/commands/token.rs @@ -0,0 +1,46 @@ +use crate::context::Context; +use clap::{Parser, Subcommand}; +use snafu::Snafu; + +mod balance; + +#[derive(Debug, Parser)] +pub struct TokenArgs { + /// Token identifier (name or canister ID). Defaults to "icp" when omitted. + #[arg(value_name = "TOKEN")] + token: Option, +} + +impl TokenArgs { + pub fn token(&self) -> &str { + self.token.as_deref().unwrap_or("icp") + } +} + +#[derive(Debug, Parser)] +#[command(subcommand_precedence_over_arg = true)] +pub struct Cmd { + #[clap(flatten)] + pub token_args: TokenArgs, + + #[command(subcommand)] + subcmd: TokenSubcmd, +} + +#[derive(Debug, Subcommand)] +pub enum TokenSubcmd { + Balance(balance::Cmd), +} + +pub async fn dispatch(ctx: &Context, cmd: Cmd) -> Result<(), CommandError> { + match cmd.subcmd { + TokenSubcmd::Balance(subcmd) => balance::exec(ctx, cmd.token_args, subcmd).await?, + } + Ok(()) +} + +#[derive(Debug, Snafu)] +pub enum CommandError { + #[snafu(transparent)] + Balance { source: balance::CommandError }, +} diff --git a/bin/icp-cli/src/commands/token/balance.rs b/bin/icp-cli/src/commands/token/balance.rs new file mode 100644 index 000000000..24d046973 --- /dev/null +++ b/bin/icp-cli/src/commands/token/balance.rs @@ -0,0 +1,131 @@ +use bigdecimal::BigDecimal; +use candid::{Decode, Encode, Nat, Principal}; +use clap::Parser; +use ic_agent::AgentError; +use icp_identity::key::LoadIdentityInContextError; +use icrc_ledger_types::icrc1::account::Account; +use snafu::Snafu; + +use crate::{ + context::{Context, ContextGetAgentError, GetProjectError}, + options::{EnvironmentOpt, IdentityOpt}, +}; + +#[derive(Debug, Parser)] +pub struct Cmd { + #[clap(flatten)] + pub environment: EnvironmentOpt, + + #[clap(flatten)] + pub identity: IdentityOpt, + // todo: subaccount, owner, account_id +} + +pub async fn exec( + ctx: &Context, + parent_cmd: super::TokenArgs, + cmd: Cmd, +) -> Result<(), CommandError> { + // Load identity + ctx.require_identity(cmd.identity.name()); + + // Load the project manifest, which defines the canisters to be built. + let pm = ctx.project()?; + + // Load target environment + let env = pm + .environments + .iter() + .find(|&v| v.name == cmd.environment.name()) + .ok_or(CommandError::EnvironmentNotFound { + name: cmd.environment.name().to_owned(), + })?; + + // TODO(or.ricon): Support default networks (`local` and `ic`) + // + let network = env + .network + .as_ref() + .expect("no network specified in environment"); + + // Setup network + ctx.require_network(network); + + // Prepare agent + let agent = ctx.agent()?; + + let token_address = if let Ok(token_address) = Principal::from_text(parent_cmd.token()) { + token_address + } else { + match parent_cmd.token().to_lowercase().as_str() { + "icp" => Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap(), + "cycles" => Principal::from_text("um5iw-rqaaa-aaaaq-qaaba-cai").unwrap(), + _ => { + return Err(CommandError::InvalidToken { + token: parent_cmd.token().to_string(), + }); + } + } + }; + + let account = Account { + owner: ctx + .identity()? + .as_ref() + .sender() + .map_err(|message| CommandError::GetPrincipalError { message })?, + subaccount: None, + }; + + let balance_future = agent + .query(&token_address, "icrc1_balance_of") + .with_arg(Encode!(&account).unwrap()) + .call(); + let decimals_future = agent + .query(&token_address, "icrc1_decimals") + .with_arg(Encode!(&()).unwrap()) + .call(); + + let (balance, decimals) = tokio::join!(balance_future, decimals_future); + let balance_bytes = balance + .map_err(|e| CommandError::TokenCanisterError { source: e }) + .unwrap(); + let decimals_bytes = decimals + .map_err(|e| CommandError::TokenCanisterError { source: e }) + .unwrap(); + + let balance = Decode!(&balance_bytes, Nat).unwrap(); + let decimals = Decode!(&decimals_bytes, u8).unwrap(); + + print_balance(balance, decimals); + Ok(()) +} + +fn print_balance(balance: Nat, decimals: u8) { + let amount = BigDecimal::from_biguint(balance.0, decimals as i64); + println!("Balance: {amount}"); +} + +#[derive(Debug, Snafu)] +pub enum CommandError { + #[snafu(display("project does not contain an environment named '{name}'"))] + EnvironmentNotFound { name: String }, + + #[snafu(transparent)] + GetAgent { source: ContextGetAgentError }, + + #[snafu(display("Failed to get identity principal: {message}"))] + GetPrincipalError { message: String }, + + #[snafu(transparent)] + GetProject { source: GetProjectError }, + + #[snafu(display("Token name unknown: {token}"))] + InvalidToken { token: String }, + + #[snafu(transparent)] + LoadIdentity { source: LoadIdentityInContextError }, + + #[snafu(display("Failed to talk to token canister: {source}"))] + TokenCanisterError { source: AgentError }, +} diff --git a/bin/icp-cli/src/options.rs b/bin/icp-cli/src/options.rs index 02f31b2b0..d661def97 100644 --- a/bin/icp-cli/src/options.rs +++ b/bin/icp-cli/src/options.rs @@ -41,14 +41,4 @@ impl EnvironmentOpt { // Otherwise, default to `local` self.environment.as_deref().unwrap_or(ENVIRONMENT_LOCAL) } - - pub fn is_mainnet(&self) -> bool { - self.name() == ENVIRONMENT_IC - } - - /// Refers to `aaaaa-aa:provisional_create_canister_with_cycles` and `aaaaa-aa:provisional_top_up_canister` - pub fn supports_provisional_api(&self) -> bool { - // Provisional API is not supported on mainnet. In the future, maybe it is also not supported on e.g. UTOPIA networks. - !self.is_mainnet() - } } diff --git a/bin/icp-cli/tests/common/context.rs b/bin/icp-cli/tests/common/context.rs index 76837235a..3452738a8 100644 --- a/bin/icp-cli/tests/common/context.rs +++ b/bin/icp-cli/tests/common/context.rs @@ -1,3 +1,4 @@ +use std::cell::{Ref, RefCell}; use std::{ env, ffi::OsString, @@ -8,9 +9,16 @@ use assert_cmd::Command; use camino::{Utf8Path, Utf8PathBuf}; use camino_tempfile::{Utf8TempDir, tempdir}; use icp_network::NETWORK_LOCAL; +use pocket_ic::PocketIc; use serde_json::{Value, json}; +use url::Url; -use crate::common::{ChildGuard, PATH_SEPARATOR, TestNetwork, TestNetworkForDfx}; +use crate::common::{ + ChildGuard, PATH_SEPARATOR, TestNetwork, TestNetworkForDfx, + context::icp_ledger::IcpLedgerPocketIcClient, +}; + +mod icp_ledger; pub struct TestContext { home_dir: Utf8TempDir, @@ -18,6 +26,7 @@ pub struct TestContext { asset_dir: Utf8PathBuf, dfx_path: Option, os_path: OsString, + pocketic: RefCell>, } impl TestContext { @@ -44,6 +53,7 @@ impl TestContext { asset_dir, dfx_path: None, os_path, + pocketic: RefCell::new(None), } } @@ -188,8 +198,15 @@ impl TestContext { // "icp network start" will wait for the local network to be healthy, // but for now we need to wait for the descriptor to be created. - self.wait_for_local_network_descriptor(project_dir); + let network_descriptor = self.wait_for_local_network_descriptor(project_dir); + let pic = PocketIc::new_from_existing_instance( + network_descriptor.pocketic_url, + network_descriptor.pocketic_instance_id, + None, + ); + + self.pocketic.replace(Some(pic)); child_guard } @@ -256,9 +273,24 @@ impl TestContext { .expect("network descriptor does not contain root key") .to_string(); + let pocketic_url = network_descriptor + .get("pocketic-url") + .and_then(|pu| pu.as_str()) + .expect("network descriptor does not contain pocketic url") + .to_string(); + let pocketic_url = Url::parse(&pocketic_url).expect("invalid pocketic url"); + + let pocketic_instance_id = network_descriptor + .get("pocketic-instance-id") + .and_then(|pi| pi.as_u64()) + .expect("network descriptor does not contain pocketic instance id") + as usize; + TestNetwork { gateway_port, root_key, + pocketic_url, + pocketic_instance_id, } } @@ -300,4 +332,11 @@ impl TestContext { std::fs::write(&descriptor_path, contents) .expect("Failed to write network descriptor file"); } + + pub fn icp_ledger(&self) -> IcpLedgerPocketIcClient<'_> { + let pic_ref: Ref<'_, PocketIc> = Ref::map(self.pocketic.borrow(), |opt| { + opt.as_ref().expect("pocketic not started") + }); + IcpLedgerPocketIcClient { pic: pic_ref } + } } diff --git a/bin/icp-cli/tests/common/context/icp_ledger.rs b/bin/icp-cli/tests/common/context/icp_ledger.rs new file mode 100644 index 000000000..b1afdc4a9 --- /dev/null +++ b/bin/icp-cli/tests/common/context/icp_ledger.rs @@ -0,0 +1,37 @@ +use candid::{Encode, Nat, Principal}; +use icrc_ledger_types::icrc1::account::Subaccount; +use pocket_ic::PocketIc; +use std::cell::Ref; + +const GOVERNANCE_ID: Principal = Principal::from_slice(&[0, 0, 0, 0, 0, 0, 0, 1, 1, 1]); // rrkah-fqaaa-aaaaa-aaaaq-cai +const LEDGER_ID: Principal = Principal::from_slice(&[0, 0, 0, 0, 0, 0, 0, 2, 1, 1]); // ryjl3-tyaaa-aaaaa-aaaba-cai + +pub struct IcpLedgerPocketIcClient<'a> { + pub pic: Ref<'a, PocketIc>, +} + +impl<'a> IcpLedgerPocketIcClient<'a> { + pub fn mint_icp( + &self, + owner: Principal, + subaccount: Option, + amount: impl Into, + ) { + self.pic + .update_call( + LEDGER_ID, + GOVERNANCE_ID, + "icrc1_transfer", + Encode!(&icrc_ledger_types::icrc1::transfer::TransferArg { + from_subaccount: None, + to: icrc_ledger_types::icrc1::account::Account { owner, subaccount }, + fee: None, + created_at_time: None, + memo: None, + amount: amount.into(), + }) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/bin/icp-cli/tests/common/mod.rs b/bin/icp-cli/tests/common/mod.rs index 26941b797..59da4119f 100644 --- a/bin/icp-cli/tests/common/mod.rs +++ b/bin/icp-cli/tests/common/mod.rs @@ -7,6 +7,7 @@ use httptest::{Expectation, Server, matchers::*, responders::*}; mod context; pub use context::TestContext; +use url::Url; #[cfg(unix)] pub const PATH_SEPARATOR: &str = ":"; @@ -33,6 +34,8 @@ pub fn spawn_test_server(method: &str, path: &str, body: &[u8]) -> httptest::Ser // A network run by icp-cli for a test. These fields are read from the network descriptor // after starting the network. pub struct TestNetwork { + pub pocketic_url: Url, + pub pocketic_instance_id: usize, pub gateway_port: u16, pub root_key: String, } diff --git a/bin/icp-cli/tests/network_tests.rs b/bin/icp-cli/tests/network_tests.rs index 978796cb5..77d5dc808 100644 --- a/bin/icp-cli/tests/network_tests.rs +++ b/bin/icp-cli/tests/network_tests.rs @@ -162,6 +162,7 @@ fn deploy_to_other_projects_network() { let TestNetwork { gateway_port, root_key, + .. } = ctx.wait_for_local_network_descriptor(&proja); // Use vendored WASM diff --git a/bin/icp-cli/tests/token_tests.rs b/bin/icp-cli/tests/token_tests.rs new file mode 100644 index 000000000..a42d0380a --- /dev/null +++ b/bin/icp-cli/tests/token_tests.rs @@ -0,0 +1,70 @@ +use crate::common::TestContext; +use candid::{Encode, Nat, Principal}; +use icp_fs::fs::write; +use icrc_ledger_types; +use predicates::str::contains; +use serial_test::serial; + +mod common; + +#[test] +#[serial] +fn token_balance() { + let ctx = TestContext::new(); + + // Setup project + let project_dir = ctx.create_project_dir("icp"); + + // Use vendored WASM + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + // Project manifest + let pm = format!( + r#" + canister: + name: my-canister + build: + steps: + - type: script + command: sh -c 'cp {} "$ICP_WASM_OUTPUT_PATH"' + "#, + wasm, + ); + + write( + project_dir.join("icp.yaml"), // path + pm, // contents + ) + .expect("failed to write project manifest"); + + // Start network + let _g = ctx.start_network_in(&project_dir); + + // Wait for network + ctx.ping_until_healthy(&project_dir); + + ctx.icp() + .current_dir(&project_dir) + .args(["token", "balance"]) + .assert() + .stdout(contains("Balance: 0")) + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args(["token", "cycles", "balance"]) + .assert() + .stdout(contains("Balance: 0")) + .success(); + + // mint icp to identity + ctx.icp_ledger() + .mint_icp(Principal::anonymous(), None, 123456789_u128); + + ctx.icp() + .current_dir(&project_dir) + .args(["token", "icp", "balance"]) + .assert() + .stdout(contains("Balance: 1.23456789")) + .success(); +} diff --git a/lib/icp-network/Cargo.toml b/lib/icp-network/Cargo.toml index 0836796e1..b09ee7a7d 100644 --- a/lib/icp-network/Cargo.toml +++ b/lib/icp-network/Cargo.toml @@ -18,3 +18,4 @@ uuid = { workspace = true } snafu = { workspace = true } time = { workspace = true } tokio = { workspace = true } +url = { workspace = true } diff --git a/lib/icp-network/src/config.rs b/lib/icp-network/src/config.rs index 1b8277dfa..7c0ebf1cf 100644 --- a/lib/icp-network/src/config.rs +++ b/lib/icp-network/src/config.rs @@ -19,6 +19,8 @@ pub struct NetworkDescriptorModel { pub network_dir: Utf8PathBuf, pub gateway: NetworkDescriptorGatewayPort, pub default_effective_canister_id: Principal, + pub pocketic_url: String, + pub pocketic_instance_id: usize, pub pid: Option, pub root_key: String, } diff --git a/lib/icp-network/src/managed/pocketic.rs b/lib/icp-network/src/managed/pocketic.rs index fd1cf75c3..4086dcb68 100644 --- a/lib/icp-network/src/managed/pocketic.rs +++ b/lib/icp-network/src/managed/pocketic.rs @@ -2,8 +2,8 @@ use camino::Utf8Path; use candid::Principal; use pocket_ic::common::rest::{ AutoProgressConfig, CreateHttpGatewayResponse, CreateInstanceResponse, ExtendedSubnetConfigSet, - HttpGatewayBackend, HttpGatewayConfig, HttpGatewayInfo, InstanceConfig, InstanceId, RawTime, - SubnetSpec, Topology, + HttpGatewayBackend, HttpGatewayConfig, HttpGatewayInfo, IcpFeatures, InstanceConfig, + InstanceId, RawTime, SubnetSpec, Topology, }; use reqwest::Url; use snafu::prelude::*; @@ -38,7 +38,7 @@ pub fn spawn_pocketic(pocketic_path: &Utf8Path, port_file: &Utf8Path) -> tokio:: pub struct PocketIcAdminInterface { client: reqwest::Client, - base_url: Url, + pub base_url: Url, } impl PocketIcAdminInterface { @@ -73,6 +73,16 @@ impl PocketIcAdminInterface { nonmainnet_features: true, log_level: Some("ERROR".to_string()), bitcoind_addr: None, // bitcoind_addr.clone(), + icp_features: Some(IcpFeatures { + cycles_minting: true, + registry: false, + icp_token: true, + cycles_token: true, + nns_governance: false, + sns: false, + }), + allow_incomplete_state: None, + initial_time: None, }) .send() .await? diff --git a/lib/icp-network/src/managed/run.rs b/lib/icp-network/src/managed/run.rs index 94edf2ed8..7346cea66 100644 --- a/lib/icp-network/src/managed/run.rs +++ b/lib/icp-network/src/managed/run.rs @@ -120,6 +120,8 @@ async fn run_pocketic( default_effective_canister_id, pid: Some(child.id().unwrap()), root_key: instance.root_key, + pocketic_url: instance.admin.base_url.to_string(), + pocketic_instance_id: instance.instance_id, }; let _cleaner = nd.save_network_descriptors(&descriptor)?; From c360c931fc6c3b569b7880143230c0e3f7ce3135 Mon Sep 17 00:00:00 2001 From: Vivienne Date: Mon, 25 Aug 2025 09:48:41 +0200 Subject: [PATCH 3/7] add token symbol to balance --- bin/icp-cli/src/commands/token/balance.rs | 16 ++++++++++++---- bin/icp-cli/tests/token_tests.rs | 6 +++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/bin/icp-cli/src/commands/token/balance.rs b/bin/icp-cli/src/commands/token/balance.rs index 24d046973..4c72de294 100644 --- a/bin/icp-cli/src/commands/token/balance.rs +++ b/bin/icp-cli/src/commands/token/balance.rs @@ -85,25 +85,33 @@ pub async fn exec( .query(&token_address, "icrc1_decimals") .with_arg(Encode!(&()).unwrap()) .call(); + let symbol_future = agent + .query(&token_address, "icrc1_symbol") + .with_arg(Encode!(&()).unwrap()) + .call(); - let (balance, decimals) = tokio::join!(balance_future, decimals_future); + let (balance, decimals, symbol) = tokio::join!(balance_future, decimals_future, symbol_future); let balance_bytes = balance .map_err(|e| CommandError::TokenCanisterError { source: e }) .unwrap(); let decimals_bytes = decimals .map_err(|e| CommandError::TokenCanisterError { source: e }) .unwrap(); + let symbol_bytes = symbol + .map_err(|e| CommandError::TokenCanisterError { source: e }) + .unwrap(); let balance = Decode!(&balance_bytes, Nat).unwrap(); let decimals = Decode!(&decimals_bytes, u8).unwrap(); + let symbol = Decode!(&symbol_bytes, String).unwrap(); - print_balance(balance, decimals); + print_balance(balance, decimals, symbol); Ok(()) } -fn print_balance(balance: Nat, decimals: u8) { +fn print_balance(balance: Nat, decimals: u8, symbol: String) { let amount = BigDecimal::from_biguint(balance.0, decimals as i64); - println!("Balance: {amount}"); + println!("Balance: {amount} {symbol}"); } #[derive(Debug, Snafu)] diff --git a/bin/icp-cli/tests/token_tests.rs b/bin/icp-cli/tests/token_tests.rs index a42d0380a..b574ccc61 100644 --- a/bin/icp-cli/tests/token_tests.rs +++ b/bin/icp-cli/tests/token_tests.rs @@ -47,14 +47,14 @@ fn token_balance() { .current_dir(&project_dir) .args(["token", "balance"]) .assert() - .stdout(contains("Balance: 0")) + .stdout(contains("Balance: 0 ICP")) .success(); ctx.icp() .current_dir(&project_dir) .args(["token", "cycles", "balance"]) .assert() - .stdout(contains("Balance: 0")) + .stdout(contains("Balance: 0 TCYCLES")) .success(); // mint icp to identity @@ -65,6 +65,6 @@ fn token_balance() { .current_dir(&project_dir) .args(["token", "icp", "balance"]) .assert() - .stdout(contains("Balance: 1.23456789")) + .stdout(contains("Balance: 1.23456789 ICP")) .success(); } From 2ccb69a58742020a959c78b9b28374f1b8718ac1 Mon Sep 17 00:00:00 2001 From: Vivienne Date: Mon, 25 Aug 2025 10:03:27 +0200 Subject: [PATCH 4/7] add cycles balance command --- bin/icp-cli/src/commands.rs | 6 ++++ bin/icp-cli/src/commands/cycles.rs | 44 ++++++++++++++++++++++++++ bin/icp-cli/src/commands/token.rs | 4 +-- bin/icp-cli/tests/cycles_tets.rs | 50 ++++++++++++++++++++++++++++++ bin/icp-cli/tests/token_tests.rs | 3 +- 5 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 bin/icp-cli/src/commands/cycles.rs create mode 100644 bin/icp-cli/tests/cycles_tets.rs diff --git a/bin/icp-cli/src/commands.rs b/bin/icp-cli/src/commands.rs index 451cdd4b3..96b07d4a6 100644 --- a/bin/icp-cli/src/commands.rs +++ b/bin/icp-cli/src/commands.rs @@ -8,6 +8,7 @@ use snafu::Snafu; mod build; mod canister; +mod cycles; mod deploy; mod environment; mod identity; @@ -25,6 +26,7 @@ pub struct Cmd { pub enum Subcmd { Build(build::Cmd), Canister(canister::Cmd), + Cycles(cycles::Cmd), Deploy(deploy::Cmd), Environment(environment::Cmd), Identity(identity::IdentityCmd), @@ -37,6 +39,7 @@ pub async fn dispatch(ctx: &Context, cli: Cmd) -> Result<(), DispatchError> { match cli.subcommand { Subcmd::Build(opts) => build::exec(ctx, opts).await?, Subcmd::Canister(opts) => canister::dispatch(ctx, opts).await?, + Subcmd::Cycles(opts) => cycles::dispatch(ctx, opts).await?, Subcmd::Deploy(opts) => deploy::exec(ctx, opts).await?, Subcmd::Environment(opts) => environment::exec(ctx, opts).await?, Subcmd::Identity(opts) => identity::dispatch(ctx, opts).await?, @@ -55,6 +58,9 @@ pub enum DispatchError { #[snafu(transparent)] Canister { source: CanisterCommandError }, + #[snafu(transparent)] + Cycles { source: cycles::CommandError }, + #[snafu(transparent)] Deploy { source: deploy::CommandError }, diff --git a/bin/icp-cli/src/commands/cycles.rs b/bin/icp-cli/src/commands/cycles.rs new file mode 100644 index 000000000..176c90ac8 --- /dev/null +++ b/bin/icp-cli/src/commands/cycles.rs @@ -0,0 +1,44 @@ +use crate::{ + commands::token::{self, TokenArgs}, + context::Context, +}; +use clap::{Parser, Subcommand}; +use snafu::Snafu; + +fn cycles_token_args() -> TokenArgs { + TokenArgs { + token: Some(String::from("cycles")), + } +} + +#[derive(Debug, Parser)] +#[command(subcommand_precedence_over_arg = true)] +pub struct Cmd { + #[clap(flatten)] + pub token_args: TokenArgs, + + #[command(subcommand)] + subcmd: TokenSubcmd, +} + +#[derive(Debug, Subcommand)] +pub enum TokenSubcmd { + Balance(token::balance::Cmd), +} + +pub async fn dispatch(ctx: &Context, cmd: Cmd) -> Result<(), CommandError> { + match cmd.subcmd { + TokenSubcmd::Balance(subcmd) => { + token::balance::exec(ctx, cycles_token_args(), subcmd).await? + } + } + Ok(()) +} + +#[derive(Debug, Snafu)] +pub enum CommandError { + #[snafu(transparent)] + Balance { + source: token::balance::CommandError, + }, +} diff --git a/bin/icp-cli/src/commands/token.rs b/bin/icp-cli/src/commands/token.rs index dc3c362f5..1c912d2b8 100644 --- a/bin/icp-cli/src/commands/token.rs +++ b/bin/icp-cli/src/commands/token.rs @@ -2,13 +2,13 @@ use crate::context::Context; use clap::{Parser, Subcommand}; use snafu::Snafu; -mod balance; +pub mod balance; #[derive(Debug, Parser)] pub struct TokenArgs { /// Token identifier (name or canister ID). Defaults to "icp" when omitted. #[arg(value_name = "TOKEN")] - token: Option, + pub token: Option, } impl TokenArgs { diff --git a/bin/icp-cli/tests/cycles_tets.rs b/bin/icp-cli/tests/cycles_tets.rs new file mode 100644 index 000000000..41d116835 --- /dev/null +++ b/bin/icp-cli/tests/cycles_tets.rs @@ -0,0 +1,50 @@ +use crate::common::TestContext; +use icp_fs::fs::write; +use predicates::str::contains; +use serial_test::serial; + +mod common; + +#[test] +#[serial] +fn cycles_balance() { + let ctx = TestContext::new(); + + // Setup project + let project_dir = ctx.create_project_dir("icp"); + + // Use vendored WASM + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + // Project manifest + let pm = format!( + r#" + canister: + name: my-canister + build: + steps: + - type: script + command: sh -c 'cp {} "$ICP_WASM_OUTPUT_PATH"' + "#, + wasm, + ); + + write( + project_dir.join("icp.yaml"), // path + pm, // contents + ) + .expect("failed to write project manifest"); + + // Start network + let _g = ctx.start_network_in(&project_dir); + + // Wait for network + ctx.ping_until_healthy(&project_dir); + + ctx.icp() + .current_dir(&project_dir) + .args(["cycles", "balance"]) + .assert() + .stdout(contains("Balance: 0 TCYCLES")) + .success(); +} diff --git a/bin/icp-cli/tests/token_tests.rs b/bin/icp-cli/tests/token_tests.rs index b574ccc61..f476efe26 100644 --- a/bin/icp-cli/tests/token_tests.rs +++ b/bin/icp-cli/tests/token_tests.rs @@ -1,7 +1,6 @@ use crate::common::TestContext; -use candid::{Encode, Nat, Principal}; +use candid::Principal; use icp_fs::fs::write; -use icrc_ledger_types; use predicates::str::contains; use serial_test::serial; From 4da8b32e9d7ecb7f7e249b7a9cf8de62aba80705 Mon Sep 17 00:00:00 2001 From: Vivienne Date: Mon, 25 Aug 2025 13:37:01 +0200 Subject: [PATCH 5/7] add cycles mint --- Cargo.lock | 1 + bin/icp-cli/Cargo.toml | 1 + bin/icp-cli/src/commands/cycles.rs | 7 + bin/icp-cli/src/commands/cycles/mint.rs | 274 ++++++++++++++++++ bin/icp-cli/tests/common/context.rs | 7 + .../tests/common/context/icp_shorthand.rs | 41 +++ bin/icp-cli/tests/cycles_tets.rs | 35 +++ 7 files changed, 366 insertions(+) create mode 100644 bin/icp-cli/src/commands/cycles/mint.rs create mode 100644 bin/icp-cli/tests/common/context/icp_shorthand.rs diff --git a/Cargo.lock b/Cargo.lock index c2bea3d1f..1d8a6d52f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2262,6 +2262,7 @@ dependencies = [ "pkcs8 0.10.2", "pocket-ic", "predicates", + "rand 0.9.1", "reqwest", "sec1 0.7.3", "serde", diff --git a/bin/icp-cli/Cargo.toml b/bin/icp-cli/Cargo.toml index 709a82308..180729e5b 100644 --- a/bin/icp-cli/Cargo.toml +++ b/bin/icp-cli/Cargo.toml @@ -48,6 +48,7 @@ camino-tempfile = "1" nix = { version = "0.30.1", features = ["process", "signal"] } pocket-ic.workspace = true predicates = "3" +rand.workspace = true reqwest = { workspace = true } serde_yaml.workspace = true serial_test = { version = "3.2.0", features = ["file_locks"] } diff --git a/bin/icp-cli/src/commands/cycles.rs b/bin/icp-cli/src/commands/cycles.rs index 176c90ac8..a2cfa6d30 100644 --- a/bin/icp-cli/src/commands/cycles.rs +++ b/bin/icp-cli/src/commands/cycles.rs @@ -5,6 +5,8 @@ use crate::{ use clap::{Parser, Subcommand}; use snafu::Snafu; +mod mint; + fn cycles_token_args() -> TokenArgs { TokenArgs { token: Some(String::from("cycles")), @@ -24,6 +26,7 @@ pub struct Cmd { #[derive(Debug, Subcommand)] pub enum TokenSubcmd { Balance(token::balance::Cmd), + Mint(mint::Cmd), } pub async fn dispatch(ctx: &Context, cmd: Cmd) -> Result<(), CommandError> { @@ -31,6 +34,7 @@ pub async fn dispatch(ctx: &Context, cmd: Cmd) -> Result<(), CommandError> { TokenSubcmd::Balance(subcmd) => { token::balance::exec(ctx, cycles_token_args(), subcmd).await? } + TokenSubcmd::Mint(subcmd) => mint::exec(ctx, subcmd).await?, } Ok(()) } @@ -41,4 +45,7 @@ pub enum CommandError { Balance { source: token::balance::CommandError, }, + + #[snafu(transparent)] + Mint { source: mint::CommandError }, } diff --git a/bin/icp-cli/src/commands/cycles/mint.rs b/bin/icp-cli/src/commands/cycles/mint.rs new file mode 100644 index 000000000..fec7d8800 --- /dev/null +++ b/bin/icp-cli/src/commands/cycles/mint.rs @@ -0,0 +1,274 @@ +use bigdecimal::BigDecimal; +use bigdecimal::ToPrimitive; +use candid::{CandidType, Decode, Encode, Nat, Principal}; +use clap::Parser; +use ic_agent::AgentError; +use ic_ledger_types::AccountIdentifier; +use ic_ledger_types::Memo; +use ic_ledger_types::Subaccount; +use ic_ledger_types::Tokens; +use ic_ledger_types::TransferArgs; +use ic_ledger_types::TransferResult; +use icp_identity::key::LoadIdentityInContextError; +use serde::Deserialize; +use snafu::Snafu; + +use crate::{ + context::{Context, ContextGetAgentError, GetProjectError}, + options::{EnvironmentOpt, IdentityOpt}, +}; + +pub const MEMO_MINT_CYCLES: u64 = 0x544e494d; // == 'MINT' + +#[derive(Debug, Parser)] +pub struct Cmd { + #[clap(flatten)] + pub environment: EnvironmentOpt, + + #[clap(flatten)] + pub identity: IdentityOpt, + + #[clap(long = "icp-amount", conflicts_with = "cycles_amount")] + pub icp_amount: Option, + + #[clap(long = "cycles-amount", conflicts_with = "icp_amount")] + pub cycles_amount: Option, +} + +pub async fn exec(ctx: &Context, cmd: Cmd) -> Result<(), CommandError> { + // Load identity + ctx.require_identity(cmd.identity.name()); + + // Load the project manifest, which defines the canisters to be built. + let pm = ctx.project()?; + + // Load target environment + let env = pm + .environments + .iter() + .find(|&v| v.name == cmd.environment.name()) + .ok_or(CommandError::EnvironmentNotFound { + name: cmd.environment.name().to_owned(), + })?; + + // TODO(or.ricon): Support default networks (`local` and `ic`) + // + let network = env + .network + .as_ref() + .expect("no network specified in environment"); + + // Setup network + ctx.require_network(network); + + // Prepare agent + let agent = ctx.agent()?; + let user_principal = ctx + .identity()? + .sender() + .map_err(|e| CommandError::GetPrincipalError { message: e })?; + + let icp_e8s_to_deposit = if let Some(icp_amount) = cmd.icp_amount { + (icp_amount * 100_000_000_u64) + .to_u64() + .ok_or(CommandError::IcpAmountOverflow)? + } else if let Some(cycles_amount) = cmd.cycles_amount { + let cmc_response = agent + .query( + &Principal::from_text("rkp4c-7iaaa-aaaaa-aaaca-cai").unwrap(), + "get_icp_xdr_conversion_rate", + ) + .with_arg(Encode!(&()).unwrap()) + .call() + .await + .map_err(|e| CommandError::CanisterError { + canister: "cmc".to_string(), + source: e, + })?; + + let cmc_response = Decode!(&cmc_response, CmcResponse).unwrap(); + let cycles_per_e8s = cmc_response.data.xdr_permyriad_per_icp as u128; + let cycles_plus_fees = cycles_amount + 100_000_000_u128; // Cycles ledger charges 100M for deposits + let e8s_to_deposit = cycles_plus_fees.div_ceil(cycles_per_e8s); + + e8s_to_deposit + .to_u64() + .ok_or(CommandError::IcpAmountOverflow)? + } else { + return Err(CommandError::NoAmountSpecified); + }; + + let account_id = AccountIdentifier::new( + &Principal::from_text("rkp4c-7iaaa-aaaaa-aaaca-cai").unwrap(), + &Subaccount::from(user_principal), + ); + let memo = Memo(MEMO_MINT_CYCLES); + let transfer_args = TransferArgs { + memo, + amount: Tokens::from_e8s(icp_e8s_to_deposit), + fee: Tokens::from_e8s(10_000), + from_subaccount: None, + to: account_id, + created_at_time: None, + }; + + let transfer_result = agent + .update( + &Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap(), + "transfer", + ) + .with_arg(Encode!(&transfer_args).unwrap()) + .call_and_wait() + .await + .map_err(|e| CommandError::CanisterError { + canister: "ICP ledger".to_string(), + source: e, + })?; + let transfer_response = Decode!(&transfer_result, TransferResult).unwrap(); + let block_index = match transfer_response { + Ok(block_index) => block_index, + Err(err) => { + match err { + ic_ledger_types::TransferError::TxDuplicate { duplicate_of } => duplicate_of, + ic_ledger_types::TransferError::InsufficientFunds { balance } => { + let required = BigDecimal::new((icp_e8s_to_deposit + 10_000).into(), 8); // transfer fee + let available = BigDecimal::new(balance.e8s().into(), 8); + return Err(CommandError::InsufficientFunds { + required, + available, + }); + } + err => { + return Err(CommandError::TransferError { src: err }); + } + } + } + }; + + let notify_response = agent + .update( + &Principal::from_text("rkp4c-7iaaa-aaaaa-aaaca-cai").unwrap(), + "notify_mint_cycles", + ) + .with_arg( + Encode!(&NotifyMintCyclesArgs { + block_index, + deposit_memo: None, + to_subaccount: None, + }) + .unwrap(), + ) + .call_and_wait() + .await + .map_err(|e| CommandError::CanisterError { + canister: "cmc".to_string(), + source: e, + })?; + let notify_response = Decode!(¬ify_response, NotifyMintCyclesResponse).unwrap(); + let minted = match notify_response { + NotifyMintCyclesResponse::Ok(ok) => ok, + NotifyMintCyclesResponse::Err(err) => { + return Err(CommandError::NotifyMintCyclesError { src: err }); + } + }; + + // display + let deposited = BigDecimal::new((minted.minted - 100_000_000_u128).into(), 12); // deposit charges 100M fee + let new_balance = BigDecimal::new(minted.balance.into(), 12); + println!("Minted {deposited} TCYCLES to your account, new balance: {new_balance} TCYCLES."); + + Ok(()) +} + +#[derive(Debug, Deserialize, CandidType)] +struct CmcResponse { + data: CmcData, +} + +#[derive(Debug, Deserialize, CandidType)] +struct CmcData { + xdr_permyriad_per_icp: u64, +} + +#[derive(Debug, Deserialize, CandidType)] +pub struct NotifyMintCyclesArgs { + pub block_index: u64, + pub deposit_memo: Option>, + pub to_subaccount: Option>, +} + +#[derive(Debug, Deserialize, CandidType)] +pub struct NotifyMintCyclesOk { + pub balance: Nat, + pub block_index: Nat, + pub minted: Nat, +} + +#[derive(Debug, Deserialize, CandidType)] +pub struct NotifyMintCyclesRefunded { + pub block_index: Option, + pub reason: String, +} + +#[derive(Debug, Deserialize, CandidType)] +pub struct NotifyMintCyclesOther { + pub error_message: String, + pub error_code: u64, +} + +#[derive(Debug, Deserialize, CandidType)] +pub enum NotifyMintCyclesErr { + Refunded(NotifyMintCyclesRefunded), + InvalidTransaction(String), + Other(NotifyMintCyclesOther), + Processing, + TransactionTooOld(u64), +} + +#[derive(Debug, Deserialize, CandidType)] +pub enum NotifyMintCyclesResponse { + Ok(NotifyMintCyclesOk), + Err(NotifyMintCyclesErr), +} + +#[derive(Debug, Snafu)] +pub enum CommandError { + #[snafu(display("Failed to talk to {canister} canister: {source}"))] + CanisterError { + canister: String, + source: AgentError, + }, + + #[snafu(display("project does not contain an environment named '{name}'"))] + EnvironmentNotFound { name: String }, + + #[snafu(transparent)] + GetAgent { source: ContextGetAgentError }, + + #[snafu(display("Failed to get identity principal: {message}"))] + GetPrincipalError { message: String }, + + #[snafu(transparent)] + GetProject { source: GetProjectError }, + + #[snafu(display("ICP amount overflow. Specify less tokens."))] + IcpAmountOverflow, + + #[snafu(display("Failed ICP ledger transfer: {src:?}"))] + TransferError { src: ic_ledger_types::TransferError }, + + #[snafu(display("Insufficient funds: {required} ICP required, {available} ICP available."))] + InsufficientFunds { + required: BigDecimal, + available: BigDecimal, + }, + + #[snafu(transparent)] + LoadIdentity { source: LoadIdentityInContextError }, + + #[snafu(display("No amount specified. Use --icp-amount or --cycles-amount."))] + NoAmountSpecified, + + #[snafu(display("Failed to notify mint cycles: {src:?}"))] + NotifyMintCyclesError { src: NotifyMintCyclesErr }, +} diff --git a/bin/icp-cli/tests/common/context.rs b/bin/icp-cli/tests/common/context.rs index 3452738a8..28d493568 100644 --- a/bin/icp-cli/tests/common/context.rs +++ b/bin/icp-cli/tests/common/context.rs @@ -8,17 +8,20 @@ use std::{ use assert_cmd::Command; use camino::{Utf8Path, Utf8PathBuf}; use camino_tempfile::{Utf8TempDir, tempdir}; +use candid::Principal; use icp_network::NETWORK_LOCAL; use pocket_ic::PocketIc; use serde_json::{Value, json}; use url::Url; +use crate::common::context::icp_shorthand::IcpShorthand; use crate::common::{ ChildGuard, PATH_SEPARATOR, TestNetwork, TestNetworkForDfx, context::icp_ledger::IcpLedgerPocketIcClient, }; mod icp_ledger; +mod icp_shorthand; pub struct TestContext { home_dir: Utf8TempDir, @@ -95,6 +98,10 @@ impl TestContext { cmd } + pub fn icp_(&self) -> IcpShorthand<'_> { + IcpShorthand::new(&self) + } + pub fn dfx(&self) -> Command { let dfx_path = self .dfx_path diff --git a/bin/icp-cli/tests/common/context/icp_shorthand.rs b/bin/icp-cli/tests/common/context/icp_shorthand.rs new file mode 100644 index 000000000..97df11110 --- /dev/null +++ b/bin/icp-cli/tests/common/context/icp_shorthand.rs @@ -0,0 +1,41 @@ +use candid::Principal; + +use crate::common::TestContext; + +pub struct IcpShorthand<'a> { + ctx: &'a TestContext, +} + +impl<'a> IcpShorthand<'a> { + pub fn new(ctx: &'a TestContext) -> Self { + Self { ctx } + } + + pub fn active_principal(&self) -> Principal { + let stdout = String::from_utf8( + self.ctx + .icp() + .args(["identity", "principal"]) + .assert() + .get_output() + .stdout + .clone(), + ) + .unwrap(); + Principal::from_text(&stdout.trim()).unwrap() + } + + pub fn use_new_random_identity(&self) { + let random_name = format!("alice-{}", rand::random::()); + self.ctx + .icp() + .args(["identity", "new", &random_name]) + .assert() + .success(); + self.ctx + .icp() + .args(["identity", "default", &random_name]) + .assert() + .success(); + } +} diff --git a/bin/icp-cli/tests/cycles_tets.rs b/bin/icp-cli/tests/cycles_tets.rs index 41d116835..2ef775dfc 100644 --- a/bin/icp-cli/tests/cycles_tets.rs +++ b/bin/icp-cli/tests/cycles_tets.rs @@ -41,10 +41,45 @@ fn cycles_balance() { // Wait for network ctx.ping_until_healthy(&project_dir); + // Empty account has empty balance + ctx.icp_().use_new_random_identity(); ctx.icp() .current_dir(&project_dir) .args(["cycles", "balance"]) .assert() .stdout(contains("Balance: 0 TCYCLES")) .success(); + + // Mint ICP to cycles, specify ICP amount + let identity = ctx.icp_().active_principal(); + ctx.icp_ledger().mint_icp(identity, None, 123456789_u64); + ctx.icp() + .current_dir(&project_dir) + .args(["cycles", "mint", "--icp-amount", "1"]) + .assert() + .stdout(contains( + "Minted 3.519900000000 TCYCLES to your account, new balance: 3.519900000000 TCYCLES.", + )) + .success(); + + // Mint ICP to cycles, specify cycles amount + ctx.icp_().use_new_random_identity(); + let identity = ctx.icp_().active_principal(); + ctx.icp_ledger().mint_icp(identity, None, 123456789_u64); + ctx.icp() + .current_dir(&project_dir) + .args(["cycles", "mint", "--cycles-amount", "1000000000"]) + .assert() + .stdout(contains( + "Minted 0.001000000000 TCYCLES to your account, new balance: 0.001000000000 TCYCLES.", + )) + .success(); + ctx.icp() + .current_dir(&project_dir) + .args(["cycles", "mint", "--cycles-amount", "1500000000"]) + .assert() + .stdout(contains( + "Minted 0.001500016000 TCYCLES to your account, new balance: 0.002500016000 TCYCLES.", + )) + .success(); } From de9d40622a287aca1444d1df79c76278ed1b011d Mon Sep 17 00:00:00 2001 From: Vivienne Date: Mon, 25 Aug 2025 15:14:20 +0200 Subject: [PATCH 6/7] add token balance --- bin/icp-cli/src/commands/token.rs | 19 ++ bin/icp-cli/src/commands/token/balance.rs | 20 +- bin/icp-cli/src/commands/token/transfer.rs | 191 ++++++++++++++++++ bin/icp-cli/tests/common/context.rs | 1 - .../tests/common/context/icp_ledger.rs | 19 +- .../tests/common/context/icp_shorthand.rs | 16 ++ bin/icp-cli/tests/token_tests.rs | 85 ++++++++ 7 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 bin/icp-cli/src/commands/token/transfer.rs diff --git a/bin/icp-cli/src/commands/token.rs b/bin/icp-cli/src/commands/token.rs index 1c912d2b8..959494946 100644 --- a/bin/icp-cli/src/commands/token.rs +++ b/bin/icp-cli/src/commands/token.rs @@ -1,8 +1,10 @@ use crate::context::Context; +use candid::Principal; use clap::{Parser, Subcommand}; use snafu::Snafu; pub mod balance; +pub mod transfer; #[derive(Debug, Parser)] pub struct TokenArgs { @@ -15,6 +17,18 @@ impl TokenArgs { pub fn token(&self) -> &str { self.token.as_deref().unwrap_or("icp") } + + pub fn token_address(&self) -> Option { + if let Ok(token_address) = Principal::from_text(self.token()) { + return Some(token_address); + } + + match self.token().to_lowercase().as_str() { + "icp" => Some(Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap()), + "cycles" => Some(Principal::from_text("um5iw-rqaaa-aaaaq-qaaba-cai").unwrap()), + _ => None, + } + } } #[derive(Debug, Parser)] @@ -30,11 +44,13 @@ pub struct Cmd { #[derive(Debug, Subcommand)] pub enum TokenSubcmd { Balance(balance::Cmd), + Transfer(transfer::Cmd), } pub async fn dispatch(ctx: &Context, cmd: Cmd) -> Result<(), CommandError> { match cmd.subcmd { TokenSubcmd::Balance(subcmd) => balance::exec(ctx, cmd.token_args, subcmd).await?, + TokenSubcmd::Transfer(subcmd) => transfer::exec(ctx, cmd.token_args, subcmd).await?, } Ok(()) } @@ -43,4 +59,7 @@ pub async fn dispatch(ctx: &Context, cmd: Cmd) -> Result<(), CommandError> { pub enum CommandError { #[snafu(transparent)] Balance { source: balance::CommandError }, + + #[snafu(transparent)] + Transfer { source: transfer::CommandError }, } diff --git a/bin/icp-cli/src/commands/token/balance.rs b/bin/icp-cli/src/commands/token/balance.rs index 4c72de294..dc0285cea 100644 --- a/bin/icp-cli/src/commands/token/balance.rs +++ b/bin/icp-cli/src/commands/token/balance.rs @@ -1,5 +1,5 @@ use bigdecimal::BigDecimal; -use candid::{Decode, Encode, Nat, Principal}; +use candid::{Decode, Encode, Nat}; use clap::Parser; use ic_agent::AgentError; use icp_identity::key::LoadIdentityInContextError; @@ -54,19 +54,11 @@ pub async fn exec( // Prepare agent let agent = ctx.agent()?; - let token_address = if let Ok(token_address) = Principal::from_text(parent_cmd.token()) { - token_address - } else { - match parent_cmd.token().to_lowercase().as_str() { - "icp" => Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap(), - "cycles" => Principal::from_text("um5iw-rqaaa-aaaaq-qaaba-cai").unwrap(), - _ => { - return Err(CommandError::InvalidToken { - token: parent_cmd.token().to_string(), - }); - } - } - }; + let token_address = parent_cmd + .token_address() + .ok_or(CommandError::InvalidToken { + token: parent_cmd.token().to_string(), + })?; let account = Account { owner: ctx diff --git a/bin/icp-cli/src/commands/token/transfer.rs b/bin/icp-cli/src/commands/token/transfer.rs new file mode 100644 index 000000000..2670e14c6 --- /dev/null +++ b/bin/icp-cli/src/commands/token/transfer.rs @@ -0,0 +1,191 @@ +use bigdecimal::{BigDecimal, num_bigint::ToBigInt}; +use candid::{Decode, Encode, Nat, Principal}; +use clap::Parser; +use ic_agent::AgentError; +use icp_identity::key::LoadIdentityInContextError; +use icrc_ledger_types::icrc1::{account::Account, transfer::TransferArg, transfer::TransferError}; +use snafu::Snafu; + +use crate::{ + context::{Context, ContextGetAgentError, GetProjectError}, + options::{EnvironmentOpt, IdentityOpt}, +}; + +#[derive(Debug, Parser)] +pub struct Cmd { + #[clap(flatten)] + pub environment: EnvironmentOpt, + + #[clap(flatten)] + pub identity: IdentityOpt, + + #[clap(value_name = "RECEIVER")] + pub receiver: Principal, + + #[clap(value_name = "AMOUNT")] + pub amount: BigDecimal, + // todo: subaccount, owner, account_id +} + +pub async fn exec( + ctx: &Context, + parent_cmd: super::TokenArgs, + cmd: Cmd, +) -> Result<(), CommandError> { + // Load identity + ctx.require_identity(cmd.identity.name()); + + // Load the project manifest, which defines the canisters to be built. + let pm = ctx.project()?; + + // Load target environment + let env = pm + .environments + .iter() + .find(|&v| v.name == cmd.environment.name()) + .ok_or(CommandError::EnvironmentNotFound { + name: cmd.environment.name().to_owned(), + })?; + + // TODO(or.ricon): Support default networks (`local` and `ic`) + // + let network = env + .network + .as_ref() + .expect("no network specified in environment"); + + // Setup network + ctx.require_network(network); + + // Prepare agent + let agent = ctx.agent()?; + + let token_address = parent_cmd + .token_address() + .ok_or(CommandError::InvalidToken { + token: parent_cmd.token().to_string(), + })?; + + let fee_future = agent + .query(&token_address, "icrc1_fee") + .with_arg(Encode!(&()).unwrap()) + .call(); + let decimals_future = agent + .query(&token_address, "icrc1_decimals") + .with_arg(Encode!(&()).unwrap()) + .call(); + let symbol_future = agent + .query(&token_address, "icrc1_symbol") + .with_arg(Encode!(&()).unwrap()) + .call(); + let (fee_response, decimals_response, symbol_response) = + tokio::join!(fee_future, decimals_future, symbol_future); + let fee_bytes = fee_response + .map_err(|e| CommandError::TokenCanisterError { source: e }) + .unwrap(); + let decimals_bytes = decimals_response + .map_err(|e| CommandError::TokenCanisterError { source: e }) + .unwrap(); + let symbol_bytes = symbol_response + .map_err(|e| CommandError::TokenCanisterError { source: e }) + .unwrap(); + let fee = Decode!(&fee_bytes, Nat).expect("todo"); + let decimals = Decode!(&decimals_bytes, u8).expect("todo"); + let symbol = Decode!(&symbol_bytes, String).expect("todo"); + + let token_units = Nat::from( + (cmd.amount.clone() * 10u128.pow(decimals as u32)) + .to_bigint() + .expect("todo") + .to_biguint() + .expect("todo"), + ); + + let transfer_response = agent + .update(&token_address, "icrc1_transfer") + .with_arg( + Encode!(&TransferArg { + from_subaccount: None, + to: Account { + owner: cmd.receiver, + subaccount: None + }, + fee: None, + created_at_time: None, + memo: None, + amount: token_units, + }) + .unwrap(), + ) + .call_and_wait() + .await + .map_err(|e| CommandError::TokenCanisterError { source: e })?; + let transfer_result = Decode!(&transfer_response, Result) + .expect("Token does not follow the icrc1 standard"); + + match transfer_result { + Ok(block_index) => { + println!( + "Transferred {amount} {symbol} to {receiver} in block {block_index}.", + amount = cmd.amount, + symbol = symbol, + receiver = cmd.receiver, + block_index = block_index + ); + } + Err(error) => match error { + TransferError::InsufficientFunds { balance } => { + let balance = BigDecimal::from_biguint(balance.0, decimals as i64); + let fee = BigDecimal::from_biguint(fee.0, decimals as i64); + let required = cmd.amount + fee; + println!( + "Insufficient funds. Balance: {balance} {symbol}, Required: {required} {symbol} (including fee)" + ); + } + TransferError::BadFee { .. } => { + unreachable!("We do not specify a fee, so BadFee is not possible") + } + TransferError::BadBurn { min_burn_amount } => { + println!("Cannot burn less than {min_burn_amount}.") + } + TransferError::TooOld => { + unreachable!("We do not specify a created_at_time, so TooOld is not possible") + } + TransferError::CreatedInFuture { .. } => unreachable!( + "We do not specify a created_at_time, so CreatedInFuture is not possible" + ), + TransferError::TemporarilyUnavailable => todo!(), + TransferError::Duplicate { .. } => { + unreachable!("We do not specify a created_at_time, so Duplicate is not possible") + } + TransferError::GenericError { + error_code, + message, + } => { + println!("Token canister returned generic error: {error_code}: {message}"); + } + }, + }; + Ok(()) +} + +#[derive(Debug, Snafu)] +pub enum CommandError { + #[snafu(display("project does not contain an environment named '{name}'"))] + EnvironmentNotFound { name: String }, + + #[snafu(transparent)] + GetAgent { source: ContextGetAgentError }, + + #[snafu(transparent)] + GetProject { source: GetProjectError }, + + #[snafu(display("Token name unknown: {token}"))] + InvalidToken { token: String }, + + #[snafu(transparent)] + LoadIdentity { source: LoadIdentityInContextError }, + + #[snafu(display("Failed to talk to token canister: {source}"))] + TokenCanisterError { source: AgentError }, +} diff --git a/bin/icp-cli/tests/common/context.rs b/bin/icp-cli/tests/common/context.rs index 28d493568..74e67592a 100644 --- a/bin/icp-cli/tests/common/context.rs +++ b/bin/icp-cli/tests/common/context.rs @@ -8,7 +8,6 @@ use std::{ use assert_cmd::Command; use camino::{Utf8Path, Utf8PathBuf}; use camino_tempfile::{Utf8TempDir, tempdir}; -use candid::Principal; use icp_network::NETWORK_LOCAL; use pocket_ic::PocketIc; use serde_json::{Value, json}; diff --git a/bin/icp-cli/tests/common/context/icp_ledger.rs b/bin/icp-cli/tests/common/context/icp_ledger.rs index b1afdc4a9..69ba09785 100644 --- a/bin/icp-cli/tests/common/context/icp_ledger.rs +++ b/bin/icp-cli/tests/common/context/icp_ledger.rs @@ -1,4 +1,4 @@ -use candid::{Encode, Nat, Principal}; +use candid::{Decode, Encode, Nat, Principal}; use icrc_ledger_types::icrc1::account::Subaccount; use pocket_ic::PocketIc; use std::cell::Ref; @@ -11,6 +11,23 @@ pub struct IcpLedgerPocketIcClient<'a> { } impl<'a> IcpLedgerPocketIcClient<'a> { + pub fn balance_of(&self, owner: Principal, subaccount: Option) -> Nat { + Decode!( + &self + .pic + .query_call( + LEDGER_ID, + Principal::anonymous(), + "icrc1_balance_of", + Encode!(&icrc_ledger_types::icrc1::account::Account { owner, subaccount }) + .unwrap(), + ) + .unwrap(), + Nat + ) + .unwrap() + } + pub fn mint_icp( &self, owner: Principal, diff --git a/bin/icp-cli/tests/common/context/icp_shorthand.rs b/bin/icp-cli/tests/common/context/icp_shorthand.rs index 97df11110..eeb093cb0 100644 --- a/bin/icp-cli/tests/common/context/icp_shorthand.rs +++ b/bin/icp-cli/tests/common/context/icp_shorthand.rs @@ -38,4 +38,20 @@ impl<'a> IcpShorthand<'a> { .assert() .success(); } + + pub fn create_identity(&self, name: &str) { + self.ctx + .icp() + .args(["identity", "new", name]) + .assert() + .success(); + } + + pub fn use_identity(&self, name: &str) { + self.ctx + .icp() + .args(["identity", "default", name]) + .assert() + .success(); + } } diff --git a/bin/icp-cli/tests/token_tests.rs b/bin/icp-cli/tests/token_tests.rs index f476efe26..373ce8581 100644 --- a/bin/icp-cli/tests/token_tests.rs +++ b/bin/icp-cli/tests/token_tests.rs @@ -67,3 +67,88 @@ fn token_balance() { .stdout(contains("Balance: 1.23456789 ICP")) .success(); } + +#[test] +fn token_transfer() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm = ctx.make_asset("example_icp_mo.wasm"); + let pm = format!( + r#" + canister: + name: my-canister + build: + steps: + - type: script + command: sh -c 'cp {} "$ICP_WASM_OUTPUT_PATH"' + "#, + wasm, + ); + + write( + project_dir.join("icp.yaml"), // path + pm, // contents + ) + .expect("failed to write project manifest"); + let _g = ctx.start_network_in(&project_dir); + ctx.ping_until_healthy(&project_dir); + + ctx.icp_().create_identity("alice"); + ctx.icp_().use_identity("alice"); + let alice_principal = ctx.icp_().active_principal(); + ctx.icp_().create_identity("bob"); + ctx.icp_().use_identity("bob"); + let bob_principal = ctx.icp_().active_principal(); + + // Initial balance + ctx.icp_ledger() + .mint_icp(alice_principal, None, 1_000_000_000_u128); // 10 ICP + assert_eq!( + ctx.icp_ledger().balance_of(alice_principal, None), + 1_000_000_000_u128 + ); + assert_eq!(ctx.icp_ledger().balance_of(bob_principal, None), 0_u128); + + // Simple ICP transfer + ctx.icp_().use_identity("alice"); + ctx.icp() + .current_dir(&project_dir) + .args(["token", "transfer", &bob_principal.to_string(), "1.1"]) + .assert() + .stdout(contains("Transferred 1.1 ICP")) + .success(); + assert_eq!( + ctx.icp_ledger().balance_of(alice_principal, None), + 889_990_000_u128 + ); + assert_eq!( + ctx.icp_ledger().balance_of(bob_principal, None), + 110_000_000_u128 + ); + + // Simple cycles transfer + ctx.icp() + .current_dir(&project_dir) + .args(["cycles", "mint", "--icp-amount", "5"]) + .assert() + .success(); + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "cycles", + "transfer", + &bob_principal.to_string(), + "2", + ]) + .assert() + .stdout(contains("Transferred 2 TCYCLES")) + .success(); + ctx.icp_().use_identity("bob"); + ctx.icp() + .current_dir(&project_dir) + .args(["cycles", "balance"]) + .assert() + .stdout(contains("Balance: 2.000000000000 TCYCLES")) + .success(); +} From 2c28b30fde34e86c73cccb55469ec5a1a823bbfd Mon Sep 17 00:00:00 2001 From: Vivienne Date: Fri, 29 Aug 2025 12:57:52 +0200 Subject: [PATCH 7/7] use cycles ledger on mainnet networks --- bin/icp-cli/src/commands/canister/create.rs | 296 +++++++++++++----- bin/icp-cli/src/commands/deploy.rs | 2 + bin/icp-cli/src/commands/token/balance.rs | 3 + bin/icp-cli/tests/canister_create_tests.rs | 101 +++++- bin/icp-cli/tests/common/context.rs | 10 +- .../tests/common/context/cycles_ledger.rs | 29 ++ 6 files changed, 362 insertions(+), 79 deletions(-) create mode 100644 bin/icp-cli/tests/common/context/cycles_ledger.rs diff --git a/bin/icp-cli/src/commands/canister/create.rs b/bin/icp-cli/src/commands/canister/create.rs index 8fe246299..c56e1dfc7 100644 --- a/bin/icp-cli/src/commands/canister/create.rs +++ b/bin/icp-cli/src/commands/canister/create.rs @@ -1,6 +1,8 @@ +use candid::{CandidType, Decode, Encode, Nat}; use clap::Parser; use ic_agent::{AgentError, export::Principal}; use ic_utils::interfaces::management_canister::LogVisibility; +use serde::{Deserialize, Serialize}; use snafu::Snafu; use crate::{ @@ -72,6 +74,10 @@ pub struct Cmd { #[clap(flatten)] pub settings: CanisterSettings, + /// How many cycles the canister should be created with. Also needs to pay for canister creation cost. + #[clap(long)] + pub cycles: Option, + /// Suppress human-readable output; print only canister IDs, one per line, to stdout. #[clap(long, short = 'q')] pub quiet: bool, @@ -186,89 +192,225 @@ pub async fn exec(ctx: &Context, cmd: Cmd) -> Result<(), CommandError> { let mgmt = ic_utils::interfaces::ManagementCanister::create(agent); for (_, c) in cs { - // Create canister - let mut builder = mgmt.create_canister(); + // TODO: support detecting (non)mainnet + if network == "ic" { + #[derive(CandidType, Serialize, Clone, Debug, Default)] + pub struct CanisterSettings { + pub freezing_threshold: Option, + pub controllers: Option>, + pub reserved_cycles_limit: Option, + pub memory_allocation: Option, + pub compute_allocation: Option, + } + + #[derive(CandidType, Serialize, Clone, Debug, Default)] + pub struct SubnetFilter { + pub subnet_type: Option, + } - // Cycles amount - builder = builder.as_provisional_create_with_amount(None); + #[derive(CandidType, Serialize, Clone, Debug)] + pub enum SubnetSelection { + Filter(SubnetFilter), + Subnet { subnet: Principal }, + } - // Canister ID (effective) - builder = builder.with_effective_canister_id(cmd.ids.effective_id); + #[derive(CandidType, Serialize, Clone, Debug, Default)] + pub struct CmcCreateCanisterArgs { + pub subnet_selection: Option, + pub settings: Option, + } - // Canister ID (specific) - if let Some(id) = cmd.ids.specific_id { - builder = builder.as_provisional_create_with_specified_id(id); - } + #[derive(CandidType, Serialize, Clone, Debug, Default)] + pub struct CreateCanisterArgs { + pub from_subaccount: Option>, + pub created_at_time: Option, + pub amount: Nat, + pub creation_args: Option, + } - // Controllers - for c in &cmd.controller { - builder = builder.with_controller(c.to_owned()); - } + let create_canister_arg = CreateCanisterArgs { + from_subaccount: None, + created_at_time: None, + amount: cmd.cycles.unwrap_or(1_500_000_000_000).into(), + creation_args: Some(CmcCreateCanisterArgs { + subnet_selection: None, + settings: Some(CanisterSettings { + freezing_threshold: cmd.settings.freezing_threshold.map(|v| v.into()), + controllers: Some(cmd.controller.clone()), + reserved_cycles_limit: cmd.settings.reserved_cycles_limit.map(|v| v.into()), + memory_allocation: cmd.settings.memory_allocation.map(|v| v.into()), + compute_allocation: cmd.settings.compute_allocation.map(|v| v.into()), + }), + }), + }; + + let result_bytes = agent + .update( + &Principal::from_text("um5iw-rqaaa-aaaaq-qaaba-cai").unwrap(), + "create_canister", + ) + .with_arg(Encode!(&create_canister_arg).unwrap()) + .call_and_wait() + .await?; + + // Types for create_canister result + pub type BlockIndex = Nat; + + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] + pub struct CreateCanisterSuccess { + pub block_id: BlockIndex, + pub canister_id: Principal, + } + + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] + pub struct CreateCanisterErrorGeneric { + pub message: String, + pub error_code: Nat, + } + + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] + pub struct CreateCanisterErrorDuplicate { + pub duplicate_of: Nat, + pub canister_id: Option, + } - // Compute - builder = builder.with_optional_compute_allocation( - cmd.settings - .compute_allocation - .or(c.settings.compute_allocation), - ); - - // Memory - builder = builder.with_optional_memory_allocation( - cmd.settings - .memory_allocation - .or(c.settings.memory_allocation), - ); - - // Freezing Threshold - builder = builder.with_optional_freezing_threshold( - cmd.settings - .freezing_threshold - .or(c.settings.freezing_threshold), - ); - - // Reserved Cycles (limit) - builder = builder.with_optional_reserved_cycles_limit( - cmd.settings - .reserved_cycles_limit - .or(c.settings.reserved_cycles_limit), - ); - - // Wasm (memory limit) - builder = builder.with_optional_wasm_memory_limit( - cmd.settings - .wasm_memory_limit - .or(c.settings.wasm_memory_limit), - ); - - // Wasm (memory threshold) - builder = builder.with_optional_wasm_memory_threshold( - cmd.settings - .wasm_memory_threshold - .or(c.settings.wasm_memory_threshold), - ); - - // Logs - builder = builder.with_optional_log_visibility( - Some(LogVisibility::Public), // - ); - - // Create the canister - let (cid,) = builder.await?; - - // Create canister-network association-key - let k = Key { - network: network.to_owned(), - environment: env.name.to_owned(), - canister: c.name.to_owned(), - }; - - // Register the canister ID - ctx.id_store.register(&k, &cid)?; - - if cmd.quiet { - println!("{}", cid); + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] + pub struct CreateCanisterErrorCreatedInFuture { + pub ledger_time: u64, + } + + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] + pub struct CreateCanisterErrorFailedToCreate { + pub error: String, + pub refund_block: Option, + pub fee_block: Option, + } + + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] + pub struct CreateCanisterErrorInsufficientFunds { + pub balance: Nat, + } + + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] + pub enum CreateCanisterError { + GenericError(CreateCanisterErrorGeneric), + TemporarilyUnavailable, + Duplicate(CreateCanisterErrorDuplicate), + CreatedInFuture(CreateCanisterErrorCreatedInFuture), + FailedToCreate(CreateCanisterErrorFailedToCreate), + TooOld, + InsufficientFunds(CreateCanisterErrorInsufficientFunds), + } + + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] + pub enum CreateCanisterResult { + Ok(CreateCanisterSuccess), + Err(CreateCanisterError), + } + + let result = Decode!(&result_bytes, CreateCanisterResult) + .expect("The cycles ledger's create_canister method returned an invalid result"); + + match result { + CreateCanisterResult::Ok(success) => { + if cmd.quiet { + println!("{}", success.canister_id); + } else { + eprintln!( + "Created canister '{}' with ID: '{}'", + c.name, success.canister_id + ); + } + } + CreateCanisterResult::Err(error) => { + println!("Error creating canister: {:?}", error); + } + } } else { - eprintln!("Created canister '{}' with ID: '{}'", c.name, cid); + // Create canister + let mut builder = mgmt.create_canister(); + + // Cycles amount + builder = builder.as_provisional_create_with_amount(cmd.cycles); + + // Canister ID (effective) + builder = builder.with_effective_canister_id(cmd.ids.effective_id); + + // Canister ID (specific) + if let Some(id) = cmd.ids.specific_id { + builder = builder.as_provisional_create_with_specified_id(id); + } + + // Controllers + for c in &cmd.controller { + builder = builder.with_controller(c.to_owned()); + } + + // Compute + builder = builder.with_optional_compute_allocation( + cmd.settings + .compute_allocation + .or(c.settings.compute_allocation), + ); + + // Memory + builder = builder.with_optional_memory_allocation( + cmd.settings + .memory_allocation + .or(c.settings.memory_allocation), + ); + + // Freezing Threshold + builder = builder.with_optional_freezing_threshold( + cmd.settings + .freezing_threshold + .or(c.settings.freezing_threshold), + ); + + // Reserved Cycles (limit) + builder = builder.with_optional_reserved_cycles_limit( + cmd.settings + .reserved_cycles_limit + .or(c.settings.reserved_cycles_limit), + ); + + // Wasm (memory limit) + builder = builder.with_optional_wasm_memory_limit( + cmd.settings + .wasm_memory_limit + .or(c.settings.wasm_memory_limit), + ); + + // Wasm (memory threshold) + builder = builder.with_optional_wasm_memory_threshold( + cmd.settings + .wasm_memory_threshold + .or(c.settings.wasm_memory_threshold), + ); + + // Logs + builder = builder.with_optional_log_visibility( + Some(LogVisibility::Public), // + ); + + // Create the canister + let (cid,) = builder.await?; + + // Create canister-network association-key + let k = Key { + network: network.to_owned(), + environment: env.name.to_owned(), + canister: c.name.to_owned(), + }; + + // Register the canister ID + ctx.id_store.register(&k, &cid)?; + + if cmd.quiet { + println!("{}", cid); + } else { + eprintln!("Created canister '{}' with ID: '{}'", c.name, cid); + } } } diff --git a/bin/icp-cli/src/commands/deploy.rs b/bin/icp-cli/src/commands/deploy.rs index 7314070d1..bad4b04ca 100644 --- a/bin/icp-cli/src/commands/deploy.rs +++ b/bin/icp-cli/src/commands/deploy.rs @@ -109,6 +109,8 @@ pub async fn exec(ctx: &Context, cmd: Cmd) -> Result<(), CommandError> { ..Default::default() }, + cycles: None, + quiet: false, }, ) diff --git a/bin/icp-cli/src/commands/token/balance.rs b/bin/icp-cli/src/commands/token/balance.rs index dc0285cea..b79a34610 100644 --- a/bin/icp-cli/src/commands/token/balance.rs +++ b/bin/icp-cli/src/commands/token/balance.rs @@ -31,6 +31,8 @@ pub async fn exec( // Load the project manifest, which defines the canisters to be built. let pm = ctx.project()?; + println!("pm.networks: {:?}", pm.networks); + println!("pm.environments: {:?}", pm.environments); // Load target environment let env = pm @@ -50,6 +52,7 @@ pub async fn exec( // Setup network ctx.require_network(network); + println!("required network: {:?}", network); // Prepare agent let agent = ctx.agent()?; diff --git a/bin/icp-cli/tests/canister_create_tests.rs b/bin/icp-cli/tests/canister_create_tests.rs index 88c4ae402..52a6dfa93 100644 --- a/bin/icp-cli/tests/canister_create_tests.rs +++ b/bin/icp-cli/tests/canister_create_tests.rs @@ -1,4 +1,4 @@ -use crate::common::TestContext; +use crate::common::{TestContext, TestNetwork}; use camino_tempfile::NamedUtf8TempFile; use icp_fs::fs::write; use predicates::{ @@ -189,3 +189,102 @@ fn canister_create_with_settings_cmdline_override() { .and(contains("Compute allocation: 20")), ); } + +#[test] +#[serial] +fn canister_create_via_cycles_ledger() { + let ctx = TestContext::new().with_dfx(); + + // Setup project + let project_dir = ctx.create_project_dir("icp"); + ctx.configure_icp_local_network_random_port(&project_dir); + let _g = ctx.start_network_in(&project_dir); + let TestNetwork { + gateway_port, + root_key, + .. + } = ctx.wait_for_local_network_descriptor(&project_dir); + + // Project manifest + let pm = format!( + r#" + canister: + name: my-canister + build: + steps: + - type: script + command: echo hi + + networks: + - name: ic + mode: connected + url: http://localhost:{gateway_port} + root-key: "{root_key}" + + environments: + - name: ic + network: ic + "# + ); + + write( + project_dir.join("icp.yaml"), // path + &pm, // contents + ) + .expect("failed to write project manifest"); + + println!("icp.yaml: {}", pm); + + // Wait for network + ctx.ping_until_healthy(&project_dir); + + ctx.icp_().use_new_random_identity(); + let principal = ctx.icp_().active_principal(); + ctx.icp_ledger() + .mint_icp(principal, None, 10_000_000_000_000_u64); + assert_eq!( + ctx.icp_ledger().balance_of(principal, None), + 10_000_000_000_000_u64 + ); + ctx.icp() + .current_dir(&project_dir) + .args(["token", "balance", "--ic"]) + .assert() + .stdout(contains("10000000000000")) + .success(); + ctx.icp() + .current_dir(&project_dir) + .args(["identity", "principal"]) + .assert() + .stdout(contains(principal.to_string())) + .success(); + ctx.icp() + .current_dir(&project_dir) + .args([ + "cycles", + "mint", + "--cycles-amount", + "2000000000000", /* 2T cycles */ + "--ic", + ]) + .assert() + .success(); + let balance_before = ctx.cycles_ledger().balance_of(principal, None); + + // Create canister + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "create", + "--effective-id", + "ghsi2-tqaaa-aaaan-aaaca-cai", + "--cycles", + "1000000000000", + "--ic", + ]) + .assert() + .success(); + let balance_after = ctx.cycles_ledger().balance_of(principal, None); + assert!(balance_after < balance_before); +} diff --git a/bin/icp-cli/tests/common/context.rs b/bin/icp-cli/tests/common/context.rs index 74e67592a..a425cabda 100644 --- a/bin/icp-cli/tests/common/context.rs +++ b/bin/icp-cli/tests/common/context.rs @@ -16,9 +16,10 @@ use url::Url; use crate::common::context::icp_shorthand::IcpShorthand; use crate::common::{ ChildGuard, PATH_SEPARATOR, TestNetwork, TestNetworkForDfx, - context::icp_ledger::IcpLedgerPocketIcClient, + context::{cycles_ledger::CyclesLedgerPocketIcClient, icp_ledger::IcpLedgerPocketIcClient}, }; +mod cycles_ledger; mod icp_ledger; mod icp_shorthand; @@ -345,4 +346,11 @@ impl TestContext { }); IcpLedgerPocketIcClient { pic: pic_ref } } + + pub fn cycles_ledger(&self) -> CyclesLedgerPocketIcClient<'_> { + let pic_ref: Ref<'_, PocketIc> = Ref::map(self.pocketic.borrow(), |opt| { + opt.as_ref().expect("pocketic not started") + }); + CyclesLedgerPocketIcClient { pic: pic_ref } + } } diff --git a/bin/icp-cli/tests/common/context/cycles_ledger.rs b/bin/icp-cli/tests/common/context/cycles_ledger.rs new file mode 100644 index 000000000..3a4ccf841 --- /dev/null +++ b/bin/icp-cli/tests/common/context/cycles_ledger.rs @@ -0,0 +1,29 @@ +use candid::{Decode, Encode, Nat, Principal}; +use icrc_ledger_types::icrc1::account::Subaccount; +use pocket_ic::PocketIc; +use std::cell::Ref; + +const CYCLES_LEDGER_ID: Principal = Principal::from_slice(&[0, 0, 0, 0, 2, 16, 0, 2, 1, 1]); // um5iw-rqaaa-aaaaq-qaaba-cai + +pub struct CyclesLedgerPocketIcClient<'a> { + pub pic: Ref<'a, PocketIc>, +} + +impl<'a> CyclesLedgerPocketIcClient<'a> { + pub fn balance_of(&self, owner: Principal, subaccount: Option) -> Nat { + Decode!( + &self + .pic + .query_call( + CYCLES_LEDGER_ID, + Principal::anonymous(), + "icrc1_balance_of", + Encode!(&icrc_ledger_types::icrc1::account::Account { owner, subaccount }) + .unwrap(), + ) + .unwrap(), + Nat + ) + .unwrap() + } +}