Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ bump. Currently experimental: project bundling

# Unreleased

* feat: `icp token [TOKEN|LEDGER_ID] approve <AMOUNT> <SPENDER>` 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 <DURATION>` (e.g. `24h`, `30d`) to auto-expire the allowance.
* feat: `icp token [TOKEN|LEDGER_ID] allowance <SPENDER>` 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
Expand Down
110 changes: 110 additions & 0 deletions crates/icp-cli/src/commands/token/allowance.rs
Original file line number Diff line number Diff line change
@@ -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] <SPENDER>")]
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<Principal>,

#[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<u64>,
}
139 changes: 139 additions & 0 deletions crates/icp-cli/src/commands/token/approve.rs
Original file line number Diff line number Diff line change
@@ -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] <AMOUNT> <SPENDER>")]
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<DurationAmount>,

#[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<u64>,
}
15 changes: 15 additions & 0 deletions crates/icp-cli/src/commands/token/mod.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -18,4 +31,6 @@ pub(crate) struct Command {
pub(crate) enum Commands {
Balance(balance::BalanceArgs),
Transfer(transfer::TransferArgs),
Approve(approve::ApproveArgs),
Allowance(allowance::AllowanceArgs),
}
8 changes: 8 additions & 0 deletions crates/icp-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
},
}

Expand Down
Loading
Loading