diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5ee1e00..2c39227cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ bump. Currently experimental: project bundling # Unreleased +* feat: `icp token [TOKEN|LEDGER_ID] approve ` grants an ICRC-2 allowance, letting a spender transfer tokens on your behalf. Supports `--from-subaccount` (the account debited for the allowance), `--spender-subaccount`, and an optional `--expires-in ` (e.g. `24h`, `30d`) to auto-expire the allowance. +* feat: `icp token [TOKEN|LEDGER_ID] allowance ` displays the ICRC-2 allowance granted to a spender. Supports `--subaccount`, `--spender-subaccount`, and `--of-principal` to inspect any account. * feat: `icp canister delete` will now send the canister's remaining cycles to the caller # v1.0.2 diff --git a/crates/icp-cli/src/commands/token/allowance.rs b/crates/icp-cli/src/commands/token/allowance.rs new file mode 100644 index 000000000..13dadce90 --- /dev/null +++ b/crates/icp-cli/src/commands/token/allowance.rs @@ -0,0 +1,110 @@ +use std::io::stdout; + +use candid::Principal; +use clap::Args; +use icp::context::Context; +use icrc_ledger_types::icrc1::account::Account; +use serde::Serialize; + +use crate::commands::args::TokenCommandArgs; +use crate::commands::parsers::parse_subaccount; +use crate::commands::token::format_expiry; +use crate::operations::token::allowance::get_allowance; + +/// Display the allowance granted to a spender (ICRC-2) (default token: icp) +/// +/// This is a read-only query that works for any owner/spender pair, including +/// accounts you do not control (use `--of-principal` to set the owner). The amount +/// is shown in whole tokens, along with an expiry if one was set. Works with any +/// ICRC-2 ledger, referenced by a known token name or a ledger canister id. +#[derive(Args, Debug)] +#[command(override_usage = "icp token [TOKEN|LEDGER_ID] allowance [OPTIONS] ")] +pub(crate) struct AllowanceArgs { + /// Principal of the spender whose allowance to look up. + pub(crate) spender: Principal, + + /// The spender's subaccount, as a hex string (32 bytes, left-padded). + /// Defaults to the default subaccount. + #[arg(long, value_parser = parse_subaccount)] + pub(crate) spender_subaccount: Option<[u8; 32]>, + + /// The owner's subaccount that granted the allowance, as a hex string + /// (32 bytes, left-padded). Defaults to the default subaccount. + #[arg(long, value_parser = parse_subaccount)] + pub(crate) subaccount: Option<[u8; 32]>, + + /// The allowance owner to look up, instead of the current identity. + /// Lets you inspect allowances granted by any principal. + #[arg(long)] + pub(crate) of_principal: Option, + + #[command(flatten)] + pub(crate) token_command_args: TokenCommandArgs, + + /// Output command results as JSON + #[arg(long, conflicts_with = "quiet")] + pub(crate) json: bool, + + /// Suppress human-readable output; print only the allowance amount + #[arg(long, short)] + pub(crate) quiet: bool, +} + +/// Display the allowance an owner account has granted to a spender +/// +/// The allowance is queried against an ICRC-2 compatible ledger canister. Supports +/// two user flows: +/// (1) Specifying token name, and checking against known or stored mappings +/// (2) Specifying compatible ledger canister id +pub(crate) async fn exec( + ctx: &Context, + token: &str, + args: &AllowanceArgs, +) -> Result<(), anyhow::Error> { + let selections = args.token_command_args.selections(); + + // Agent + let agent = ctx + .get_agent( + &selections.identity, + &selections.network, + &selections.environment, + ) + .await?; + let owner = args + .of_principal + .unwrap_or_else(|| agent.get_principal().unwrap()); + + let spender = Account { + owner: args.spender, + subaccount: args.spender_subaccount, + }; + + // Query the allowance from the ledger + let info = get_allowance(&agent, token, owner, args.subaccount, spender).await?; + + if args.json { + serde_json::to_writer( + stdout(), + &JsonAllowance { + allowance: info.allowance.to_string(), + expires_at: info.expires_at, + }, + )?; + } else if args.quiet { + println!("{}", info.allowance); + } else { + println!("Allowance: {}", info.allowance); + if let Some(expires_at) = info.expires_at { + println!("Expires at: {}", format_expiry(expires_at)); + } + } + + Ok(()) +} + +#[derive(Serialize)] +struct JsonAllowance { + allowance: String, + expires_at: Option, +} diff --git a/crates/icp-cli/src/commands/token/approve.rs b/crates/icp-cli/src/commands/token/approve.rs new file mode 100644 index 000000000..72a4fddb3 --- /dev/null +++ b/crates/icp-cli/src/commands/token/approve.rs @@ -0,0 +1,139 @@ +use std::io::stdout; + +use anyhow::Context as _; +use bigdecimal::BigDecimal; +use candid::Principal; +use clap::Args; +use icp::context::Context; +use icp::parsers::{DurationAmount, parse_token_amount}; +use icrc_ledger_types::icrc1::account::Account; +use serde::Serialize; +use time::OffsetDateTime; + +use crate::commands::args::TokenCommandArgs; +use crate::commands::parsers::parse_subaccount; +use crate::commands::token::format_expiry; +use crate::operations::token::approve::approve; + +/// Approve a spender to transfer tokens on your behalf (ICRC-2) (default token: icp) +/// +/// Sets the spender's allowance to the given amount, overwriting any existing +/// allowance (this is a set, not an increment). The allowance is granted from the +/// calling identity's account, which is charged the ledger's approval fee, and can +/// optionally be given an expiry with `--expires-in`. Works with any ICRC-2 ledger, +/// referenced by a known token name or a ledger canister id. +#[derive(Debug, Args)] +#[command(override_usage = "icp token [TOKEN|LEDGER_ID] approve [OPTIONS] ")] +pub(crate) struct ApproveArgs { + /// The allowance amount, in whole tokens (e.g. `1.5`), the spender may transfer. + /// Supports suffixes: k (thousand), m (million), b (billion), t (trillion). + #[arg(value_parser = parse_token_amount)] + pub(crate) amount: BigDecimal, + + /// Principal of the spender being granted the allowance. + pub(crate) spender: Principal, + + /// The spender's subaccount, as a hex string (32 bytes, left-padded). + /// Defaults to the default subaccount. + #[arg(long, value_parser = parse_subaccount)] + pub(crate) spender_subaccount: Option<[u8; 32]>, + + /// The caller's subaccount to grant the allowance from (the account debited), + /// as a hex string (32 bytes, left-padded). Defaults to the default subaccount. + #[arg(long, value_parser = parse_subaccount)] + pub(crate) from_subaccount: Option<[u8; 32]>, + + /// Expire the allowance after this duration from now, e.g. `24h`, `30d` + /// (suffixes: s, m, h, d, w; a bare number is seconds). Never expires if omitted. + #[arg(long, value_name = "DURATION")] + pub(crate) expires_in: Option, + + #[command(flatten)] + pub(crate) token_command_args: TokenCommandArgs, + + /// Output command results as JSON + #[arg(long, conflicts_with = "quiet")] + pub(crate) json: bool, + + /// Suppress human-readable output; print only the block index + #[arg(long, short)] + pub(crate) quiet: bool, +} + +/// Approve a spender to transfer tokens on the current identity's behalf +/// +/// The allowance is set against an ICRC-2 compatible ledger canister. Supports two +/// user flows: +/// (1) Specifying token name, and checking against known or stored mappings +/// (2) Specifying compatible ledger canister id +pub(crate) async fn exec( + ctx: &Context, + token: &str, + args: &ApproveArgs, +) -> Result<(), anyhow::Error> { + let selections = args.token_command_args.selections(); + + // Agent + let agent = ctx + .get_agent( + &selections.identity, + &selections.network, + &selections.environment, + ) + .await?; + + let spender = Account { + owner: args.spender, + subaccount: args.spender_subaccount, + }; + + // Resolve the relative expiry to an absolute timestamp (nanoseconds since epoch) + let expires_at = args + .expires_in + .as_ref() + .map(|duration| { + let now_nanos = OffsetDateTime::now_utc().unix_timestamp_nanos(); + let duration_nanos = i128::from(duration.get()) * 1_000_000_000; + u64::try_from(now_nanos + duration_nanos).context("expiry is too far in the future") + }) + .transpose()?; + + // Execute approve + let info = approve( + &agent, + token, + &args.amount, + args.from_subaccount, + spender, + expires_at, + ) + .await?; + + if args.json { + serde_json::to_writer( + stdout(), + &JsonApprove { + block_index: info.block_index.to_string(), + expires_at, + }, + )?; + } else if args.quiet { + println!("{}", info.block_index); + } else { + println!( + "Approved {} to spend up to {} (block {})", + info.spender_display, info.allowance, info.block_index + ); + if let Some(expires_at) = expires_at { + println!("Expires at: {}", format_expiry(expires_at)); + } + } + + Ok(()) +} + +#[derive(Serialize)] +struct JsonApprove { + block_index: String, + expires_at: Option, +} diff --git a/crates/icp-cli/src/commands/token/mod.rs b/crates/icp-cli/src/commands/token/mod.rs index ecc069d8b..5e88e2d61 100644 --- a/crates/icp-cli/src/commands/token/mod.rs +++ b/crates/icp-cli/src/commands/token/mod.rs @@ -1,8 +1,21 @@ use clap::{Parser, Subcommand}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +pub(crate) mod allowance; +pub(crate) mod approve; pub(crate) mod balance; pub(crate) mod transfer; +/// Format an ICRC-2 expiry (nanoseconds since the Unix epoch) as a human-readable +/// RFC 3339 timestamp, falling back to the raw nanoseconds if it cannot be rendered. +pub(crate) fn format_expiry(expires_at_nanos: u64) -> String { + OffsetDateTime::from_unix_timestamp_nanos(expires_at_nanos as i128) + .ok() + .and_then(|dt| dt.format(&Rfc3339).ok()) + .unwrap_or_else(|| expires_at_nanos.to_string()) +} + /// Perform token transactions #[derive(Debug, Parser)] pub(crate) struct Command { @@ -18,4 +31,6 @@ pub(crate) struct Command { pub(crate) enum Commands { Balance(balance::BalanceArgs), Transfer(transfer::TransferArgs), + Approve(approve::ApproveArgs), + Allowance(allowance::AllowanceArgs), } diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 5458b8381..efee0ba1e 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -443,6 +443,14 @@ async fn dispatch(ctx: &icp::context::Context, command: Command) -> Result<(), E commands::token::Commands::Transfer(args) => { commands::token::transfer::exec(ctx, &cmd.token_name_or_ledger_id, &args).await? } + + commands::token::Commands::Approve(args) => { + commands::token::approve::exec(ctx, &cmd.token_name_or_ledger_id, &args).await? + } + + commands::token::Commands::Allowance(args) => { + commands::token::allowance::exec(ctx, &cmd.token_name_or_ledger_id, &args).await? + } }, } diff --git a/crates/icp-cli/src/operations/token/allowance.rs b/crates/icp-cli/src/operations/token/allowance.rs new file mode 100644 index 000000000..31c34000a --- /dev/null +++ b/crates/icp-cli/src/operations/token/allowance.rs @@ -0,0 +1,141 @@ +use bigdecimal::BigDecimal; +use candid::{Decode, Encode, Nat, Principal}; +use ic_agent::{Agent, AgentError}; +use icrc_ledger_types::icrc1::account::Account; +use icrc_ledger_types::icrc2::allowance::{Allowance, AllowanceArgs}; +use snafu::{ResultExt, Snafu}; + +use super::{TOKEN_LEDGER_CIDS, TokenAmount}; + +#[derive(Debug, Snafu)] +pub enum GetAllowanceError { + #[snafu(display("failed to parse canister id '{canister_id}': {source}"))] + ParseCanisterId { + canister_id: String, + source: candid::types::principal::PrincipalError, + }, + + #[snafu(display("failed to query decimals"))] + QueryDecimals { source: AgentError }, + + #[snafu(display("failed to query symbol"))] + QuerySymbol { source: AgentError }, + + #[snafu(display("failed to query allowance"))] + QueryAllowance { source: AgentError }, + + #[snafu(display("failed to decode decimals response"))] + DecodeDecimals { source: candid::Error }, + + #[snafu(display("failed to decode symbol response"))] + DecodeSymbol { source: candid::Error }, + + #[snafu(display("failed to decode allowance response"))] + DecodeAllowance { source: candid::Error }, +} + +pub struct AllowanceInfo { + pub allowance: TokenAmount, + pub expires_at: Option, +} + +/// Get the allowance an owner account has granted to a spender (ICRC-2 `icrc2_allowance`). +/// +/// The token parameter supports two flows: +/// 1. Specifying a known token name (e.g., "icp") which will be looked up +/// 2. Specifying a canister ID directly for any ICRC-2 compatible ledger +/// +/// # Arguments +/// +/// * `agent` - The IC agent to use for queries +/// * `token` - The token name or ledger canister id +/// * `owner` - The principal that granted the allowance +/// * `subaccount` - The owner's subaccount that granted the allowance +/// * `spender` - The account the allowance was granted to +/// +/// # Returns +/// +/// An `AllowanceInfo` struct containing the allowance amount and optional expiry +pub async fn get_allowance( + agent: &Agent, + token: &str, + owner: Principal, + subaccount: Option<[u8; 32]>, + spender: Account, +) -> Result { + // Obtain token info + let canister_id = match TOKEN_LEDGER_CIDS.get(token) { + // Given token matched known token names + Some(cid) => cid.to_string(), + + // Given token is not known, indicating it's either already a canister id + // or is simply a name of a token we do not know of + None => token.to_string(), + }; + + // Parse the canister id + let cid = Principal::from_text(&canister_id).context(ParseCanisterIdSnafu { + canister_id: canister_id.to_string(), + })?; + + // Perform the required ledger calls + let (allowance, decimals, symbol) = tokio::join!( + // + // Obtain the allowance granted to the spender + async { + let arg = AllowanceArgs { + account: Account { owner, subaccount }, + spender, + }; + + // Perform query + let resp = agent + .query(&cid, "icrc2_allowance") + .with_arg(Encode!(&arg).expect("failed to encode arg")) + .await + .context(QueryAllowanceSnafu)?; + + // Decode response + Decode!(&resp, Allowance).context(DecodeAllowanceSnafu) + }, + // + // Obtain the number of decimals the token uses + async { + // Perform query + let resp = agent + .query(&cid, "icrc1_decimals") + .with_arg(Encode!(&()).expect("failed to encode arg")) + .await + .context(QueryDecimalsSnafu)?; + + // Decode response + Decode!(&resp, u8).context(DecodeDecimalsSnafu) + }, + // + // Obtain the symbol of the token + async { + // Perform query + let resp = agent + .query(&cid, "icrc1_symbol") + .with_arg(Encode!(&()).expect("failed to encode arg")) + .await + .context(QuerySymbolSnafu)?; + + // Decode response + Decode!(&resp, String).context(DecodeSymbolSnafu) + }, + ); + + // Check for errors + let (allowance, decimals, symbol) = (allowance?, decimals? as i64, symbol?); + + let Nat(raw_allowance) = allowance.allowance; + + Ok(AllowanceInfo { + allowance: TokenAmount { + amount: BigDecimal::from_biguint(raw_allowance, decimals), + symbol, + }, + expires_at: allowance.expires_at, + }) +} diff --git a/crates/icp-cli/src/operations/token/approve.rs b/crates/icp-cli/src/operations/token/approve.rs new file mode 100644 index 000000000..58e1d4fc3 --- /dev/null +++ b/crates/icp-cli/src/operations/token/approve.rs @@ -0,0 +1,177 @@ +use bigdecimal::BigDecimal; +use candid::{Decode, Encode, Nat, Principal}; +use ic_agent::{Agent, AgentError}; +use icp::parsers::to_token_unit_amount; +use icrc_ledger_types::icrc1::account::Account; +use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError as Icrc2ApproveError}; +use snafu::{ResultExt, Snafu}; + +use super::{TOKEN_LEDGER_CIDS, TokenAmount}; + +#[derive(Debug, Snafu)] +pub enum TokenApproveError { + #[snafu(display("failed to parse canister id '{canister_id}': {source}"))] + ParseCanisterId { + canister_id: String, + source: candid::types::principal::PrincipalError, + }, + + #[snafu(display("failed to query decimals"))] + QueryDecimals { source: AgentError }, + + #[snafu(display("failed to query symbol"))] + QuerySymbol { source: AgentError }, + + #[snafu(display("failed to decode decimals response"))] + DecodeDecimals { source: candid::Error }, + + #[snafu(display("failed to decode symbol response"))] + DecodeSymbol { source: candid::Error }, + + #[snafu(display("invalid amount: {message}"))] + InvalidAmount { message: String }, + + #[snafu(display("failed to encode approve argument"))] + EncodeApproveArg { source: candid::Error }, + + #[snafu(display("failed to execute approve"))] + ExecuteApprove { source: AgentError }, + + #[snafu(display("failed to decode approve response"))] + DecodeApproveResponse { source: candid::Error }, + + #[snafu(display("approve failed: {message}"))] + ApproveFailed { message: String }, +} + +pub struct ApproveInfo { + pub block_index: Nat, + pub allowance: TokenAmount, + pub spender_display: String, +} + +/// Approve a spender to transfer tokens on the caller's behalf (ICRC-2 `icrc2_approve`). +/// +/// This sets the spender's allowance to `amount`, overwriting any existing allowance. +/// The approval fee is charged to the caller's account (optionally scoped to +/// `from_subaccount`); the ledger's default fee is used. +/// +/// The token parameter supports two flows: +/// 1. Specifying a known token name (e.g., "icp") which will be looked up +/// 2. Specifying a canister ID directly for any ICRC-2 compatible ledger +/// +/// # Arguments +/// +/// * `agent` - The IC agent to use for queries and the update call +/// * `token` - The token name or ledger canister id +/// * `amount` - The decimal allowance amount to grant +/// * `from_subaccount` - The caller's subaccount to grant the allowance from +/// * `spender` - The account being granted the allowance +/// * `expires_at` - Optional absolute expiry, in nanoseconds since the Unix epoch +/// +/// # Returns +/// +/// An `ApproveInfo` struct containing the block index and the granted allowance +pub async fn approve( + agent: &Agent, + token: &str, + amount: &BigDecimal, + from_subaccount: Option<[u8; 32]>, + spender: Account, + expires_at: Option, +) -> Result { + // Obtain token info + let canister_id = match TOKEN_LEDGER_CIDS.get(token) { + // Given token matched known token names + Some(cid) => cid.to_string(), + + // Given token is not known, indicating it's either already a canister id + // or is simply a name of a token we do not know of + None => token.to_string(), + }; + + // Parse the canister id + let cid = Principal::from_text(&canister_id).context(ParseCanisterIdSnafu { + canister_id: canister_id.to_string(), + })?; + + // Perform the required ledger calls + let (decimals, symbol) = tokio::join!( + // + // Obtain the number of decimals the token uses + async { + // Perform query + let resp = agent + .query(&cid, "icrc1_decimals") + .with_arg(Encode!(&()).expect("failed to encode arg")) + .await + .context(QueryDecimalsSnafu)?; + + // Decode response + Decode!(&resp, u8).context(DecodeDecimalsSnafu) + }, + // + // Obtain the symbol of the token + async { + // Perform query + let resp = agent + .query(&cid, "icrc1_symbol") + .with_arg(Encode!(&()).expect("failed to encode arg")) + .await + .context(QuerySymbolSnafu)?; + + // Decode response + Decode!(&resp, String).context(DecodeSymbolSnafu) + }, + ); + + // Check for errors + let (decimals, symbol) = (decimals?, symbol?); + + // Convert the decimal amount to the ledger's smallest unit. This validates that + // the amount is exactly representable at the token's precision (rather than + // silently truncating) and uses arbitrary-precision math to avoid overflow. + let ledger_amount = to_token_unit_amount(amount.clone(), decimals) + .map(Nat::from) + .map_err(|message| TokenApproveError::InvalidAmount { message })?; + + // Capture the spender display before the value is moved into the argument + let spender_display = spender.to_string(); + + let arg = ApproveArgs { + from_subaccount, + spender, + amount: ledger_amount.clone(), + expected_allowance: None, + expires_at, + fee: None, + memo: None, + created_at_time: None, + }; + + // Perform approve + let resp = agent + .update(&cid, "icrc2_approve") + .with_arg(Encode!(&arg).context(EncodeApproveArgSnafu)?) + .call_and_wait() + .await + .context(ExecuteApproveSnafu)?; + + // Parse response + let resp = + Decode!(&resp, Result).context(DecodeApproveResponseSnafu)?; + + // Process response + let block_index = resp.map_err(|err| TokenApproveError::ApproveFailed { + message: err.to_string(), + })?; + + Ok(ApproveInfo { + block_index, + allowance: TokenAmount { + amount: BigDecimal::from_biguint(ledger_amount.0, decimals as i64), + symbol, + }, + spender_display, + }) +} diff --git a/crates/icp-cli/src/operations/token/mod.rs b/crates/icp-cli/src/operations/token/mod.rs index 44e158ea0..550fac7c3 100644 --- a/crates/icp-cli/src/operations/token/mod.rs +++ b/crates/icp-cli/src/operations/token/mod.rs @@ -5,6 +5,8 @@ use num_bigint::ToBigInt; use phf::phf_map; use std::fmt; +pub(crate) mod allowance; +pub(crate) mod approve; pub(crate) mod balance; pub(crate) mod mint; pub(crate) mod transfer; diff --git a/crates/icp-cli/tests/common/clients/ledger.rs b/crates/icp-cli/tests/common/clients/ledger.rs index 5d214f0f5..073a8c20c 100644 --- a/crates/icp-cli/tests/common/clients/ledger.rs +++ b/crates/icp-cli/tests/common/clients/ledger.rs @@ -5,6 +5,7 @@ use icrc_ledger_types::icrc1::{ account::{Account, Subaccount}, transfer::TransferArg, }; +use icrc_ledger_types::icrc2::allowance::{Allowance, AllowanceArgs}; use crate::common::TestContext; @@ -29,6 +30,33 @@ impl Client { Decode!(result, Nat).unwrap() } + pub(crate) async fn allowance_of( + &self, + owner: Principal, + owner_subaccount: Option, + spender: Principal, + spender_subaccount: Option, + ) -> Allowance { + let arg = AllowanceArgs { + account: Account { + owner, + subaccount: owner_subaccount, + }, + spender: Account { + owner: spender, + subaccount: spender_subaccount, + }, + }; + let bytes = Encode!(&arg).unwrap(); + let result = &self + .agent + .query(&ICP_LEDGER_PRINCIPAL, "icrc2_allowance") + .with_arg(bytes) + .await + .unwrap(); + Decode!(result, Allowance).unwrap() + } + pub(crate) async fn acquire_icp( &self, owner: Principal, diff --git a/crates/icp-cli/tests/token_tests.rs b/crates/icp-cli/tests/token_tests.rs index 0dbb4fac8..bdc0c8eb1 100644 --- a/crates/icp-cli/tests/token_tests.rs +++ b/crates/icp-cli/tests/token_tests.rs @@ -191,6 +191,382 @@ async fn token_transfer_to_account_identifier() { ); } +#[tokio::test] +async fn token_approve_and_allowance() { + 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(); + + // Fund alice so she can pay the approval fee + let icp_ledger = clients::ledger(&ctx); + icp_ledger + .acquire_icp(alice_principal, None, 1_000_000_000_u128) + .await; // 10 ICP + + // No allowance granted yet + icp_client.use_identity("alice"); + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "allowance", + &bob_principal.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .stdout(contains("Allowance: 0 ICP")) + .success(); + + // Alice approves bob to spend 5 ICP + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "approve", + "5", + &bob_principal.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .stdout(contains(format!( + "Approved {bob_principal} to spend up to 5.00000000 ICP" + ))) + .success(); + + // The ledger records the allowance + let allowance = icp_ledger + .allowance_of(alice_principal, None, bob_principal, None) + .await; + assert_eq!(allowance.allowance, 500_000_000_u128); + + // And the CLI reports it back + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "allowance", + &bob_principal.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .stdout(contains("Allowance: 5.00000000 ICP")) + .success(); + + // A third party (bob) can read the allowance alice granted, via --of-principal + icp_client.use_identity("bob"); + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "allowance", + &bob_principal.to_string(), + "--of-principal", + &alice_principal.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .stdout(contains("Allowance: 5.00000000 ICP")) + .success(); + + // --json emits the machine-readable allowance + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "allowance", + &bob_principal.to_string(), + "--of-principal", + &alice_principal.to_string(), + "--json", + "--environment", + "random-environment", + ]) + .assert() + .stdout(contains("\"allowance\":\"5.00000000 ICP\"")) + .success(); + + // An amount too precise for the token's decimals (ICP has 8) is rejected up + // front rather than silently truncated + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "approve", + "0.000000001", // 9 decimal places + &bob_principal.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .stderr(contains("cannot be represented")) + .failure(); + + // Re-approving overwrites (not adds to) the existing allowance, and --quiet + // prints only the block index + icp_client.use_identity("alice"); + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "approve", + "2", + &bob_principal.to_string(), + "--quiet", + "--environment", + "random-environment", + ]) + .assert() + // A bare block index, none of the human-readable prose + .stdout(predicates::str::is_match(r"^\d+\n$").unwrap()) + .success(); + let allowance = icp_ledger + .allowance_of(alice_principal, None, bob_principal, None) + .await; + assert_eq!(allowance.allowance, 200_000_000_u128); +} + +#[tokio::test] +async fn token_approve_with_subaccounts() { + 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(); + + // Subaccount 1 (owner) and subaccount 2 (spender) + let from_subaccount: [u8; 32] = { + let mut s = [0u8; 32]; + s[31] = 1; + s + }; + let spender_subaccount: [u8; 32] = { + let mut s = [0u8; 32]; + s[31] = 2; + s + }; + let from_subaccount_hex = hex::encode(from_subaccount); + let spender_subaccount_hex = hex::encode(spender_subaccount); + + // Fund alice's subaccount 1 so it can pay the approval fee + let icp_ledger = clients::ledger(&ctx); + icp_ledger + .acquire_icp(alice_principal, Some(from_subaccount), 1_000_000_000_u128) + .await; // 10 ICP + + // Approve bob's subaccount 2 to spend 3 ICP from alice's subaccount 1 + icp_client.use_identity("alice"); + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "approve", + "3", + &bob_principal.to_string(), + "--spender-subaccount", + &spender_subaccount_hex, + "--from-subaccount", + &from_subaccount_hex, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // The allowance is recorded against the specific (sub)accounts + let allowance = icp_ledger + .allowance_of( + alice_principal, + Some(from_subaccount), + bob_principal, + Some(spender_subaccount), + ) + .await; + assert_eq!(allowance.allowance, 300_000_000_u128); + + // The default accounts have no allowance + let default_allowance = icp_ledger + .allowance_of(alice_principal, None, bob_principal, None) + .await; + assert_eq!(default_allowance.allowance, 0_u128); + + // The CLI reports the allowance when the matching subaccounts are provided + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "allowance", + &bob_principal.to_string(), + "--spender-subaccount", + &spender_subaccount_hex, + "--subaccount", + &from_subaccount_hex, + "--environment", + "random-environment", + ]) + .assert() + .stdout(contains("Allowance: 3.00000000 ICP")) + .success(); +} + +#[tokio::test] +async fn token_approve_with_expiry() { + 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(); + + // Fund alice so she can pay the approval fee + let icp_ledger = clients::ledger(&ctx); + icp_ledger + .acquire_icp(alice_principal, None, 1_000_000_000_u128) + .await; // 10 ICP + + // Approve bob for 5 ICP, expiring 24h from now + let before_nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + + icp_client.use_identity("alice"); + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "approve", + "5", + &bob_principal.to_string(), + "--expires-in", + "24h", + "--environment", + "random-environment", + ]) + .assert() + // Human output surfaces the resolved (RFC 3339) expiry + .stdout(contains("Expires at:")) + .success(); + + let after_nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + + // The ledger records an expiry ~24h ahead (bounded by the wall-clock window + // around the CLI call) + let allowance = icp_ledger + .allowance_of(alice_principal, None, bob_principal, None) + .await; + assert_eq!(allowance.allowance, 500_000_000_u128); + let day_nanos: u128 = 24 * 60 * 60 * 1_000_000_000; + let expires_at = u128::from( + allowance + .expires_at + .expect("expiry should be set on the allowance"), + ); + assert!( + expires_at >= before_nanos + day_nanos && expires_at <= after_nanos + day_nanos, + "expiry {expires_at} not within [{}, {}]", + before_nanos + day_nanos, + after_nanos + day_nanos, + ); + + // --json exposes the raw expiry timestamp + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "allowance", + &bob_principal.to_string(), + "--json", + "--environment", + "random-environment", + ]) + .assert() + .stdout(contains(format!( + "\"expires_at\":{}", + allowance.expires_at.unwrap() + ))) + .success(); + + // An allowance granted without an expiry reports none + ctx.icp() + .current_dir(&project_dir) + .args([ + "token", + "approve", + "1", + &bob_principal.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .success(); + let allowance = icp_ledger + .allowance_of(alice_principal, None, bob_principal, None) + .await; + assert_eq!(allowance.expires_at, None); +} + #[tokio::test] async fn token_balance_with_subaccount() { let ctx = TestContext::new(); diff --git a/docs/guides/tokens-and-cycles.md b/docs/guides/tokens-and-cycles.md index de1ef8566..b91134c5b 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -169,6 +169,84 @@ This works with any ICRC-1 compatible token ledger on the Internet Computer. **Finding Token Ledger IDs:** You can find ledger canister IDs for various tokens on the [ICP Dashboard](https://dashboard.internetcomputer.org/tokens). +## Approving Token Spending (Allowances) + +Beyond direct transfers, ICP and ICRC-2 compatible tokens support *allowances*: you authorize another principal — the **spender**, typically a canister — to transfer a limited amount of tokens from your account on your behalf. This is the foundation for many payment and recurring-billing flows, where a canister pulls funds only when needed (for example, an exchange platform settling a trade or a service charging per use) rather than requiring you to send tokens up front. + +Allowances follow the [ICRC-2 standard](https://docs.internetcomputer.org/references/digital-asset-standards/#icrc-2-approve-and-transfer-from) and work with any ICRC-2 compatible ledger, including the ICP ledger. + +### Granting an Allowance + +Use `icp token approve` to authorize a spender: + +```bash +# Approve a canister to spend up to 5 ICP on your behalf +icp token approve 5 -n ic + +# Approve 0.01 ckBTC (any ICRC-2 ledger, by canister id) +icp token mxzaz-hqaaa-aaaar-qaada-cai approve 0.01 -n ic +``` + +`` is the principal you are authorizing (usually a canister ID). The amount is in whole tokens and supports the same [human-readable suffixes](#amount-format) as transfers. + +**Approvals overwrite, they do not add.** Each `approve` call *sets* the allowance to the amount you specify, replacing any previous value. Approving `5` and then `2` leaves an allowance of `2`, not `7`. To revoke an allowance, approve `0`: + +```bash +icp token approve 0 -n ic +``` + +The allowance is granted from your account, which pays the standard ledger fee (0.0001 ICP for the ICP ledger). + +### Setting an Expiry + +For safety, you can make an allowance expire automatically with `--expires-in`. It accepts a relative duration (suffixes `s`, `m`, `h`, `d`, `w`; a bare number is seconds), the same format used elsewhere in the CLI: + +```bash +# Allow spending for the next 24 hours only +icp token approve 5 --expires-in 24h -n ic + +# Expire in 30 days +icp token approve 5 --expires-in 30d -n ic +``` + +A short expiry limits your exposure if the spender is ever compromised. Without `--expires-in`, the allowance stays in effect until you change or revoke it. + +### Checking an Allowance + +Use `icp token allowance` to see how much a spender is currently authorized to transfer: + +```bash +# Allowance you granted to a spender +icp token allowance -n ic +``` + +This is a read-only query, so you can inspect any allowance — not just your own. Use `--of-principal` to look up the allowance another account granted: + +```bash +# Allowance that granted to +icp token allowance --of-principal -n ic +``` + +The output includes the expiry, if one was set. + +### Allowances with Subaccounts + +Both commands accept [subaccount](#subaccounts) flags, specified as hex strings just like `balance` and `transfer`: + +| Flag | Command | Meaning | +|------|---------|---------| +| `--from-subaccount ` | `approve` | Your subaccount the allowance is granted from (the account debited) | +| `--subaccount ` | `allowance` | The owner subaccount that granted the allowance | +| `--spender-subaccount ` | both | The spender's subaccount | + +```bash +# Approve from your subaccount 1, to the spender's subaccount 2 +icp token approve 5 --from-subaccount 1 --spender-subaccount 2 -n ic + +# Check that same allowance +icp token allowance --subaccount 1 --spender-subaccount 2 -n ic +``` + ## Transferring Cycles Transfer cycles directly to another principal via the cycles ledger: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ce3dfab67..067745066 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -77,6 +77,8 @@ This document contains the help content for the `icp` command-line program. * [`icp token`↴](#icp-token) * [`icp token balance`↴](#icp-token-balance) * [`icp token transfer`↴](#icp-token-transfer) +* [`icp token approve`↴](#icp-token-approve) +* [`icp token allowance`↴](#icp-token-allowance) ## `icp` @@ -1650,6 +1652,8 @@ Perform token transactions * `balance` — Display the token balance on the ledger (default token: icp) * `transfer` — Transfer ICP or ICRC1 tokens through their ledger (default token: icp) +* `approve` — Approve a spender to transfer tokens on your behalf (ICRC-2) (default token: icp) +* `allowance` — Display the allowance granted to a spender (ICRC-2) (default token: icp) ###### **Arguments:** @@ -1702,6 +1706,59 @@ Transfer ICP or ICRC1 tokens through their ledger (default token: icp) +## `icp token approve` + +Approve a spender to transfer tokens on your behalf (ICRC-2) (default token: icp) + +Sets the spender's allowance to the given amount, overwriting any existing allowance (this is a set, not an increment). The allowance is granted from the calling identity's account, which is charged the ledger's approval fee, and can optionally be given an expiry with `--expires-in`. Works with any ICRC-2 ledger, referenced by a known token name or a ledger canister id. + +**Usage:** `icp token [TOKEN|LEDGER_ID] approve [OPTIONS] ` + +###### **Arguments:** + +* `` — The allowance amount, in whole tokens (e.g. `1.5`), the spender may transfer. Supports suffixes: k (thousand), m (million), b (billion), t (trillion) +* `` — Principal of the spender being granted the allowance + +###### **Options:** + +* `--spender-subaccount ` — The spender's subaccount, as a hex string (32 bytes, left-padded). Defaults to the default subaccount +* `--from-subaccount ` — The caller's subaccount to grant the allowance from (the account debited), as a hex string (32 bytes, left-padded). Defaults to the default subaccount +* `--expires-in ` — Expire the allowance after this duration from now, e.g. `24h`, `30d` (suffixes: s, m, h, d, w; a bare number is seconds). Never expires if omitted +* `-n`, `--network ` — Name or URL of the network to target, conflicts with environment argument +* `-k`, `--root-key ` — The root key to use if connecting to a network by URL. Required when using `--network ` +* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--identity ` — The user identity to run this command as +* `--json` — Output command results as JSON +* `-q`, `--quiet` — Suppress human-readable output; print only the block index + + + +## `icp token allowance` + +Display the allowance granted to a spender (ICRC-2) (default token: icp) + +This is a read-only query that works for any owner/spender pair, including accounts you do not control (use `--of-principal` to set the owner). The amount is shown in whole tokens, along with an expiry if one was set. Works with any ICRC-2 ledger, referenced by a known token name or a ledger canister id. + +**Usage:** `icp token [TOKEN|LEDGER_ID] allowance [OPTIONS] ` + +###### **Arguments:** + +* `` — Principal of the spender whose allowance to look up + +###### **Options:** + +* `--spender-subaccount ` — The spender's subaccount, as a hex string (32 bytes, left-padded). Defaults to the default subaccount +* `--subaccount ` — The owner's subaccount that granted the allowance, as a hex string (32 bytes, left-padded). Defaults to the default subaccount +* `--of-principal ` — The allowance owner to look up, instead of the current identity. Lets you inspect allowances granted by any principal +* `-n`, `--network ` — Name or URL of the network to target, conflicts with environment argument +* `-k`, `--root-key ` — The root key to use if connecting to a network by URL. Required when using `--network ` +* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--identity ` — The user identity to run this command as +* `--json` — Output command results as JSON +* `-q`, `--quiet` — Suppress human-readable output; print only the allowance amount + + +