From 44f33b1f2f4d8324c77b293f8bc8f8735b835b2a Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 3 Jul 2026 11:10:14 +0200 Subject: [PATCH 1/5] feat: add ICRC-2 `token approve` and `token allowance` commands Add first-class support for ICRC-2 allowances so developers can authorize a spender (e.g. a canister) to transfer tokens on their behalf, and inspect existing allowances, without hand-writing raw `canister call` payloads. - `icp token [TOKEN|LEDGER_ID] approve ` sets a spender's allowance (overwrite, not increment) via `icrc2_approve`. Supports `--from-subaccount` (the debited account) and `--spender-subaccount`. - `icp token [TOKEN|LEDGER_ID] allowance ` reads an allowance via `icrc2_allowance`. Supports `--subaccount`, `--spender-subaccount`, and `--of-principal` to inspect any owner/spender pair, including accounts you do not control. Both work with any ICRC-2 ledger by known name or canister id, mirror the existing `balance`/`transfer` conventions (subaccounts as hex, `--json`, `-q/--quiet`, operations-layer split, per-action Snafu errors), and are covered by integration tests exercising subaccounts, third-party reads, output modes, and overwrite semantics. --- CHANGELOG.md | 2 + .../icp-cli/src/commands/token/allowance.rs | 109 ++++++++ crates/icp-cli/src/commands/token/approve.rs | 106 ++++++++ crates/icp-cli/src/commands/token/mod.rs | 4 + crates/icp-cli/src/main.rs | 8 + .../icp-cli/src/operations/token/allowance.rs | 141 ++++++++++ .../icp-cli/src/operations/token/approve.rs | 177 +++++++++++++ crates/icp-cli/src/operations/token/mod.rs | 2 + crates/icp-cli/tests/common/clients/ledger.rs | 28 ++ crates/icp-cli/tests/token_tests.rs | 243 ++++++++++++++++++ docs/reference/cli.md | 56 ++++ 11 files changed, 876 insertions(+) create mode 100644 crates/icp-cli/src/commands/token/allowance.rs create mode 100644 crates/icp-cli/src/commands/token/approve.rs create mode 100644 crates/icp-cli/src/operations/token/allowance.rs create mode 100644 crates/icp-cli/src/operations/token/approve.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5ee1e00..95be4d5a3 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) and `--spender-subaccount`. +* 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..fc45f9fbd --- /dev/null +++ b/crates/icp-cli/src/commands/token/allowance.rs @@ -0,0 +1,109 @@ +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::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: {expires_at} (nanoseconds since epoch)"); + } + } + + 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..cf41d657e --- /dev/null +++ b/crates/icp-cli/src/commands/token/approve.rs @@ -0,0 +1,106 @@ +use std::io::stdout; + +use bigdecimal::BigDecimal; +use candid::Principal; +use clap::Args; +use icp::context::Context; +use icp::parsers::parse_token_amount; +use icrc_ledger_types::icrc1::account::Account; +use serde::Serialize; + +use crate::commands::args::TokenCommandArgs; +use crate::commands::parsers::parse_subaccount; +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. 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]>, + + #[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, + }; + + // Execute approve + let info = approve(&agent, token, &args.amount, args.from_subaccount, spender).await?; + + if args.json { + serde_json::to_writer( + stdout(), + &JsonApprove { + block_index: info.block_index.to_string(), + }, + )?; + } else if args.quiet { + println!("{}", info.block_index); + } else { + println!( + "Approved {} to spend up to {} (block {})", + info.spender_display, info.allowance, info.block_index + ); + } + + Ok(()) +} + +#[derive(Serialize)] +struct JsonApprove { + block_index: String, +} diff --git a/crates/icp-cli/src/commands/token/mod.rs b/crates/icp-cli/src/commands/token/mod.rs index ecc069d8b..843d9b9e9 100644 --- a/crates/icp-cli/src/commands/token/mod.rs +++ b/crates/icp-cli/src/commands/token/mod.rs @@ -1,5 +1,7 @@ use clap::{Parser, Subcommand}; +pub(crate) mod allowance; +pub(crate) mod approve; pub(crate) mod balance; pub(crate) mod transfer; @@ -18,4 +20,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..63bb93d9c --- /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}'"))] + 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..63183d5a5 --- /dev/null +++ b/crates/icp-cli/src/operations/token/approve.rs @@ -0,0 +1,177 @@ +use bigdecimal::{BigDecimal, num_bigint::ToBigInt}; +use candid::{Decode, Encode, Nat, Principal}; +use ic_agent::{Agent, AgentError}; +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}'"))] + 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: unable to convert to ledger units"))] + InvalidAmount, + + #[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 +/// +/// # 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, +) -> 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? as u32, symbol?); + + // Calculate units of token to approve + // Ledgers do not work in decimals and instead use the smallest non-divisible unit of the token + let ledger_amount_decimal = amount.clone() * 10u128.pow(decimals); + let ledger_amount = ledger_amount_decimal + .to_bigint() + .ok_or(TokenApproveError::InvalidAmount)? + .to_biguint() + .ok_or(TokenApproveError::InvalidAmount) + .map(Nat::from)?; + + // 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: None, + 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..b5d72e004 100644 --- a/crates/icp-cli/tests/token_tests.rs +++ b/crates/icp-cli/tests/token_tests.rs @@ -191,6 +191,249 @@ 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(); + + // 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_balance_with_subaccount() { let ctx = TestContext::new(); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ce3dfab67..a64b3e8c7 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,58 @@ 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. 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 +* `-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 + + +
From 116d098c90d500e978a23bb55b34ca0036a80f3b Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 3 Jul 2026 11:38:19 +0200 Subject: [PATCH 2/5] feat(token): add optional --expires-in to approve, and guide docs Add an optional `--expires-in ` flag to `icp token approve` that sets an ICRC-2 allowance expiry, using the CLI's existing relative-duration format (`24h`, `30d`; suffixes s/m/h/d/w, bare number is seconds). The relative duration is resolved to an absolute nanosecond timestamp at call time. Approval and allowance expiries are now rendered as RFC 3339 timestamps in human output and as raw nanoseconds in `--json`. Also document the new `approve`/`allowance` commands (including expiry and subaccounts) in the "Tokens and Cycles" guide, and cover expiry with an integration test. --- CHANGELOG.md | 2 +- .../icp-cli/src/commands/token/allowance.rs | 3 +- crates/icp-cli/src/commands/token/approve.rs | 41 +++++- crates/icp-cli/src/commands/token/mod.rs | 11 ++ .../icp-cli/src/operations/token/approve.rs | 4 +- crates/icp-cli/tests/token_tests.rs | 117 ++++++++++++++++++ docs/guides/tokens-and-cycles.md | 78 ++++++++++++ docs/reference/cli.md | 3 +- 8 files changed, 251 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95be4d5a3..2c39227cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ 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) and `--spender-subaccount`. +* 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 diff --git a/crates/icp-cli/src/commands/token/allowance.rs b/crates/icp-cli/src/commands/token/allowance.rs index fc45f9fbd..13dadce90 100644 --- a/crates/icp-cli/src/commands/token/allowance.rs +++ b/crates/icp-cli/src/commands/token/allowance.rs @@ -8,6 +8,7 @@ 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) @@ -95,7 +96,7 @@ pub(crate) async fn exec( } else { println!("Allowance: {}", info.allowance); if let Some(expires_at) = info.expires_at { - println!("Expires at: {expires_at} (nanoseconds since epoch)"); + println!("Expires at: {}", format_expiry(expires_at)); } } diff --git a/crates/icp-cli/src/commands/token/approve.rs b/crates/icp-cli/src/commands/token/approve.rs index cf41d657e..72a4fddb3 100644 --- a/crates/icp-cli/src/commands/token/approve.rs +++ b/crates/icp-cli/src/commands/token/approve.rs @@ -1,23 +1,27 @@ use std::io::stdout; +use anyhow::Context as _; use bigdecimal::BigDecimal; use candid::Principal; use clap::Args; use icp::context::Context; -use icp::parsers::parse_token_amount; +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. Works -/// with any ICRC-2 ledger, referenced by a known token name or a ledger canister id. +/// 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 { @@ -39,6 +43,11 @@ pub(crate) struct ApproveArgs { #[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, @@ -78,14 +87,34 @@ pub(crate) async fn exec( 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).await?; + 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 { @@ -95,6 +124,9 @@ pub(crate) async fn exec( "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(()) @@ -103,4 +135,5 @@ pub(crate) async fn exec( #[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 843d9b9e9..5e88e2d61 100644 --- a/crates/icp-cli/src/commands/token/mod.rs +++ b/crates/icp-cli/src/commands/token/mod.rs @@ -1,10 +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 { diff --git a/crates/icp-cli/src/operations/token/approve.rs b/crates/icp-cli/src/operations/token/approve.rs index 63183d5a5..7da7a8491 100644 --- a/crates/icp-cli/src/operations/token/approve.rs +++ b/crates/icp-cli/src/operations/token/approve.rs @@ -66,6 +66,7 @@ pub struct ApproveInfo { /// * `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 /// @@ -76,6 +77,7 @@ pub async fn approve( 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) { @@ -143,7 +145,7 @@ pub async fn approve( spender, amount: ledger_amount.clone(), expected_allowance: None, - expires_at: None, + expires_at, fee: None, memo: None, created_at_time: None, diff --git a/crates/icp-cli/tests/token_tests.rs b/crates/icp-cli/tests/token_tests.rs index b5d72e004..ce01a44f8 100644 --- a/crates/icp-cli/tests/token_tests.rs +++ b/crates/icp-cli/tests/token_tests.rs @@ -434,6 +434,123 @@ async fn token_approve_with_subaccounts() { .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..566750da1 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 DeFi and payment flows, where a canister pulls funds only when needed (for example, a DEX executing a swap or a service charging per use) rather than requiring you to send tokens up front. + +Allowances follow the [ICRC-2 standard](https://github.com/dfinity/ICRC-1/blob/main/standards/ICRC-2/README.md) 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 a64b3e8c7..067745066 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1710,7 +1710,7 @@ Transfer ICP or ICRC1 tokens through their ledger (default token: icp) 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. Works with any ICRC-2 ledger, referenced by a known token name or a ledger canister id. +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] ` @@ -1723,6 +1723,7 @@ Sets the spender's allowance to the given amount, overwriting any existing allow * `--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 From 20cb0cebd7e01f3682894a7e6095113038880c17 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 3 Jul 2026 11:44:19 +0200 Subject: [PATCH 3/5] docs: reword allowances intro to avoid DeFi jargon --- docs/guides/tokens-and-cycles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/tokens-and-cycles.md b/docs/guides/tokens-and-cycles.md index 566750da1..92386df8f 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -171,7 +171,7 @@ This works with any ICRC-1 compatible token ledger on the Internet Computer. ## 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 DeFi and payment flows, where a canister pulls funds only when needed (for example, a DEX executing a swap or a service charging per use) rather than requiring you to send tokens up front. +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://github.com/dfinity/ICRC-1/blob/main/standards/ICRC-2/README.md) and work with any ICRC-2 compatible ledger, including the ICP ledger. From 226f479545ad115174ea207157a9fcdff4037089 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 3 Jul 2026 11:59:05 +0200 Subject: [PATCH 4/5] =?UTF-8?q?refactor(token):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20safer=20amount=20scaling=20and=20richer=20parse=20e?= =?UTF-8?q?rrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - approve: convert the decimal amount via `icp::parsers::to_token_unit_amount` instead of `10^decimals` multiplication. This rejects amounts that aren't exactly representable at the token's precision (rather than silently truncating) and uses arbitrary-precision math to avoid overflow for large `decimals`. Covered by a new test asserting an over-precise amount is refused. - approve/allowance: include `{source}` in the `ParseCanisterId` error display, matching `operations/token/balance.rs` for better diagnostics. --- .../icp-cli/src/operations/token/allowance.rs | 2 +- .../icp-cli/src/operations/token/approve.rs | 28 +++++++++---------- crates/icp-cli/tests/token_tests.rs | 16 +++++++++++ 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/crates/icp-cli/src/operations/token/allowance.rs b/crates/icp-cli/src/operations/token/allowance.rs index 63bb93d9c..31c34000a 100644 --- a/crates/icp-cli/src/operations/token/allowance.rs +++ b/crates/icp-cli/src/operations/token/allowance.rs @@ -9,7 +9,7 @@ use super::{TOKEN_LEDGER_CIDS, TokenAmount}; #[derive(Debug, Snafu)] pub enum GetAllowanceError { - #[snafu(display("failed to parse canister id '{canister_id}'"))] + #[snafu(display("failed to parse canister id '{canister_id}': {source}"))] ParseCanisterId { canister_id: String, source: candid::types::principal::PrincipalError, diff --git a/crates/icp-cli/src/operations/token/approve.rs b/crates/icp-cli/src/operations/token/approve.rs index 7da7a8491..58e1d4fc3 100644 --- a/crates/icp-cli/src/operations/token/approve.rs +++ b/crates/icp-cli/src/operations/token/approve.rs @@ -1,6 +1,7 @@ -use bigdecimal::{BigDecimal, num_bigint::ToBigInt}; +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}; @@ -9,7 +10,7 @@ use super::{TOKEN_LEDGER_CIDS, TokenAmount}; #[derive(Debug, Snafu)] pub enum TokenApproveError { - #[snafu(display("failed to parse canister id '{canister_id}'"))] + #[snafu(display("failed to parse canister id '{canister_id}': {source}"))] ParseCanisterId { canister_id: String, source: candid::types::principal::PrincipalError, @@ -27,8 +28,8 @@ pub enum TokenApproveError { #[snafu(display("failed to decode symbol response"))] DecodeSymbol { source: candid::Error }, - #[snafu(display("invalid amount: unable to convert to ledger units"))] - InvalidAmount, + #[snafu(display("invalid amount: {message}"))] + InvalidAmount { message: String }, #[snafu(display("failed to encode approve argument"))] EncodeApproveArg { source: candid::Error }, @@ -125,17 +126,14 @@ pub async fn approve( ); // Check for errors - let (decimals, symbol) = (decimals? as u32, symbol?); - - // Calculate units of token to approve - // Ledgers do not work in decimals and instead use the smallest non-divisible unit of the token - let ledger_amount_decimal = amount.clone() * 10u128.pow(decimals); - let ledger_amount = ledger_amount_decimal - .to_bigint() - .ok_or(TokenApproveError::InvalidAmount)? - .to_biguint() - .ok_or(TokenApproveError::InvalidAmount) - .map(Nat::from)?; + 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(); diff --git a/crates/icp-cli/tests/token_tests.rs b/crates/icp-cli/tests/token_tests.rs index ce01a44f8..bdc0c8eb1 100644 --- a/crates/icp-cli/tests/token_tests.rs +++ b/crates/icp-cli/tests/token_tests.rs @@ -309,6 +309,22 @@ async fn token_approve_and_allowance() { .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"); From 372dcb61d60cdf62928b810685b386cb05c645a2 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 3 Jul 2026 12:24:38 +0200 Subject: [PATCH 5/5] docs: link ICRC-2 to internetcomputer.org reference instead of GitHub --- docs/guides/tokens-and-cycles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/tokens-and-cycles.md b/docs/guides/tokens-and-cycles.md index 92386df8f..b91134c5b 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -173,7 +173,7 @@ This works with any ICRC-1 compatible token ledger on the Internet Computer. 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://github.com/dfinity/ICRC-1/blob/main/standards/ICRC-2/README.md) and work with any ICRC-2 compatible ledger, including the ICP ledger. +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