Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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