From 1a0c9169199dbfa0b61727cce8fa106b7d2d129d Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 7 Oct 2026 22:40:00 +0000 Subject: [PATCH 1/8] feat: `icp cycles buy` buys cycles with a card for the current identity Adds `icp cycles buy --amount `: quotes the amount on a cycles gateway canister, shows buyer, gateway canister id and quote for confirmation, creates the order as the current identity, prints the hosted checkout URL (opening it in a browser from a terminal) and waits until the cycles land on the identity's own cycles-ledger account. `--resume` and `--cancel` continue or cancel an existing order; `--gateway` or `ICP_CYCLES_GATEWAY_CANISTER_ID` picks another gateway canister. - `icp-canister-interfaces::cycles_gateway`: the gateway's Candid interface and default canister id. - `icp-app::operations::cycles_purchase`: quote, create, read, cancel and wait over `CanisterCalls`, with a currency-aware `Money` so another currency is additive. Unit-tested against a scripted fake gateway. - `AgentCalls::wrap` reports an HTTP gateway's `canister_not_found` as an IC0301 rejection, so "this canister does not exist here" is answered the same on a local network as by a replica. Closes #812 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh --- CHANGELOG.md | 1 + crates/icp-app/src/calls.rs | 48 +- .../src/operations/cycles_purchase/mod.rs | 558 ++++++++++++++ .../src/operations/cycles_purchase/money.rs | 274 +++++++ .../src/operations/cycles_purchase/tests.rs | 698 ++++++++++++++++++ crates/icp-app/src/operations/mod.rs | 1 + crates/icp-app/src/operations/token/mod.rs | 40 + .../src/cycles_gateway.rs | 595 +++++++++++++++ crates/icp-canister-interfaces/src/lib.rs | 1 + crates/icp-cli/src/commands/cycles/buy.rs | 664 +++++++++++++++++ crates/icp-cli/src/commands/cycles/mod.rs | 2 + crates/icp-cli/src/commands/parsers.rs | 7 + crates/icp-cli/src/main.rs | 2 + crates/icp-cli/tests/cycles_tests.rs | 102 +++ docs/guides/deploying-to-mainnet.md | 8 +- docs/guides/tokens-and-cycles.md | 31 + docs/reference/cli.md | 30 + 17 files changed, 3060 insertions(+), 2 deletions(-) create mode 100644 crates/icp-app/src/operations/cycles_purchase/mod.rs create mode 100644 crates/icp-app/src/operations/cycles_purchase/money.rs create mode 100644 crates/icp-app/src/operations/cycles_purchase/tests.rs create mode 100644 crates/icp-canister-interfaces/src/cycles_gateway.rs create mode 100644 crates/icp-cli/src/commands/cycles/buy.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c8c1f24..dd3772870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ air-gapped signing # Unreleased +* feat: `icp cycles buy --amount ` buys cycles with a card for the current identity through a cycles gateway canister. It quotes the amount, asks for confirmation, creates the order as the identity, prints the hosted checkout URL (and opens it in a browser from a terminal) and waits for the cycles to land on the identity's cycles-ledger account. `--resume` and `--cancel` continue or cancel an existing order; `--gateway` or `ICP_CYCLES_GATEWAY_CANISTER_ID` picks another gateway canister. * feat: `icp deploy` and `icp canister create` take `--engine ` to target a cloud engine by name. The name is resolved to the engine's subnet through the engine canister, among the engines visible to the identity in use, and then behaves as `--subnet` would with that subnet; a subnet id passed as the engine is used as-is. `name/slug` or the engine's id picks one of several engines sharing a name. * fix: reject path-like network and environment names diff --git a/crates/icp-app/src/calls.rs b/crates/icp-app/src/calls.rs index bd5b56708..25153b9ae 100644 --- a/crates/icp-app/src/calls.rs +++ b/crates/icp-app/src/calls.rs @@ -16,7 +16,7 @@ use async_trait::async_trait; use candid::{Encode, Nat, Principal}; use ic_agent::{ Agent, AgentError, - agent::{CallResponse, EffectiveId, SubnetType}, + agent::{CallResponse, EffectiveId, SubnetType, agent_error::HttpErrorPayload}, hash_tree::{Label, LookupResult}, }; use ic_management_canister_types::{CanisterMetadataArgs, CanisterMetadataResult}; @@ -73,6 +73,18 @@ impl AgentCalls { AgentError::TimeoutWaitingForResponse() | AgentError::TransportError(_) => { CallError::unanswered(canister, method, err) } + // An HTTP gateway that cannot route to the canister answers before + // any replica does, with no reject code to carry. It has still + // reached the same verdict a replica's IC0301 would, so it is + // reported as that rejection rather than as an opaque failure. + AgentError::HttpError(payload) if is_canister_not_found_http(payload) => { + CallError::Rejected { + canister, + method: method.to_owned(), + code: Some(CANISTER_NOT_FOUND.to_owned()), + message: format!("Canister {canister} not found"), + } + } _ => CallError::failed(canister, method, err), } } @@ -402,6 +414,17 @@ impl CanisterCalls for AgentCalls { } } +/// The replica's error code for a call addressed to a canister that does not +/// exist, which [`CallError::is_canister_not_found`] recognizes. +const CANISTER_NOT_FOUND: &str = "IC0301"; + +/// Whether an HTTP gateway's error says the canister it was asked to route to +/// does not exist: a 4xx whose body is the gateway's `canister_not_found`. +fn is_canister_not_found_http(payload: &HttpErrorPayload) -> bool { + (400..500).contains(&payload.status) + && String::from_utf8_lossy(&payload.content).contains("canister_not_found") +} + #[cfg(test)] mod tests { use super::*; @@ -444,6 +467,29 @@ mod tests { assert!(!err.is_transient()); } + /// A local HTTP gateway refuses to route to a canister it has never heard + /// of before any replica sees the call. Callers that branch on "does this + /// canister exist" must get the same answer they would from a replica. + #[test] + fn a_gateway_canister_not_found_is_an_ic0301_rejection() { + let err = wrapped(AgentError::HttpError(HttpErrorPayload { + status: 400, + content_type: Some("text/plain; charset=utf-8".to_owned()), + content: b"error: canister_not_found".to_vec(), + })); + assert!(err.is_canister_not_found(), "{err}"); + assert!(err.is_rejection()); + + // Any other HTTP failure stays what it is: deterministic, not absence. + let err = wrapped(AgentError::HttpError(HttpErrorPayload { + status: 502, + content_type: None, + content: b"bad gateway".to_vec(), + })); + assert!(!err.is_canister_not_found()); + assert!(!err.is_rejection()); + } + /// The replica's own wording for the two ways a target reports it has no /// section, copied from `CanisterManagerError` in the IC repo. Both are /// absence, not failure, so both must reach the plugin as `none`. diff --git a/crates/icp-app/src/operations/cycles_purchase/mod.rs b/crates/icp-app/src/operations/cycles_purchase/mod.rs new file mode 100644 index 000000000..da687abc9 --- /dev/null +++ b/crates/icp-app/src/operations/cycles_purchase/mod.rs @@ -0,0 +1,558 @@ +//! Buying cycles with a card through a cycles gateway canister. +//! +//! The buyer is whoever makes the calls: the order is created as the caller, +//! the cycles go to the caller's own default cycles-ledger account, and only +//! the caller can read the order back. The flow is quote, create, pay on the +//! hosted checkout page the order carries, then wait for the gateway to flip +//! the order to `delivered`. +//! +//! Nothing here prints. [`wait_for_order`] reports each status change through +//! a callback and hands the last order it saw back inside its error when it +//! is interrupted, so the caller can say how to resume. + +mod money; + +#[cfg(test)] +mod tests; + +use std::future::Future; +use std::pin::pin; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use candid::{Nat, Principal}; +use icp_canister_interfaces::cycles_gateway::{ + Account, Amount, CANCEL_ORDER_METHOD, CREATE_ORDER_METHOD, CYCLES_GATEWAY_CANISTER_ID_ENV, + CancelOrderError, CancelOrderResult, CreateOrderError, CreateOrderResult, Destination, + ExpiredBy, GET_ORDER_METHOD, LIST_ORDERS_METHOD, ListOrdersResult, Order, OrderStatus, + QUOTE_PREVIEWS_METHOD, QuotePreviews, Reason, +}; +use icp_project::calls::{CanisterCalls, RouteTo, TypedCallError, query_typed, update_typed}; +use num_traits::ToPrimitive; +use snafu::{OptionExt, Snafu}; +use tokio::{select, time::sleep}; + +pub use money::{Currency, Money, ParseMoneyError}; + +/// How often [`wait_for_order`] asks the gateway for the order. +pub const POLL_INTERVAL: Duration = Duration::from_secs(3); + +/// How far the rate may move against the buyer between quote and creation +/// before the gateway refuses the order instead of delivering less. +pub const QUOTE_SLIPPAGE_PERCENT: u128 = 5; + +/// Orders per `list_orders` page when looking for the caller's open order. +const LIST_ORDERS_PAGE: u8 = 50; + +/// Pages of `list_orders` to walk before giving up on finding an open order. +const LIST_ORDERS_MAX_PAGES: usize = 3; + +#[derive(Debug, Snafu)] +pub enum CyclesPurchaseError { + #[snafu(display( + "the cycles gateway canister {gateway} does not exist on this network. \ + The default gateway is deployed on mainnet (`-n ic`); \ + --gateway or {CYCLES_GATEWAY_CANISTER_ID_ENV} chooses another" + ))] + GatewayNotFound { gateway: Principal }, + + #[snafu(display("failed to get a quote from cycles gateway {gateway}"))] + QuotePreviews { + gateway: Principal, + #[snafu(source(from(TypedCallError, Box::new)))] + source: Box, + }, + + #[snafu(display("cycles gateway {gateway} returned no quote for {amount}"))] + QuoteMissing { gateway: Principal, amount: Money }, + + #[snafu(display( + "cycles gateway {gateway} is not pricing orders right now (its quote carries no \ + cycles figure); try again in a few minutes" + ))] + RateUnavailable { gateway: Principal }, + + #[snafu(display("failed to create an order on cycles gateway {gateway}"))] + CreateOrder { + gateway: Principal, + #[snafu(source(from(TypedCallError, Box::new)))] + source: Box, + }, + + #[snafu(display("the cycles gateway refused the order: {error}"))] + OrderRefused { error: CreateOrderError }, + + #[snafu(display( + "you already have an open order {} ({open} of {max} allowed)", order.id + ))] + OpenOrderExists { + order: Box, + open: u128, + max: u128, + }, + + #[snafu(display("failed to read order {id} from cycles gateway {gateway}"))] + GetOrder { + gateway: Principal, + id: String, + #[snafu(source(from(TypedCallError, Box::new)))] + source: Box, + }, + + #[snafu(display( + "order {id} was not found on cycles gateway {gateway} for {caller}; \ + an order is visible only to the identity that created it" + ))] + OrderNotFound { + gateway: Principal, + id: String, + caller: Principal, + }, + + #[snafu(display("failed to list orders on cycles gateway {gateway}"))] + ListOrders { + gateway: Principal, + #[snafu(source(from(TypedCallError, Box::new)))] + source: Box, + }, + + #[snafu(display("failed to cancel order {id} on cycles gateway {gateway}"))] + CancelOrder { + gateway: Principal, + id: String, + #[snafu(source(from(TypedCallError, Box::new)))] + source: Box, + }, + + #[snafu(display("the cycles gateway refused to cancel order {id}: {error}"))] + CancelRefused { id: String, error: CancelOrderError }, + + #[snafu(display("the cycles gateway returned a value for '{field}' that does not fit"))] + ConvertField { field: &'static str }, + + #[snafu(display("interrupted while waiting for order {}", order.id))] + Interrupted { order: Box }, + + #[snafu(display( + "gave up waiting for order {} (last status: {})", order.id, order.status + ))] + WaitTimedOut { order: Box }, + + #[snafu(display("lost contact with cycles gateway {gateway} while waiting for order {id}"))] + WaitUnanswered { + gateway: Principal, + id: String, + #[snafu(source(from(TypedCallError, Box::new)))] + source: Box, + }, +} + +/// What an amount buys, as the gateway priced it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Quote { + /// What the card is charged. + pub amount: Money, + /// The card-processing fee included in `amount`. + pub fee: Money, + /// The cycles delivered for `amount` at the current rate. + pub cycles: u128, +} + +impl Quote { + /// The fewest cycles the buyer will accept: the quote less + /// [`QUOTE_SLIPPAGE_PERCENT`]. Passed to the gateway so a rate move + /// against the buyer is refused rather than silently delivering less. + pub fn minimum_cycles(&self) -> u128 { + self.cycles * (100 - QUOTE_SLIPPAGE_PERCENT) / 100 + } +} + +/// An order, converted out of Candid's arbitrary-precision numbers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OrderView { + pub id: String, + pub status: OrderStatus, + /// The cycles promised to the buyer, fixed at creation. + pub locked_cycles: u128, + /// The hosted checkout page, while the order is payable. + pub checkout_url: Option, + /// When the checkout page stops accepting payment, in nanoseconds since + /// the Unix epoch. + pub expires_at_ns: Option, + /// What the card was charged, once paid. + pub paid: Option, + pub expired_by: Option, + pub abandoned_reason: Option, +} + +impl TryFrom for OrderView { + type Error = CyclesPurchaseError; + + fn try_from(order: Order) -> Result { + Ok(Self { + id: order.id, + status: order.status, + locked_cycles: order.locked_cycles.0.to_u128().context(ConvertFieldSnafu { + field: "lockedCycles", + })?, + checkout_url: order.stripe_session_url, + expires_at_ns: order + .expires_at_ns + .map(|ns| { + ns.0.to_i64().context(ConvertFieldSnafu { + field: "expiresAtNs", + }) + }) + .transpose()?, + paid: order + .paid_usd_cents + .map(|cents| { + Money::from_nat_minor_units(¢s, Currency::Usd).context(ConvertFieldSnafu { + field: "paidUsdCents", + }) + }) + .transpose()?, + expired_by: order.expired_by, + abandoned_reason: order.abandoned_reason, + }) + } +} + +/// Ask the gateway what `amount` buys right now. +/// +/// The figure comes from the same function that prices an order, so it is +/// displayed as given rather than recomputed. +pub async fn quote( + calls: &dyn CanisterCalls, + gateway: Principal, + amount: Money, +) -> Result { + let minor_units = match amount.currency { + Currency::Usd => Nat::from(amount.minor_units), + }; + let (previews,): (QuotePreviews,) = + query_typed(calls, gateway, QUOTE_PREVIEWS_METHOD, (vec![minor_units],)) + .await + .map_err(|source| { + map_absent(source, gateway, |source| { + CyclesPurchaseError::QuotePreviews { + gateway, + source: Box::new(source), + } + }) + })?; + let preview = previews + .quotes + .into_iter() + .next() + .context(QuoteMissingSnafu { gateway, amount })?; + let cycles = preview + .cycles + .context(RateUnavailableSnafu { gateway })? + .0 + .to_u128() + .context(ConvertFieldSnafu { field: "cycles" })?; + let fee = Money::from_nat_minor_units(&preview.fee_cents, amount.currency) + .context(ConvertFieldSnafu { field: "feeCents" })?; + Ok(Quote { + amount, + fee, + cycles, + }) +} + +/// Create an order for `quote`, delivering to the caller's own default +/// cycles-ledger account and refusing fewer cycles than +/// [`Quote::minimum_cycles`]. +/// +/// When the gateway refuses because the caller already has an open order, +/// that order is looked up and returned in +/// [`CyclesPurchaseError::OpenOrderExists`] so the caller can resume or cancel +/// it instead of trying again. +pub async fn place_order( + calls: &dyn CanisterCalls, + gateway: Principal, + quote: &Quote, +) -> Result { + let amount = match quote.amount.currency { + Currency::Usd => Amount::Custom(Nat::from(quote.amount.minor_units)), + }; + let destination = Destination::CyclesLedgerAccount(Account { + owner: calls.caller(), + subaccount: None, + }); + let min_cycles = Some(Nat::from(quote.minimum_cycles())); + + let (result,): (CreateOrderResult,) = update_typed( + calls, + gateway, + CREATE_ORDER_METHOD, + (amount, destination, min_cycles), + RouteTo::Callee, + 0, + ) + .await + .map_err(|source| { + map_absent(source, gateway, |source| CyclesPurchaseError::CreateOrder { + gateway, + source: Box::new(source), + }) + })?; + + match result { + CreateOrderResult::Ok(created) => created.order.try_into(), + CreateOrderResult::Err(CreateOrderError::NotAdmitted(Reason::TooManyOpenOrders { + max, + open, + })) => match find_open_order(calls, gateway).await? { + Some(order) => OpenOrderExistsSnafu { + order: Box::new(order), + open: open.0.to_u128().unwrap_or(u128::MAX), + max: max.0.to_u128().unwrap_or(u128::MAX), + } + .fail(), + None => OrderRefusedSnafu { + error: CreateOrderError::NotAdmitted(Reason::TooManyOpenOrders { max, open }), + } + .fail(), + }, + CreateOrderResult::Err(error) => OrderRefusedSnafu { error }.fail(), + } +} + +/// Read one of the caller's orders. +/// +/// The gateway answers `null` both for an order that does not exist and for +/// one the caller does not own; both come back as +/// [`CyclesPurchaseError::OrderNotFound`]. +pub async fn get_order( + calls: &dyn CanisterCalls, + gateway: Principal, + id: &str, +) -> Result { + let (order,): (Option,) = + query_typed(calls, gateway, GET_ORDER_METHOD, (id.to_owned(),)) + .await + .map_err(|source| { + map_absent(source, gateway, |source| CyclesPurchaseError::GetOrder { + gateway, + id: id.to_owned(), + source: Box::new(source), + }) + })?; + order + .context(OrderNotFoundSnafu { + gateway, + id, + caller: calls.caller(), + })? + .try_into() +} + +/// The caller's open order — created or paid — if there is one. +pub async fn find_open_order( + calls: &dyn CanisterCalls, + gateway: Principal, +) -> Result, CyclesPurchaseError> { + let mut cursor: Option = None; + for _ in 0..LIST_ORDERS_MAX_PAGES { + let (page,): (ListOrdersResult,) = query_typed( + calls, + gateway, + LIST_ORDERS_METHOD, + (cursor.clone(), Nat::from(LIST_ORDERS_PAGE)), + ) + .await + .map_err(|source| { + map_absent(source, gateway, |source| CyclesPurchaseError::ListOrders { + gateway, + source: Box::new(source), + }) + })?; + if let Some(order) = page + .orders + .into_iter() + .find(|order| !order.status.is_terminal()) + { + return order.try_into().map(Some); + } + match page.next_cursor { + Some(next) => cursor = Some(next), + None => break, + } + } + Ok(None) +} + +/// Cancel one of the caller's open orders. +pub async fn cancel_order( + calls: &dyn CanisterCalls, + gateway: Principal, + id: &str, +) -> Result { + let (result,): (CancelOrderResult,) = update_typed( + calls, + gateway, + CANCEL_ORDER_METHOD, + (id.to_owned(),), + RouteTo::Callee, + 0, + ) + .await + .map_err(|source| { + map_absent(source, gateway, |source| CyclesPurchaseError::CancelOrder { + gateway, + id: id.to_owned(), + source: Box::new(source), + }) + })?; + match result { + CancelOrderResult::Ok(order) => (*order).try_into(), + CancelOrderResult::Err(error) => CancelRefusedSnafu { id, error }.fail(), + } +} + +/// Bounds on [`wait_for_order`]. +#[derive(Clone, Debug)] +pub struct WaitOptions { + /// Time between polls. + pub poll_interval: Duration, + /// How long past its own deadline a still-payable order is waited on + /// before giving up. The gateway expires it on a timer, not instantly. + pub expiry_grace: Duration, + /// How long a payable order without a deadline is waited on. + pub max_without_expiry: Duration, + /// How long a paid order is given to be delivered. + pub max_paid_wait: Duration, + /// Consecutive unanswered polls tolerated before giving up. + pub max_consecutive_transient: u32, +} + +impl Default for WaitOptions { + fn default() -> Self { + Self { + poll_interval: POLL_INTERVAL, + expiry_grace: Duration::from_secs(2 * 60), + max_without_expiry: Duration::from_secs(30 * 60), + max_paid_wait: Duration::from_secs(15 * 60), + max_consecutive_transient: 20, + } + } +} + +/// Poll the gateway until the order reaches a terminal status. +/// +/// `initial` is the order as last seen, if the caller has it: it seeds the +/// change detection so `on_change` fires only on a transition, and it is what +/// [`CyclesPurchaseError::Interrupted`] carries if `interrupt` resolves before +/// the first poll answers. `on_change` runs for every status change, including +/// the terminal one. +/// +/// A poll the network never answered is retried; any other failure returns at +/// once. A terminal status is a success here whatever it is — expired is an +/// answer — and the caller decides what each one means. +pub async fn wait_for_order( + calls: &dyn CanisterCalls, + gateway: Principal, + id: &str, + initial: Option, + options: &WaitOptions, + interrupt: impl Future, + on_change: &mut dyn FnMut(&OrderView), +) -> Result { + let mut interrupt = pin!(interrupt); + let mut last = initial; + let mut transient_streak = 0u32; + let started = Instant::now(); + let mut paid_since: Option = None; + + loop { + match get_order(calls, gateway, id).await { + Ok(order) => { + transient_streak = 0; + if last.as_ref().map(|l| l.status) != Some(order.status) { + on_change(&order); + } + if order.status.is_terminal() { + return Ok(order); + } + if order.status == OrderStatus::Paid && paid_since.is_none() { + paid_since = Some(Instant::now()); + } + let overdue = match (order.status, order.expires_at_ns) { + (OrderStatus::Paid, _) => { + paid_since.is_some_and(|since| since.elapsed() > options.max_paid_wait) + } + (_, Some(expires_at_ns)) => { + now_ns() > expires_at_ns.saturating_add(as_ns(options.expiry_grace)) + } + (_, None) => started.elapsed() > options.max_without_expiry, + }; + if overdue { + return WaitTimedOutSnafu { + order: Box::new(order), + } + .fail(); + } + last = Some(order); + } + Err(CyclesPurchaseError::GetOrder { source, .. }) if is_transient(&source) => { + transient_streak += 1; + if transient_streak > options.max_consecutive_transient { + return Err(CyclesPurchaseError::WaitUnanswered { + gateway, + id: id.to_owned(), + source, + }); + } + } + Err(error) => return Err(error), + } + + select! { + _ = sleep(options.poll_interval) => {} + _ = &mut interrupt => { + let order = last.unwrap_or_else(|| OrderView { + id: id.to_owned(), + status: OrderStatus::Created, + locked_cycles: 0, + checkout_url: None, + expires_at_ns: None, + paid: None, + expired_by: None, + abandoned_reason: None, + }); + return InterruptedSnafu { order: Box::new(order) }.fail(); + } + } + } +} + +/// A rejection saying the gateway canister does not exist becomes +/// [`CyclesPurchaseError::GatewayNotFound`]; anything else goes to `wrap`. +fn map_absent( + source: TypedCallError, + gateway: Principal, + wrap: impl FnOnce(TypedCallError) -> CyclesPurchaseError, +) -> CyclesPurchaseError { + match &source { + TypedCallError::Call { source: call } if call.is_canister_not_found() => { + CyclesPurchaseError::GatewayNotFound { gateway } + } + _ => wrap(source), + } +} + +fn is_transient(error: &TypedCallError) -> bool { + matches!(error, TypedCallError::Call { source } if source.is_transient()) +} + +fn now_ns() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos().try_into().unwrap_or(i64::MAX)) + .unwrap_or(0) +} + +fn as_ns(duration: Duration) -> i64 { + duration.as_nanos().try_into().unwrap_or(i64::MAX) +} diff --git a/crates/icp-app/src/operations/cycles_purchase/money.rs b/crates/icp-app/src/operations/cycles_purchase/money.rs new file mode 100644 index 000000000..eb17a2952 --- /dev/null +++ b/crates/icp-app/src/operations/cycles_purchase/money.rs @@ -0,0 +1,274 @@ +//! A sum of money in a fiat currency, kept in minor units (cents) so no +//! arithmetic ever rounds. +//! +//! The gateway sells for USD only today, but nothing here assumes it: a new +//! currency is a new [`Currency`] variant, and the one place that encodes a +//! [`Money`] for the gateway matches on it exhaustively. + +use std::fmt; +use std::str::FromStr; + +use candid::Nat; +use num_traits::ToPrimitive; +use serde::{Serialize, Serializer}; +use snafu::Snafu; + +/// A fiat currency, by ISO 4217 code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Currency { + Usd, +} + +impl Currency { + /// The ISO 4217 code, e.g. `USD`. + pub fn code(self) -> &'static str { + match self { + Self::Usd => "USD", + } + } + + /// Decimal places in the major unit: `2` for a currency with cents. + pub fn exponent(self) -> u32 { + match self { + Self::Usd => 2, + } + } + + fn minor_per_major(self) -> u64 { + 10u64.pow(self.exponent()) + } +} + +impl fmt::Display for Currency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.code()) + } +} + +impl FromStr for Currency { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_uppercase().as_str() { + "USD" => Ok(Self::Usd), + other => Err(format!("unsupported currency '{other}'")), + } + } +} + +/// Why a decimal amount would not parse as [`Money`]. +#[derive(Debug, Snafu)] +pub enum ParseMoneyError { + #[snafu(display("amount '{input}' is not a decimal number"))] + NotDecimal { input: String }, + + #[snafu(display("amount '{input}' has more than {max} decimal places"))] + TooManyDecimals { input: String, max: u32 }, + + #[snafu(display("amount must be greater than zero"))] + Zero, + + #[snafu(display("amount '{input}' is too large"))] + Overflow { input: String }, +} + +/// An amount of a [`Currency`], in its minor unit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Money { + pub minor_units: u64, + pub currency: Currency, +} + +impl Money { + pub fn from_minor_units(minor_units: u64, currency: Currency) -> Self { + Self { + minor_units, + currency, + } + } + + /// A gateway-reported amount in minor units, or `None` when it does not + /// fit in a `u64`. + pub fn from_nat_minor_units(minor_units: &Nat, currency: Currency) -> Option { + minor_units + .0 + .to_u64() + .map(|minor_units| Self::from_minor_units(minor_units, currency)) + } + + /// Parse a decimal amount in major units: `10`, `12.5`, `12.50`, `1_000`. + /// + /// Rejects a sign, more decimal places than the currency has, and zero. + pub fn parse_decimal(input: &str, currency: Currency) -> Result { + let cleaned: String = input.trim().chars().filter(|c| *c != '_').collect(); + let (whole, fraction) = match cleaned.split_once('.') { + Some((whole, fraction)) => (whole, fraction), + None => (cleaned.as_str(), ""), + }; + let is_digits = |s: &str| s.chars().all(|c| c.is_ascii_digit()); + if cleaned.is_empty() + || (whole.is_empty() && fraction.is_empty()) + || !is_digits(whole) + || !is_digits(fraction) + { + return NotDecimalSnafu { input }.fail(); + } + let exponent = currency.exponent(); + if fraction.len() > exponent as usize { + return TooManyDecimalsSnafu { + input, + max: exponent, + } + .fail(); + } + + let whole: u64 = if whole.is_empty() { + 0 + } else { + whole.parse().ok().context_overflow(input)? + }; + let fraction: u64 = if fraction.is_empty() { + 0 + } else { + let padded = format!("{fraction:0 String { + let per_major = self.currency.minor_per_major(); + format!( + "{}.{:0>width$}", + self.minor_units / per_major, + self.minor_units % per_major, + width = self.currency.exponent() as usize + ) + } +} + +trait ContextOverflow { + fn context_overflow(self, input: &str) -> Result; +} + +impl ContextOverflow for Option { + fn context_overflow(self, input: &str) -> Result { + self.ok_or_else(|| ParseMoneyError::Overflow { + input: input.to_owned(), + }) + } +} + +/// `10.00 USD`: amount then code, as token amounts print `... ICP`. +impl fmt::Display for Money { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {}", self.to_decimal_string(), self.currency) + } +} + +/// `{"currency":"USD","value":"10.00"}`: the value is a string so no JSON +/// reader turns it into a float. +impl Serialize for Money { + fn serialize(&self, serializer: S) -> Result { + #[derive(Serialize)] + struct MoneyJson<'a> { + currency: &'a str, + value: String, + } + MoneyJson { + currency: self.currency.code(), + value: self.to_decimal_string(), + } + .serialize(serializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn usd(input: &str) -> Result { + Money::parse_decimal(input, Currency::Usd) + } + + #[test] + fn parses_whole_and_fractional_usd() { + assert_eq!(usd("10").unwrap().minor_units, 1000); + assert_eq!(usd("12.5").unwrap().minor_units, 1250); + assert_eq!(usd("12.50").unwrap().minor_units, 1250); + assert_eq!(usd("0.01").unwrap().minor_units, 1); + assert_eq!(usd(".5").unwrap().minor_units, 50); + assert_eq!(usd("1_000.00").unwrap().minor_units, 100_000); + assert_eq!(usd(" 7 ").unwrap().minor_units, 700); + } + + #[test] + fn rejects_bad_amounts() { + assert!(matches!( + usd("10.005"), + Err(ParseMoneyError::TooManyDecimals { max: 2, .. }) + )); + assert!(matches!(usd("-1"), Err(ParseMoneyError::NotDecimal { .. }))); + assert!(matches!( + usd("abc"), + Err(ParseMoneyError::NotDecimal { .. }) + )); + assert!(matches!(usd(""), Err(ParseMoneyError::NotDecimal { .. }))); + assert!(matches!(usd("."), Err(ParseMoneyError::NotDecimal { .. }))); + assert!(matches!( + usd("1.2.3"), + Err(ParseMoneyError::NotDecimal { .. }) + )); + assert!(matches!(usd("0"), Err(ParseMoneyError::Zero))); + assert!(matches!(usd("0.00"), Err(ParseMoneyError::Zero))); + assert!(matches!( + usd("99999999999999999999"), + Err(ParseMoneyError::Overflow { .. }) + )); + } + + #[test] + fn displays_and_serializes_money() { + let ten = Money::from_minor_units(1000, Currency::Usd); + assert_eq!(ten.to_string(), "10.00 USD"); + assert_eq!( + serde_json::to_string(&ten).unwrap(), + r#"{"currency":"USD","value":"10.00"}"# + ); + assert_eq!( + Money::from_minor_units(5, Currency::Usd).to_string(), + "0.05 USD" + ); + assert_eq!( + Money::from_minor_units(123_456, Currency::Usd).to_decimal_string(), + "1234.56" + ); + } + + #[test] + fn nat_conversion_bounds() { + assert_eq!( + Money::from_nat_minor_units(&Nat::from(59u8), Currency::Usd), + Some(Money::from_minor_units(59, Currency::Usd)) + ); + assert_eq!( + Money::from_nat_minor_units(&Nat::from(u128::MAX), Currency::Usd), + None + ); + } + + #[test] + fn currency_codes() { + assert_eq!("usd".parse::().unwrap(), Currency::Usd); + assert!("EUR".parse::().is_err()); + assert_eq!(Currency::Usd.to_string(), "USD"); + } +} diff --git a/crates/icp-app/src/operations/cycles_purchase/tests.rs b/crates/icp-app/src/operations/cycles_purchase/tests.rs new file mode 100644 index 000000000..8826c74e2 --- /dev/null +++ b/crates/icp-app/src/operations/cycles_purchase/tests.rs @@ -0,0 +1,698 @@ +use std::collections::VecDeque; +use std::future::{pending, ready}; +use std::sync::Mutex; +use std::time::Duration; + +use async_trait::async_trait; +use candid::{Decode, Encode, Int, Nat, Principal}; +use icp_canister_interfaces::cycles_gateway::{ + Account, Amount, CANCEL_ORDER_METHOD, CREATE_ORDER_METHOD, CancelOrderError, CancelOrderResult, + CreateOrderError, CreateOrderResult, CreatedOrder, Destination, GET_ORDER_METHOD, + LIST_ORDERS_METHOD, ListOrdersResult, Order, OrderStatus, QUOTE_PREVIEWS_METHOD, QuotePreview, + QuotePreviews, Reason, +}; +use icp_project::calls::{Authority, Call, CallError, CanisterCalls}; + +use super::*; + +const GATEWAY: &str = "saz2a-riaaa-aaaay-aadha-cai"; +const BUYER: &str = "rrkah-fqaaa-aaaaa-aaaaq-cai"; + +fn principal(text: &str) -> Principal { + Principal::from_text(text).unwrap() +} + +fn gateway() -> Principal { + principal(GATEWAY) +} + +fn usd(cents: u64) -> Money { + Money::from_minor_units(cents, Currency::Usd) +} + +fn order(id: &str, status: OrderStatus) -> Order { + Order { + id: id.to_owned(), + status, + locked_cycles: Nat::from(7_238_000_000_000u64), + stripe_session_url: Some(format!("https://checkout.example/{id}")), + // Far enough in the future that no test trips the expiry grace. + expires_at_ns: Some(Int::from(i64::MAX / 2)), + paid_usd_cents: None, + expired_by: None, + abandoned_reason: None, + destination: Destination::CyclesLedgerAccount(Account { + owner: principal(BUYER), + subaccount: None, + }), + } +} + +fn created(order: Order) -> CreateOrderResult { + CreateOrderResult::Ok(CreatedOrder { order }) +} + +fn refused(error: CreateOrderError) -> CreateOrderResult { + CreateOrderResult::Err(error) +} + +fn unanswered() -> CallError { + CallError::unanswered( + gateway(), + GET_ORDER_METHOD, + std::io::Error::other("connection reset"), + ) +} + +fn rejected(message: &str) -> CallError { + CallError::Rejected { + canister: gateway(), + method: GET_ORDER_METHOD.to_owned(), + code: Some("IC0503".to_owned()), + message: message.to_owned(), + } +} + +/// A cycles gateway whose replies are scripted per method. +#[derive(Default)] +struct FakeGateway { + /// The one preview `quote_previews` answers with; `None` answers with + /// no previews at all. + quote: Option, + /// One reply per `create_order` call, in order. + create: Mutex>, + /// One reply per `get_order` poll, in order. An `Err` is the network + /// failing to answer, or rejecting. + get_order: Mutex, CallError>>>, + /// Pages of `list_orders`, in cursor order. + list_orders: Vec>, + cancel: Option, + /// Every call made, as (method, candid argument). + calls_seen: Mutex)>>, + /// Rejects every call as if the canister did not exist. + absent: bool, +} + +impl FakeGateway { + fn with_quote(cents: u64, fee_cents: u64, cycles: Option) -> Self { + Self { + quote: Some(QuotePreview { + cycles: cycles.map(Nat::from), + fee_cents: Nat::from(fee_cents), + net_cents: Some(Nat::from(cents - fee_cents)), + usd_cents: Nat::from(cents), + }), + ..Self::default() + } + } + + fn polls(self, replies: Vec, CallError>>) -> Self { + *self.get_order.lock().unwrap() = replies.into(); + self + } + + fn creates(self, replies: Vec) -> Self { + *self.create.lock().unwrap() = replies.into(); + self + } + + fn arg_of(&self, method: &str) -> Vec { + self.calls_seen + .lock() + .unwrap() + .iter() + .find(|(m, _)| m == method) + .map(|(_, arg)| arg.clone()) + .unwrap_or_else(|| panic!("no call to {method}")) + } + + fn count_of(&self, method: &str) -> usize { + self.calls_seen + .lock() + .unwrap() + .iter() + .filter(|(m, _)| m == method) + .count() + } + + fn record(&self, call: &Call) -> Result<(), CallError> { + assert_eq!(call.canister, gateway()); + self.calls_seen + .lock() + .unwrap() + .push((call.method.clone(), call.arg.clone())); + if self.absent { + return Err(CallError::Rejected { + canister: call.canister, + method: call.method.clone(), + code: Some("IC0301".to_owned()), + message: format!("Canister {GATEWAY} not found"), + }); + } + Ok(()) + } +} + +#[async_trait] +impl CanisterCalls for FakeGateway { + fn caller(&self) -> Principal { + principal(BUYER) + } + + async fn update(&self, call: Call) -> Result, CallError> { + self.record(&call)?; + match call.method.as_str() { + CREATE_ORDER_METHOD => { + let reply = self + .create + .lock() + .unwrap() + .pop_front() + .expect("ran out of scripted create_order replies"); + Ok(Encode!(&reply).unwrap()) + } + CANCEL_ORDER_METHOD => { + Ok(Encode!(self.cancel.as_ref().expect("no cancel reply")).unwrap()) + } + other => panic!("unexpected update {other}"), + } + } + + async fn query(&self, call: Call) -> Result, CallError> { + self.record(&call)?; + match call.method.as_str() { + QUOTE_PREVIEWS_METHOD => { + let previews = QuotePreviews { + quotes: self.quote.iter().cloned().collect(), + }; + Ok(Encode!(&previews).unwrap()) + } + GET_ORDER_METHOD => { + let reply = self + .get_order + .lock() + .unwrap() + .pop_front() + .expect("ran out of scripted get_order replies")?; + Ok(Encode!(&reply).unwrap()) + } + LIST_ORDERS_METHOD => { + let (cursor, _limit) = Decode!(&call.arg, Option, Nat).unwrap(); + let index: usize = cursor.map(|c| c.parse().unwrap()).unwrap_or(0); + let page = ListOrdersResult { + next_cursor: (index + 1 < self.list_orders.len()) + .then(|| (index + 1).to_string()), + orders: self.list_orders.get(index).cloned().unwrap_or_default(), + }; + Ok(Encode!(&page).unwrap()) + } + other => panic!("unexpected query {other}"), + } + } + + async fn metadata_section( + &self, + _canister: Principal, + _path: &str, + _authority: Authority, + ) -> Result>, CallError> { + unimplemented!() + } + + async fn controllers(&self, _canister: Principal) -> Result>, CallError> { + unimplemented!() + } + + async fn module_hash(&self, _canister: Principal) -> Result>, CallError> { + unimplemented!() + } + + async fn subnet_of(&self, _canister: Principal) -> Result { + unimplemented!() + } + + async fn subnet_uses_engine_operator(&self, _subnet: Principal) -> Result { + unimplemented!() + } +} + +fn fast() -> WaitOptions { + WaitOptions { + poll_interval: Duration::from_millis(1), + ..WaitOptions::default() + } +} + +fn ten_dollars() -> Quote { + Quote { + amount: usd(1000), + fee: usd(59), + cycles: 7_238_000_000_000, + } +} + +// --- quote ----------------------------------------------------------------- + +#[tokio::test] +async fn quote_maps_preview_to_quote() { + let fake = FakeGateway::with_quote(1000, 59, Some(7_238_000_000_000)); + let q = quote(&fake, gateway(), usd(1000)).await.unwrap(); + assert_eq!(q, ten_dollars()); + assert_eq!(q.minimum_cycles(), 7_238_000_000_000 * 95 / 100); + let amounts = Decode!(&fake.arg_of(QUOTE_PREVIEWS_METHOD), Vec).unwrap(); + assert_eq!(amounts, vec![Nat::from(1000u32)]); +} + +#[tokio::test] +async fn quote_without_rate_is_rate_unavailable() { + let fake = FakeGateway::with_quote(1000, 59, None); + let err = quote(&fake, gateway(), usd(1000)).await.unwrap_err(); + assert!( + matches!(err, CyclesPurchaseError::RateUnavailable { .. }), + "{err}" + ); +} + +#[tokio::test] +async fn quote_without_preview_is_missing() { + let fake = FakeGateway::default(); + let err = quote(&fake, gateway(), usd(1000)).await.unwrap_err(); + assert!( + matches!(err, CyclesPurchaseError::QuoteMissing { .. }), + "{err}" + ); +} + +#[tokio::test] +async fn quote_on_missing_gateway_is_gateway_not_found() { + let fake = FakeGateway { + absent: true, + ..FakeGateway::default() + }; + let err = quote(&fake, gateway(), usd(1000)).await.unwrap_err(); + assert!( + matches!(err, CyclesPurchaseError::GatewayNotFound { .. }), + "{err}" + ); +} + +// --- place_order ----------------------------------------------------------- + +#[tokio::test] +async fn place_order_sends_caller_destination_and_min_cycles() { + let fake = FakeGateway::default().creates(vec![created(order("o1", OrderStatus::Created))]); + let view = place_order(&fake, gateway(), &ten_dollars()).await.unwrap(); + assert_eq!(view.id, "o1"); + assert_eq!(view.status, OrderStatus::Created); + assert_eq!(view.locked_cycles, 7_238_000_000_000); + assert_eq!( + view.checkout_url.as_deref(), + Some("https://checkout.example/o1") + ); + + let (amount, destination, min) = Decode!( + &fake.arg_of(CREATE_ORDER_METHOD), + Amount, + Destination, + Option + ) + .unwrap(); + assert_eq!(amount, Amount::Custom(Nat::from(1000u32))); + assert_eq!( + destination, + Destination::CyclesLedgerAccount(Account { + owner: principal(BUYER), + subaccount: None, + }) + ); + assert_eq!(min, Some(Nat::from(7_238_000_000_000u128 * 95 / 100))); +} + +#[tokio::test] +async fn place_order_on_missing_gateway_is_gateway_not_found() { + let fake = FakeGateway { + absent: true, + ..FakeGateway::default() + }; + let err = place_order(&fake, gateway(), &ten_dollars()) + .await + .unwrap_err(); + assert!( + matches!(err, CyclesPurchaseError::GatewayNotFound { .. }), + "{err}" + ); +} + +#[tokio::test] +async fn too_many_open_orders_resolves_via_list_orders() { + let too_many = refused(CreateOrderError::NotAdmitted(Reason::TooManyOpenOrders { + max: Nat::from(1u8), + open: Nat::from(1u8), + })); + let fake = FakeGateway { + list_orders: vec![vec![ + order("old", OrderStatus::Cancelled), + order("open", OrderStatus::Paid), + ]], + ..FakeGateway::default() + } + .creates(vec![too_many]); + + let err = place_order(&fake, gateway(), &ten_dollars()) + .await + .unwrap_err(); + match err { + CyclesPurchaseError::OpenOrderExists { order, open, max } => { + assert_eq!(order.id, "open"); + assert_eq!(order.status, OrderStatus::Paid); + assert_eq!((open, max), (1, 1)); + } + other => panic!("unexpected {other:?}"), + } +} + +#[tokio::test] +async fn too_many_open_orders_without_a_visible_one_is_a_refusal() { + let too_many = refused(CreateOrderError::NotAdmitted(Reason::TooManyOpenOrders { + max: Nat::from(1u8), + open: Nat::from(1u8), + })); + let fake = FakeGateway { + list_orders: vec![vec![order("old", OrderStatus::Delivered)]], + ..FakeGateway::default() + } + .creates(vec![too_many]); + + let err = place_order(&fake, gateway(), &ten_dollars()) + .await + .unwrap_err(); + assert!( + matches!( + err, + CyclesPurchaseError::OrderRefused { + error: CreateOrderError::NotAdmitted(Reason::TooManyOpenOrders { .. }) + } + ), + "{err}" + ); +} + +#[tokio::test] +async fn quote_changed_is_surfaced_as_refusal() { + let fake = FakeGateway::default().creates(vec![refused(CreateOrderError::QuoteChanged { + minimum: Nat::from(10u8), + quoted: Nat::from(9u8), + })]); + let err = place_order(&fake, gateway(), &ten_dollars()) + .await + .unwrap_err(); + assert!( + matches!( + err, + CyclesPurchaseError::OrderRefused { + error: CreateOrderError::QuoteChanged { .. } + } + ), + "{err}" + ); +} + +// --- get_order / find_open_order / cancel_order ---------------------------- + +#[tokio::test] +async fn get_order_none_is_order_not_found() { + let fake = FakeGateway::default().polls(vec![Ok(None)]); + let err = get_order(&fake, gateway(), "ghost").await.unwrap_err(); + match err { + CyclesPurchaseError::OrderNotFound { id, caller, .. } => { + assert_eq!(id, "ghost"); + assert_eq!(caller, principal(BUYER)); + } + other => panic!("unexpected {other:?}"), + } +} + +#[tokio::test] +async fn find_open_order_follows_cursor() { + let fake = FakeGateway { + list_orders: vec![ + vec![order("a", OrderStatus::Delivered)], + vec![ + order("b", OrderStatus::Expired), + order("c", OrderStatus::Created), + ], + ], + ..FakeGateway::default() + }; + let found = find_open_order(&fake, gateway()).await.unwrap().unwrap(); + assert_eq!(found.id, "c"); + assert_eq!(fake.count_of(LIST_ORDERS_METHOD), 2); +} + +#[tokio::test] +async fn find_open_order_none_when_all_settled() { + let fake = FakeGateway { + list_orders: vec![vec![order("a", OrderStatus::Delivered)]], + ..FakeGateway::default() + }; + assert!(find_open_order(&fake, gateway()).await.unwrap().is_none()); +} + +#[tokio::test] +async fn cancel_order_returns_the_order() { + let fake = FakeGateway { + cancel: Some(CancelOrderResult::Ok(Box::new(order( + "o1", + OrderStatus::Cancelled, + )))), + ..FakeGateway::default() + }; + let view = cancel_order(&fake, gateway(), "o1").await.unwrap(); + assert_eq!(view.status, OrderStatus::Cancelled); + let id = Decode!(&fake.arg_of(CANCEL_ORDER_METHOD), String).unwrap(); + assert_eq!(id, "o1"); +} + +#[tokio::test] +async fn cancel_order_maps_refusal() { + let fake = FakeGateway { + cancel: Some(CancelOrderResult::Err(CancelOrderError::NotCancellable { + status: OrderStatus::Delivered, + })), + ..FakeGateway::default() + }; + let err = cancel_order(&fake, gateway(), "o1").await.unwrap_err(); + assert!( + matches!( + err, + CyclesPurchaseError::CancelRefused { + error: CancelOrderError::NotCancellable { + status: OrderStatus::Delivered + }, + .. + } + ), + "{err}" + ); +} + +// --- wait_for_order -------------------------------------------------------- + +async fn wait( + fake: &FakeGateway, + initial: Option, + options: &WaitOptions, +) -> (Result, Vec) { + let mut seen = Vec::new(); + let result = wait_for_order( + fake, + gateway(), + "o1", + initial, + options, + pending(), + &mut |o| seen.push(o.status), + ) + .await; + (result, seen) +} + +#[tokio::test] +async fn wait_happy_path_created_paid_delivered() { + let fake = FakeGateway::default().polls(vec![ + Ok(Some(order("o1", OrderStatus::Created))), + Ok(Some(order("o1", OrderStatus::Created))), + Ok(Some(order("o1", OrderStatus::Paid))), + Ok(Some(order("o1", OrderStatus::Delivered))), + ]); + let (result, seen) = wait(&fake, None, &fast()).await; + assert_eq!(result.unwrap().status, OrderStatus::Delivered); + assert_eq!( + seen, + vec![ + OrderStatus::Created, + OrderStatus::Paid, + OrderStatus::Delivered + ] + ); + assert_eq!(fake.count_of(GET_ORDER_METHOD), 4); +} + +#[tokio::test] +async fn wait_does_not_report_the_initial_status_again() { + let fake = FakeGateway::default().polls(vec![ + Ok(Some(order("o1", OrderStatus::Created))), + Ok(Some(order("o1", OrderStatus::Delivered))), + ]); + let initial: OrderView = order("o1", OrderStatus::Created).try_into().unwrap(); + let (result, seen) = wait(&fake, Some(initial), &fast()).await; + assert_eq!(result.unwrap().status, OrderStatus::Delivered); + assert_eq!(seen, vec![OrderStatus::Delivered]); +} + +#[tokio::test] +async fn wait_returns_every_terminal_status_as_ok() { + for status in [ + OrderStatus::Expired, + OrderStatus::Cancelled, + OrderStatus::Abandoned, + OrderStatus::NeedsReview, + ] { + let fake = FakeGateway::default().polls(vec![Ok(Some(order("o1", status)))]); + let (result, seen) = wait(&fake, None, &fast()).await; + assert_eq!(result.unwrap().status, status); + assert_eq!(seen, vec![status]); + } +} + +#[tokio::test] +async fn wait_tolerates_transient_errors() { + let fake = FakeGateway::default().polls(vec![ + Err(unanswered()), + Err(unanswered()), + Ok(Some(order("o1", OrderStatus::Created))), + Err(unanswered()), + Err(unanswered()), + Ok(Some(order("o1", OrderStatus::Delivered))), + ]); + let options = WaitOptions { + max_consecutive_transient: 2, + ..fast() + }; + let (result, _) = wait(&fake, None, &options).await; + assert_eq!(result.unwrap().status, OrderStatus::Delivered); +} + +#[tokio::test] +async fn wait_gives_up_after_consecutive_transient_errors() { + let fake = FakeGateway::default().polls(vec![ + Err(unanswered()), + Err(unanswered()), + Err(unanswered()), + ]); + let options = WaitOptions { + max_consecutive_transient: 2, + ..fast() + }; + let (result, _) = wait(&fake, None, &options).await; + let err = result.unwrap_err(); + assert!( + matches!(err, CyclesPurchaseError::WaitUnanswered { .. }), + "{err}" + ); + assert_eq!(fake.count_of(GET_ORDER_METHOD), 3); +} + +#[tokio::test] +async fn wait_fails_fast_on_non_transient_error() { + let fake = FakeGateway::default().polls(vec![Err(rejected("canister trapped"))]); + let (result, _) = wait(&fake, None, &fast()).await; + let err = result.unwrap_err(); + assert!(matches!(err, CyclesPurchaseError::GetOrder { .. }), "{err}"); + assert_eq!(fake.count_of(GET_ORDER_METHOD), 1); +} + +#[tokio::test] +async fn wait_not_found_is_order_not_found() { + let fake = FakeGateway::default().polls(vec![Ok(None)]); + let (result, _) = wait(&fake, None, &fast()).await; + assert!(matches!( + result.unwrap_err(), + CyclesPurchaseError::OrderNotFound { .. } + )); +} + +#[tokio::test] +async fn wait_times_out_after_expiry_grace() { + let mut expired_long_ago = order("o1", OrderStatus::Created); + expired_long_ago.expires_at_ns = Some(Int::from(1_000_000_000i64)); + let fake = FakeGateway::default().polls(vec![Ok(Some(expired_long_ago))]); + let options = WaitOptions { + expiry_grace: Duration::ZERO, + ..fast() + }; + let (result, seen) = wait(&fake, None, &options).await; + match result.unwrap_err() { + CyclesPurchaseError::WaitTimedOut { order } => { + assert_eq!(order.status, OrderStatus::Created); + } + other => panic!("unexpected {other:?}"), + } + // The status was still reported before giving up. + assert_eq!(seen, vec![OrderStatus::Created]); +} + +#[tokio::test] +async fn wait_times_out_without_expiry() { + let mut no_deadline = order("o1", OrderStatus::Created); + no_deadline.expires_at_ns = None; + let fake = FakeGateway::default().polls(vec![Ok(Some(no_deadline))]); + let options = WaitOptions { + max_without_expiry: Duration::ZERO, + ..fast() + }; + let (result, _) = wait(&fake, None, &options).await; + assert!(matches!( + result.unwrap_err(), + CyclesPurchaseError::WaitTimedOut { .. } + )); +} + +#[tokio::test] +async fn wait_times_out_on_a_stuck_paid_order() { + let fake = FakeGateway::default().polls(vec![ + Ok(Some(order("o1", OrderStatus::Paid))), + Ok(Some(order("o1", OrderStatus::Paid))), + ]); + let options = WaitOptions { + max_paid_wait: Duration::ZERO, + ..fast() + }; + let (result, _) = wait(&fake, None, &options).await; + match result.unwrap_err() { + CyclesPurchaseError::WaitTimedOut { order } => assert_eq!(order.status, OrderStatus::Paid), + other => panic!("unexpected {other:?}"), + } +} + +#[tokio::test] +async fn wait_interrupted_returns_last_order() { + let fake = FakeGateway::default().polls(vec![Ok(Some(order("o1", OrderStatus::Paid)))]); + let result = wait_for_order( + &fake, + gateway(), + "o1", + None, + &WaitOptions::default(), + ready(()), + &mut |_| {}, + ) + .await; + match result.unwrap_err() { + CyclesPurchaseError::Interrupted { order } => { + assert_eq!(order.id, "o1"); + assert_eq!(order.status, OrderStatus::Paid); + } + other => panic!("unexpected {other:?}"), + } +} diff --git a/crates/icp-app/src/operations/mod.rs b/crates/icp-app/src/operations/mod.rs index f4e191327..189cace62 100644 --- a/crates/icp-app/src/operations/mod.rs +++ b/crates/icp-app/src/operations/mod.rs @@ -2,5 +2,6 @@ //! ledger, rather than on something a project manifest describes. pub mod canister_migration; +pub mod cycles_purchase; pub mod snapshot_transfer; pub mod token; diff --git a/crates/icp-app/src/operations/token/mod.rs b/crates/icp-app/src/operations/token/mod.rs index 6d74a49fa..4c31e60fb 100644 --- a/crates/icp-app/src/operations/token/mod.rs +++ b/crates/icp-app/src/operations/token/mod.rs @@ -49,3 +49,43 @@ fn format_integer_with_underscores(amount: &BigDecimal) -> String { // Fallback to plain string if conversion fails amount.to_string() } + +/// Cycles for a human: `7.238T cycles`, `512.5B cycles`, `42 cycles`. +/// +/// Trillions and billions are shown to at most three decimals with trailing +/// zeros dropped; anything smaller is the exact count. Output meant to be +/// parsed should carry the exact count instead. +pub fn format_cycles(cycles: u128) -> String { + const TRILLION: u128 = 1_000_000_000_000; + const BILLION: u128 = 1_000_000_000; + match cycles { + c if c >= TRILLION => format!("{}T cycles", scaled(c, TRILLION)), + c if c >= BILLION => format!("{}B cycles", scaled(c, BILLION)), + c => format!("{} cycles", Nat::from(c)), + } +} + +/// `cycles / unit` to three decimals, trailing zeros and a bare point dropped. +fn scaled(cycles: u128, unit: u128) -> String { + let thousandths = cycles * 1000 / unit; + let s = format!("{}.{:03}", thousandths / 1000, thousandths % 1000); + s.trim_end_matches('0').trim_end_matches('.').to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_cycles_trillions_and_billions() { + assert_eq!(format_cycles(7_238_000_000_000), "7.238T cycles"); + assert_eq!(format_cycles(7_000_000_000_000), "7T cycles"); + assert_eq!(format_cycles(7_200_000_000_000), "7.2T cycles"); + assert_eq!(format_cycles(7_238_499_999_999), "7.238T cycles"); + assert_eq!(format_cycles(512_500_000_000), "512.5B cycles"); + assert_eq!(format_cycles(1_000_000_000), "1B cycles"); + assert_eq!(format_cycles(999_999_999), "999_999_999 cycles"); + assert_eq!(format_cycles(42), "42 cycles"); + assert_eq!(format_cycles(0), "0 cycles"); + } +} diff --git a/crates/icp-canister-interfaces/src/cycles_gateway.rs b/crates/icp-canister-interfaces/src/cycles_gateway.rs new file mode 100644 index 000000000..c2222015f --- /dev/null +++ b/crates/icp-canister-interfaces/src/cycles_gateway.rs @@ -0,0 +1,595 @@ +//! Interface to a **cycles gateway**: a canister that sells cycles for a card +//! payment. The buyer creates an order as itself, pays on the hosted checkout +//! page the order carries, and the gateway transfers the locked cycles to the +//! buyer's own cycles-ledger account once the payment settles. +//! +//! "Gateway" names the role the canister plays. To the CLI it is a canister +//! id: every interaction here is an ordinary query or update call against it. +//! The gateway is a Motoko canister, so field names are camelCase and variant +//! tags are lowercase on the wire; the Rust names below are idiomatic and +//! renamed with `serde`. +//! +//! Only the fields and methods the CLI reads are declared. Candid lets the +//! canister report more in a record than the client names, so `Order` leaves +//! out pricing, problem and audit fields the CLI has no use for. + +use std::env::{self, VarError}; +use std::fmt; + +use candid::{CandidType, Int, Nat, Principal}; +use serde::Deserialize; + +/// Default cycles gateway canister: the CyclePay backend on mainnet. +/// +/// Overridable at runtime via the [`CYCLES_GATEWAY_CANISTER_ID_ENV`] +/// environment variable — see [`cycles_gateway_canister_id`]. +pub const CYCLES_GATEWAY_CID: &str = "saz2a-riaaa-aaaay-aadha-cai"; + +/// Environment variable that overrides [`CYCLES_GATEWAY_CID`]. +pub const CYCLES_GATEWAY_CANISTER_ID_ENV: &str = "ICP_CYCLES_GATEWAY_CANISTER_ID"; + +/// Public query pricing a list of amounts: `(vec nat) -> (QuotePreviews)`. +/// Amounts are USD cents. Computed by the same function that prices an order. +pub const QUOTE_PREVIEWS_METHOD: &str = "quote_previews"; + +/// Update creating an order for the caller: +/// `(Amount, Destination, opt nat) -> (CreateOrderResult)`. +pub const CREATE_ORDER_METHOD: &str = "create_order"; + +/// Owner-scoped query: `(text) -> (opt Order)`. `null` for an order the +/// caller does not own, as well as for one that does not exist. +pub const GET_ORDER_METHOD: &str = "get_order"; + +/// Update cancelling one of the caller's open orders: +/// `(text) -> (CancelOrderResult)`. +pub const CANCEL_ORDER_METHOD: &str = "cancel_order"; + +/// Caller-scoped, paginated query over the caller's own orders: +/// `(opt text, nat) -> (ListOrdersResult)`. +pub const LIST_ORDERS_METHOD: &str = "list_orders"; + +/// Resolve the cycles gateway canister id to talk to. +/// +/// Uses the value of the [`CYCLES_GATEWAY_CANISTER_ID_ENV`] variable when set +/// and non-empty, otherwise falls back to [`CYCLES_GATEWAY_CID`]. Returns an +/// error when the override is set but invalid — either not a valid principal, +/// or not valid Unicode. Only an absent or empty (whitespace-only) variable +/// uses the default: a configured-but-invalid value must never silently route +/// a payment to the built-in gateway. +pub fn cycles_gateway_canister_id() -> Result { + resolve_cycles_gateway_canister_id(env::var(CYCLES_GATEWAY_CANISTER_ID_ENV)) +} + +/// [`cycles_gateway_canister_id`] with the environment read out, so the +/// precedence can be tested without touching the process environment. +fn resolve_cycles_gateway_canister_id(env: Result) -> Result { + match env { + Ok(value) if !value.trim().is_empty() => Principal::from_text(value.trim()) + .map_err(|e| format!("invalid {CYCLES_GATEWAY_CANISTER_ID_ENV}: {e}")), + Err(VarError::NotUnicode(_)) => Err(format!( + "invalid {CYCLES_GATEWAY_CANISTER_ID_ENV}: not valid Unicode" + )), + Ok(_) | Err(VarError::NotPresent) => Ok(Principal::from_text(CYCLES_GATEWAY_CID) + .expect("CYCLES_GATEWAY_CID is a valid principal")), + } +} + +/// An order's id, as the gateway mints it. +pub type OrderId = String; + +/// An ICRC-1 account on the cycles ledger. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub struct Account { + pub owner: Principal, + pub subaccount: Option>, +} + +/// Where the gateway delivers the cycles. The gateway refuses any account the +/// caller does not own. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub enum Destination { + #[serde(rename = "cyclesLedgerAccount")] + CyclesLedgerAccount(Account), +} + +/// What the buyer pays: a custom amount in USD cents, or a preset tier by id. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub enum Amount { + #[serde(rename = "custom")] + Custom(Nat), + #[serde(rename = "tier")] + Tier(String), +} + +/// An order's lifecycle state. +#[derive(Clone, Copy, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub enum OrderStatus { + /// Created and payable until its checkout session's deadline. + #[serde(rename = "created")] + Created, + /// Paid; the cycles transfer is pending. + #[serde(rename = "paid")] + Paid, + /// The cycles reached the destination account. + #[serde(rename = "delivered")] + Delivered, + /// The checkout session ended without a payment. + #[serde(rename = "expired")] + Expired, + /// Cancelled by its owner. + #[serde(rename = "cancelled")] + Cancelled, + /// Paid, but delivery could not complete; an operator must resolve it. + #[serde(rename = "needsReview")] + NeedsReview, + /// Given up on by an operator. + #[serde(rename = "abandoned")] + Abandoned, +} + +impl OrderStatus { + /// Whether the order can still change. Only a created or paid order can. + pub fn is_terminal(self) -> bool { + !matches!(self, Self::Created | Self::Paid) + } + + /// The status as the gateway spells it on the wire, e.g. `needsReview`. + /// For output meant to be parsed; [`fmt::Display`] is for people. + pub fn tag(self) -> &'static str { + match self { + Self::Created => "created", + Self::Paid => "paid", + Self::Delivered => "delivered", + Self::Expired => "expired", + Self::Cancelled => "cancelled", + Self::NeedsReview => "needsReview", + Self::Abandoned => "abandoned", + } + } +} + +impl fmt::Display for OrderStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Created => "created", + Self::Paid => "paid", + Self::Delivered => "delivered", + Self::Expired => "expired", + Self::Cancelled => "cancelled", + Self::NeedsReview => "needs review", + Self::Abandoned => "abandoned", + }) + } +} + +/// Why an order expired. +#[derive(Clone, Copy, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub enum ExpiredBy { + /// The checkout session reached its deadline unpaid. + #[serde(rename = "sessionExpired")] + SessionExpired, + /// The checkout session failed. + #[serde(rename = "sessionFailed")] + SessionFailed, +} + +impl fmt::Display for ExpiredBy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::SessionExpired => "the checkout session expired unpaid", + Self::SessionFailed => "the checkout session failed", + }) + } +} + +/// An order, as the gateway reports it. Only the fields the CLI reads. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct Order { + pub id: OrderId, + pub status: OrderStatus, + /// The cycles promised to the buyer, fixed at creation. + #[serde(rename = "lockedCycles")] + pub locked_cycles: Nat, + /// The hosted checkout page. Set while the order is payable. + #[serde(rename = "stripeSessionUrl")] + pub stripe_session_url: Option, + /// When the checkout session stops accepting payment, in nanoseconds + /// since the Unix epoch. + #[serde(rename = "expiresAtNs")] + pub expires_at_ns: Option, + /// What the card was charged, in USD cents, once paid. + #[serde(rename = "paidUsdCents")] + pub paid_usd_cents: Option, + #[serde(rename = "expiredBy")] + pub expired_by: Option, + #[serde(rename = "abandonedReason")] + pub abandoned_reason: Option, + pub destination: Destination, +} + +/// The price of one amount, as [`QUOTE_PREVIEWS_METHOD`] reports it. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct QuotePreview { + /// The cycles this amount buys. `None` when the gateway has no rate. + pub cycles: Option, + /// Card-processing fee, in USD cents. + #[serde(rename = "feeCents")] + pub fee_cents: Nat, + /// The amount net of fees, in USD cents. `None` when fees exceed it. + #[serde(rename = "netCents")] + pub net_cents: Option, + /// The amount quoted, in USD cents. + #[serde(rename = "usdCents")] + pub usd_cents: Nat, +} + +/// Result of [`QUOTE_PREVIEWS_METHOD`]: one preview per amount asked for. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct QuotePreviews { + pub quotes: Vec, +} + +/// Why the gateway would not admit an order. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub enum Reason { + #[serde(rename = "amountAboveMax")] + AmountAboveMax { + #[serde(rename = "maxUsdCents")] + max_usd_cents: Nat, + #[serde(rename = "usdCents")] + usd_cents: Nat, + }, + #[serde(rename = "amountBelowMin")] + AmountBelowMin { + #[serde(rename = "minUsdCents")] + min_usd_cents: Nat, + #[serde(rename = "usdCents")] + usd_cents: Nat, + }, + /// The gateway sells only to allow-listed principals (simulation mode). + #[serde(rename = "buyerNotAllowed")] + BuyerNotAllowed, + #[serde(rename = "canisterCyclesLow")] + CanisterCyclesLow { balance: Nat, min: Nat }, + #[serde(rename = "reserveShort")] + ReserveShort { available: Nat, requested: Nat }, + #[serde(rename = "tooManyOpenOrders")] + TooManyOpenOrders { max: Nat, open: Nat }, + #[serde(rename = "unboundedGiveaway")] + UnboundedGiveaway { + #[serde(rename = "reserveFloor")] + reserve_floor: Nat, + }, +} + +impl fmt::Display for Reason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AmountAboveMax { + max_usd_cents, + usd_cents, + } => write!( + f, + "amount {usd_cents} USD cents is above the maximum {max_usd_cents} USD cents" + ), + Self::AmountBelowMin { + min_usd_cents, + usd_cents, + } => write!( + f, + "amount {usd_cents} USD cents is below the minimum {min_usd_cents} USD cents" + ), + Self::BuyerNotAllowed => write!(f, "buyer not allowed"), + Self::CanisterCyclesLow { balance, min } => write!( + f, + "gateway cycles balance {balance} is below its minimum {min}" + ), + Self::ReserveShort { + available, + requested, + } => write!( + f, + "gateway reserve short: {requested} cycles requested, {available} available" + ), + Self::TooManyOpenOrders { max, open } => { + write!(f, "too many open orders: {open} open, {max} allowed") + } + Self::UnboundedGiveaway { reserve_floor } => write!( + f, + "gateway reserve floor {reserve_floor} is not set up for sales" + ), + } + } +} + +/// Why [`CREATE_ORDER_METHOD`] refused. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub enum CreateOrderError { + #[serde(rename = "anonymous")] + Anonymous, + #[serde(rename = "cancelledDuringCreation")] + CancelledDuringCreation, + #[serde(rename = "destinationNotOwned")] + DestinationNotOwned, + #[serde(rename = "idGeneration")] + IdGeneration, + #[serde(rename = "notAdmitted")] + NotAdmitted(Reason), + /// The rate moved against the buyer between quote and creation: the order + /// would deliver `quoted`, below the `minimum` the buyer pinned. + #[serde(rename = "quoteChanged")] + QuoteChanged { minimum: Nat, quoted: Nat }, + #[serde(rename = "rateUnavailable")] + RateUnavailable, + #[serde(rename = "reserveUnavailable")] + ReserveUnavailable, + #[serde(rename = "sessionUnavailable")] + SessionUnavailable(String), + #[serde(rename = "simulationScaleTooSmall")] + SimulationScaleTooSmall { + #[serde(rename = "ledgerFee")] + ledger_fee: Nat, + #[serde(rename = "scaledCycles")] + scaled_cycles: Nat, + }, + #[serde(rename = "tierBelowFees")] + TierBelowFees(String), + #[serde(rename = "unknownTier")] + UnknownTier(String), +} + +impl fmt::Display for CreateOrderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Anonymous => write!(f, "anonymous callers cannot buy"), + Self::CancelledDuringCreation => write!(f, "cancelled during creation"), + Self::DestinationNotOwned => write!(f, "destination account not owned by the caller"), + Self::IdGeneration => write!(f, "the gateway could not generate an order id"), + Self::NotAdmitted(reason) => write!(f, "not admitted: {reason}"), + Self::QuoteChanged { minimum, quoted } => write!( + f, + "quote changed: {quoted} cycles would be delivered, below the {minimum} asked for" + ), + Self::RateUnavailable => write!(f, "no exchange rate available"), + Self::ReserveUnavailable => write!(f, "the gateway reserve is unavailable"), + Self::SessionUnavailable(message) => { + write!(f, "checkout session unavailable: {message}") + } + Self::SimulationScaleTooSmall { + ledger_fee, + scaled_cycles, + } => write!( + f, + "simulation scale too small: {scaled_cycles} cycles would not cover the {ledger_fee} ledger fee" + ), + Self::TierBelowFees(tier) => write!(f, "tier '{tier}' does not cover its fees"), + Self::UnknownTier(tier) => write!(f, "unknown tier '{tier}'"), + } + } +} + +/// Why [`CANCEL_ORDER_METHOD`] refused. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub enum CancelOrderError { + #[serde(rename = "alreadyExpired")] + AlreadyExpired, + #[serde(rename = "credentialsRefused")] + CredentialsRefused, + #[serde(rename = "notCancellable")] + NotCancellable { status: OrderStatus }, + #[serde(rename = "notFound")] + NotFound, + #[serde(rename = "sessionNotClosed")] + SessionNotClosed, + #[serde(rename = "settledInFlight")] + SettledInFlight { status: OrderStatus }, + #[serde(rename = "stripeUnavailable")] + StripeUnavailable, +} + +impl fmt::Display for CancelOrderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AlreadyExpired => write!(f, "the order has already expired"), + Self::CredentialsRefused => { + write!(f, "the payment processor refused the gateway's credentials") + } + Self::NotCancellable { status } => { + write!(f, "an order with status '{status}' cannot be cancelled") + } + Self::NotFound => write!(f, "order not found"), + Self::SessionNotClosed => write!(f, "the checkout session could not be closed"), + Self::SettledInFlight { status } => write!( + f, + "the order settled while being cancelled (status '{status}')" + ), + Self::StripeUnavailable => write!(f, "the payment processor is unavailable"), + } + } +} + +/// The `ok` payload of [`CREATE_ORDER_METHOD`]. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct CreatedOrder { + pub order: Order, +} + +/// Result of [`CREATE_ORDER_METHOD`]. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum CreateOrderResult { + #[serde(rename = "ok")] + Ok(CreatedOrder), + #[serde(rename = "err")] + Err(CreateOrderError), +} + +/// Result of [`CANCEL_ORDER_METHOD`]. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum CancelOrderResult { + #[serde(rename = "ok")] + Ok(Box), + #[serde(rename = "err")] + Err(CancelOrderError), +} + +/// One page of [`LIST_ORDERS_METHOD`]. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct ListOrdersResult { + /// The cursor for the next page, or `None` on the last one. + #[serde(rename = "nextCursor")] + pub next_cursor: Option, + pub orders: Vec, +} + +#[cfg(test)] +mod tests { + use candid::{Decode, Encode}; + + use super::*; + + #[test] + fn default_gateway_canister_id_parses() { + assert_eq!( + resolve_cycles_gateway_canister_id(Err(VarError::NotPresent)).unwrap(), + Principal::from_text(CYCLES_GATEWAY_CID).unwrap() + ); + assert_eq!( + resolve_cycles_gateway_canister_id(Ok(" ".to_string())).unwrap(), + Principal::from_text(CYCLES_GATEWAY_CID).unwrap() + ); + } + + #[test] + fn environment_overrides_default_gateway_canister() { + let other = "rrkah-fqaaa-aaaaa-aaaaq-cai"; + assert_eq!( + resolve_cycles_gateway_canister_id(Ok(other.to_string())).unwrap(), + Principal::from_text(other).unwrap() + ); + } + + #[test] + fn invalid_environment_override_is_an_error() { + let err = + resolve_cycles_gateway_canister_id(Ok("not-a-principal".to_string())).unwrap_err(); + assert!(err.contains(CYCLES_GATEWAY_CANISTER_ID_ENV), "{err}"); + } + + /// The gateway's `Order` carries more fields than the CLI names; the + /// trimmed struct must still decode from the full wire record. + #[test] + fn trimmed_order_decodes_from_full_wire_record() { + #[derive(CandidType, Deserialize)] + enum WireOwner { + #[serde(rename = "ii")] + Ii(Principal), + } + + #[derive(CandidType, Deserialize)] + enum WireRail { + #[serde(rename = "card")] + Card, + } + + #[derive(CandidType, Deserialize)] + struct WirePricing { + #[serde(rename = "feeBps")] + fee_bps: Nat, + #[serde(rename = "usdCents")] + usd_cents: Nat, + } + + #[derive(CandidType, Deserialize)] + struct WireOrder { + id: String, + status: OrderStatus, + #[serde(rename = "lockedCycles")] + locked_cycles: Nat, + #[serde(rename = "stripeSessionUrl")] + stripe_session_url: Option, + #[serde(rename = "stripeSessionId")] + stripe_session_id: Option, + #[serde(rename = "expiresAtNs")] + expires_at_ns: Option, + #[serde(rename = "paidUsdCents")] + paid_usd_cents: Option, + #[serde(rename = "expiredBy")] + expired_by: Option, + #[serde(rename = "abandonedReason")] + abandoned_reason: Option, + destination: Destination, + owner: WireOwner, + rail: WireRail, + pricing: WirePricing, + problems: Vec, + #[serde(rename = "createdAtNs")] + created_at_ns: Int, + #[serde(rename = "updatedAtNs")] + updated_at_ns: Int, + } + + let owner = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap(); + let wire = WireOrder { + id: "abc".into(), + status: OrderStatus::Paid, + locked_cycles: Nat::from(7_238_000_000_000u64), + stripe_session_url: Some("https://checkout.example/pay".into()), + stripe_session_id: Some("cs_test".into()), + expires_at_ns: Some(Int::from(1_700_000_000_000_000_000i64)), + paid_usd_cents: Some(Nat::from(1000u32)), + expired_by: None, + abandoned_reason: None, + destination: Destination::CyclesLedgerAccount(Account { + owner, + subaccount: None, + }), + owner: WireOwner::Ii(owner), + rail: WireRail::Card, + pricing: WirePricing { + fee_bps: Nat::from(290u32), + usd_cents: Nat::from(1000u32), + }, + problems: vec![], + created_at_ns: Int::from(1i64), + updated_at_ns: Int::from(2i64), + }; + + let bytes = Encode!(&wire).unwrap(); + let order = Decode!(&bytes, Order).unwrap(); + assert_eq!(order.id, "abc"); + assert_eq!(order.status, OrderStatus::Paid); + assert_eq!(order.locked_cycles, Nat::from(7_238_000_000_000u64)); + assert_eq!( + order.stripe_session_url.as_deref(), + Some("https://checkout.example/pay") + ); + assert_eq!( + order.destination, + Destination::CyclesLedgerAccount(Account { + owner, + subaccount: None + }) + ); + } + + #[test] + fn lowercase_tags_round_trip() { + let bytes = Encode!(&CreateOrderResult::Err(CreateOrderError::NotAdmitted( + Reason::TooManyOpenOrders { + max: Nat::from(1u8), + open: Nat::from(1u8) + } + ))) + .unwrap(); + match Decode!(&bytes, CreateOrderResult).unwrap() { + CreateOrderResult::Err(CreateOrderError::NotAdmitted(Reason::TooManyOpenOrders { + max, + open, + })) => { + assert_eq!(max, Nat::from(1u8)); + assert_eq!(open, Nat::from(1u8)); + } + other => panic!("unexpected {other:?}"), + } + } +} diff --git a/crates/icp-canister-interfaces/src/lib.rs b/crates/icp-canister-interfaces/src/lib.rs index 938756a3b..cd1a3cd59 100644 --- a/crates/icp-canister-interfaces/src/lib.rs +++ b/crates/icp-canister-interfaces/src/lib.rs @@ -1,4 +1,5 @@ pub mod candid_ui; +pub mod cycles_gateway; pub mod cycles_ledger; pub mod cycles_minting_canister; pub mod engine_canister; diff --git a/crates/icp-cli/src/commands/cycles/buy.rs b/crates/icp-cli/src/commands/cycles/buy.rs new file mode 100644 index 000000000..1bca03b02 --- /dev/null +++ b/crates/icp-cli/src/commands/cycles/buy.rs @@ -0,0 +1,664 @@ +use std::io::{IsTerminal, stderr, stdin, stdout}; + +use anyhow::{anyhow, bail}; +use candid::Principal; +use clap::Args; +use dialoguer::Confirm; +use icp_app::context::Context; +use icp_app::identity::IdentitySelection; +use icp_app::identity::manifest::IdentityDefaults; +use icp_app::operations::cycles_purchase::{ + self as purchase, CyclesPurchaseError, Money, OrderView, Quote, WaitOptions, +}; +use icp_app::operations::token::balance::get_raw_balance; +use icp_app::operations::token::format_cycles; +use icp_canister_interfaces::cycles_gateway::{ + CreateOrderError, OrderStatus, Reason, cycles_gateway_canister_id, +}; +use icp_canister_interfaces::cycles_ledger::CYCLES_LEDGER_PRINCIPAL; +use icp_project::calls::CanisterCalls; +use icp_project::signal::stop_signal; +use num_traits::ToPrimitive; +use serde::Serialize; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tracing::{info, warn}; + +use crate::commands::args::TokenCommandArgs; +use crate::commands::parsers::parse_usd_amount; +use crate::render::{ProgressManager, ProgressManagerSettings}; + +/// Buy cycles with a card. +/// +/// Creates an order on a cycles gateway canister as the current identity, +/// prints the hosted checkout URL (and opens it in a browser when run from a +/// terminal), then waits for the cycles to land on the identity's own +/// cycles-ledger account. Fund a canister from there with `icp canister +/// top-up`. The gateway accepts USD only. +/// +/// Exactly one of --amount, --resume or --cancel must be given. +#[derive(Debug, Args)] +pub(crate) struct BuyArgs { + /// Amount to spend, in USD, as a decimal (e.g. 10 or 12.50) + #[arg( + long, + value_name = "USD", + value_parser = parse_usd_amount, + required_unless_present_any = ["resume", "cancel"], + conflicts_with_all = ["resume", "cancel"] + )] + pub(crate) amount: Option, + + /// Continue waiting on an existing order instead of creating a new one + #[arg(long, value_name = "ORDER_ID", conflicts_with = "cancel")] + pub(crate) resume: Option, + + /// Cancel an open order + #[arg(long, value_name = "ORDER_ID")] + pub(crate) cancel: Option, + + /// Canister id of the cycles gateway to buy through. + /// Defaults to the mainnet gateway, or to ICP_CYCLES_GATEWAY_CANISTER_ID when set. + #[arg(long, value_name = "CANISTER_ID")] + pub(crate) gateway: Option, + + #[command(flatten)] + pub(crate) token_command_args: TokenCommandArgs, + + /// Skip confirmation prompts + #[arg(long, short)] + pub(crate) yes: bool, + + /// Do not open the checkout URL in a browser + #[arg(long)] + pub(crate) no_open: bool, + + /// Print the checkout URL and return without waiting for the order to complete + #[arg(long)] + pub(crate) no_wait: bool, + + /// Output command results as JSON + #[arg(long, conflicts_with = "quiet")] + pub(crate) json: bool, + + /// Suppress human-readable output; print only the checkout URL, then the final status + #[arg(long, short)] + pub(crate) quiet: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Output { + Human, + Quiet, + Json, +} + +impl Output { + fn of(args: &BuyArgs) -> Self { + if args.json { + Self::Json + } else if args.quiet { + Self::Quiet + } else { + Self::Human + } + } +} + +pub(crate) async fn exec(ctx: &Context, args: &BuyArgs) -> Result<(), anyhow::Error> { + let selections = args.token_command_args.selections(); + if matches!(selections.identity, IdentitySelection::Anonymous) { + bail!("cycles cannot be bought as the anonymous identity; choose one with --identity"); + } + + let agent = ctx + .get_agent( + &selections.identity, + &selections.network, + &selections.environment, + ) + .await?; + let buyer = agent + .get_principal() + .map_err(|message| anyhow!("failed to get the identity's principal: {message}"))?; + if buyer == Principal::anonymous() { + bail!("cycles cannot be bought as the anonymous identity; choose one with --identity"); + } + let calls = icp_app::calls::calls(agent.clone(), None)?; + let calls = calls.as_ref(); + + let gateway = match args.gateway { + Some(gateway) => gateway, + None => cycles_gateway_canister_id().map_err(|message| anyhow!(message))?, + }; + let output = Output::of(args); + + if let Some(id) = &args.cancel { + let order = purchase::cancel_order(calls, gateway, id).await?; + match output { + Output::Json => print_json(&JsonOrderFinal { + order_id: &order.id, + status: order.status.tag(), + cycles_delivered: None, + balance: None, + })?, + Output::Quiet => println!("{}", order.status.tag()), + Output::Human => println!("Order {} {}", order.id, order.status), + } + return Ok(()); + } + + let order = match &args.resume { + Some(id) => { + let order = purchase::get_order(calls, gateway, id).await?; + if order.status == OrderStatus::Created { + announce_payable(&order, output, args.no_open); + } else if output == Output::Human { + info!("Order {} is {}", order.id, order.status); + } + order + } + None => { + let amount = args + .amount + .expect("clap requires --amount unless --resume or --cancel is given"); + let identity = identity_name(ctx, &selections.identity).await; + let order = + create_order(calls, gateway, buyer, identity.as_deref(), amount, args).await?; + announce_payable(&order, output, args.no_open); + order + } + }; + + if args.no_wait { + if output == Output::Human { + info!( + "Not waiting. Run `icp cycles buy --resume {}` to wait for delivery.", + order.id + ); + } + return Ok(()); + } + + wait_and_report(calls, &agent, gateway, buyer, order, output).await +} + +/// Quote, confirm, create. Re-quotes and re-confirms once if the rate moved +/// against the buyer in between. +async fn create_order( + calls: &dyn CanisterCalls, + gateway: Principal, + buyer: Principal, + identity: Option<&str>, + amount: Money, + args: &BuyArgs, +) -> Result { + let mut quote = purchase::quote(calls, gateway, amount).await?; + confirm("e, gateway, buyer, identity, args.yes)?; + + for attempt in 0..2 { + match purchase::place_order(calls, gateway, "e).await { + Ok(order) => return Ok(order), + Err(CyclesPurchaseError::OrderRefused { + error: CreateOrderError::QuoteChanged { .. }, + }) if attempt == 0 => { + quote = purchase::quote(calls, gateway, amount).await?; + if args.yes { + info!( + "The exchange rate moved. New quote: {}. Retrying once.", + quote_line("e) + ); + } else { + warn!("The exchange rate moved since the quote."); + confirm("e, gateway, buyer, identity, false)?; + } + } + Err(CyclesPurchaseError::OrderRefused { error }) => { + bail!("{}", refusal_message(&error, buyer)) + } + Err(CyclesPurchaseError::OpenOrderExists { order, open, max }) => { + bail!("{}", open_order_message(&order, open, max)) + } + Err(error) => return Err(error.into()), + } + } + bail!("the exchange rate is moving too quickly to lock a quote; try again in a minute") +} + +/// Show what is about to happen and ask. Without a terminal there is nobody +/// to ask, so `--yes` is required. +fn confirm( + quote: &Quote, + gateway: Principal, + buyer: Principal, + identity: Option<&str>, + yes: bool, +) -> Result<(), anyhow::Error> { + let identity = identity + .map(|name| format!(" (identity '{name}')")) + .unwrap_or_default(); + info!("Buyer: {buyer}{identity}"); + info!("Gateway: {gateway} (canister id)"); + info!("Quote: {}", quote_line(quote)); + + if yes { + info!("Proceeding without confirmation (--yes)."); + return Ok(()); + } + if !stdin().is_terminal() { + bail!( + "Refusing to create an order without confirmation in a non-interactive context. \ + Use --yes to proceed." + ); + } + let confirmed = Confirm::new() + .with_prompt("Create this order?") + .default(false) + .interact()?; + if !confirmed { + bail!("Operation cancelled by user"); + } + Ok(()) +} + +fn quote_line(quote: &Quote) -> String { + format!( + "{} -> {} (includes a {} card fee; rate locked at order creation)", + quote.amount, + format_cycles(quote.cycles), + quote.fee + ) +} + +/// Print the checkout URL, and open it where that makes sense: a person at a +/// terminal, who did not ask otherwise. `--json` and `--quiet` are for +/// scripts, which have no browser to open. +fn announce_payable(order: &OrderView, output: Output, no_open: bool) { + let Some(url) = order.checkout_url.as_deref() else { + warn!( + "Order {} is {} but the gateway reported no checkout URL", + order.id, order.status + ); + return; + }; + match output { + Output::Json => { + if let Err(e) = print_json(&JsonOrderCreated { + order_id: &order.id, + checkout_url: url, + locked_cycles: order.locked_cycles.to_string(), + expires_at: order.expires_at_ns.map(format_ns), + }) { + warn!("failed to write JSON: {e}"); + } + } + Output::Quiet => println!("{url}"), + Output::Human => { + let until = order + .expires_at_ns + .map(|ns| format!(", payable until {}", format_ns(ns))) + .unwrap_or_default(); + info!("Order {}{until}. Pay here:", order.id); + println!("{url}"); + if !no_open && stderr().is_terminal() { + match open::that(url) { + Ok(()) => info!("Opened the checkout page in your browser."), + Err(e) => warn!("Could not open a browser ({e}); open the URL above yourself."), + } + } + } + } +} + +async fn wait_and_report( + calls: &dyn CanisterCalls, + agent: &ic_agent::Agent, + gateway: Principal, + buyer: Principal, + order: OrderView, + output: Output, +) -> Result<(), anyhow::Error> { + let progress = ProgressManager::new(ProgressManagerSettings { + hidden: output != Output::Human, + }); + let spinner = progress.create_independent_progress_bar(); + spinner.set_message(waiting_message(order.status)); + + let id = order.id.clone(); + let result = purchase::wait_for_order( + calls, + gateway, + &id, + Some(order), + &WaitOptions::default(), + stop_signal(), + &mut |order| spinner.set_message(waiting_message(order.status)), + ) + .await; + spinner.finish_and_clear(); + + let order = match result { + Ok(order) => order, + Err(CyclesPurchaseError::Interrupted { order }) => { + if output == Output::Human { + let until = order + .expires_at_ns + .map(|ns| format!(" until {}", format_ns(ns))) + .unwrap_or_default(); + info!("Order {} stays payable{until}.", order.id); + info!("Keep waiting: icp cycles buy --resume {}", order.id); + info!("Cancel it: icp cycles buy --cancel {}", order.id); + } + bail!("interrupted while waiting for order {}", order.id); + } + Err(CyclesPurchaseError::WaitTimedOut { order }) => { + if output == Output::Human { + info!("Keep waiting: icp cycles buy --resume {}", order.id); + } + return Err(CyclesPurchaseError::WaitTimedOut { order }.into()); + } + Err(error) => return Err(error.into()), + }; + + let balance = if order.status == OrderStatus::Delivered { + match get_raw_balance(agent, CYCLES_LEDGER_PRINCIPAL, buyer, None).await { + Ok(balance) => balance.0.to_u128(), + Err(e) => { + warn!("Cycles were delivered, but the balance could not be read: {e}"); + None + } + } + } else { + None + }; + + match output { + Output::Json => print_json(&JsonOrderFinal { + order_id: &order.id, + status: order.status.tag(), + cycles_delivered: (order.status == OrderStatus::Delivered) + .then(|| order.locked_cycles.to_string()), + balance: balance.map(|b| b.to_string()), + })?, + Output::Quiet => println!("{}", order.status.tag()), + Output::Human => {} + } + + match order.status { + OrderStatus::Delivered => { + if output == Output::Human { + println!( + "Delivered {} to {buyer}", + format_cycles(order.locked_cycles) + ); + if let Some(balance) = balance { + println!("Balance: {}", format_cycles(balance)); + } + info!("Next: icp deploy -e ic"); + } + Ok(()) + } + OrderStatus::NeedsReview => bail!( + "order {} needs review: your card was charged, and the gateway operator will \ + resolve delivery. Keep the order id.", + order.id + ), + OrderStatus::Expired => { + let why = order + .expired_by + .map(|by| format!(": {by}")) + .unwrap_or_default(); + bail!("order {} expired{why}; no charge was made", order.id) + } + OrderStatus::Cancelled => bail!("order {} was cancelled; no charge was made", order.id), + OrderStatus::Abandoned => { + let why = order + .abandoned_reason + .as_deref() + .map(|reason| format!(": {reason}")) + .unwrap_or_default(); + bail!("order {} was abandoned by the gateway{why}", order.id) + } + OrderStatus::Created | OrderStatus::Paid => { + bail!("order {} is still {}", order.id, order.status) + } + } +} + +fn waiting_message(status: OrderStatus) -> String { + match status { + OrderStatus::Paid => "Payment received, delivering cycles...".to_owned(), + _ => "Waiting for payment...".to_owned(), + } +} + +/// What a gateway refusal means for this buyer, with the flag to reach for. +fn refusal_message(error: &CreateOrderError, buyer: Principal) -> String { + let usd = |cents: &candid::Nat| { + Money::from_nat_minor_units(cents, purchase::Currency::Usd) + .map(|m| m.to_string()) + .unwrap_or_else(|| format!("{cents} USD cents")) + }; + match error { + CreateOrderError::NotAdmitted(Reason::AmountBelowMin { + min_usd_cents, + usd_cents, + }) => format!( + "amount {} is below the gateway minimum of {}", + usd(usd_cents), + usd(min_usd_cents) + ), + CreateOrderError::NotAdmitted(Reason::AmountAboveMax { + max_usd_cents, + usd_cents, + }) => format!( + "amount {} is above the gateway maximum of {}", + usd(usd_cents), + usd(max_usd_cents) + ), + CreateOrderError::NotAdmitted(Reason::TooManyOpenOrders { open, max }) => format!( + "you already have {open} open order(s) and the gateway allows {max}, but none is \ + visible to this identity; wait for it to expire or run `icp cycles buy --cancel `" + ), + CreateOrderError::NotAdmitted(Reason::ReserveShort { + available, + requested, + }) => format!( + "the gateway cannot lock {} right now ({} available); a smaller amount may succeed", + cycles_text(requested), + cycles_text(available) + ), + CreateOrderError::NotAdmitted(Reason::BuyerNotAllowed) => format!( + "{buyer} is not on this gateway's allow-list (the gateway is in simulation mode)" + ), + CreateOrderError::NotAdmitted( + reason @ (Reason::CanisterCyclesLow { .. } | Reason::UnboundedGiveaway { .. }), + ) => gateway_side(&reason.to_string()), + CreateOrderError::RateUnavailable + | CreateOrderError::ReserveUnavailable + | CreateOrderError::SessionUnavailable(_) => gateway_side(&error.to_string()), + CreateOrderError::CancelledDuringCreation => { + "the order was cancelled while being created; start over".to_owned() + } + CreateOrderError::QuoteChanged { .. } => { + "the exchange rate is moving too quickly to lock a quote; try again in a minute" + .to_owned() + } + CreateOrderError::Anonymous + | CreateOrderError::DestinationNotOwned + | CreateOrderError::IdGeneration + | CreateOrderError::SimulationScaleTooSmall { .. } + | CreateOrderError::TierBelowFees(_) + | CreateOrderError::UnknownTier(_) => { + format!("the cycles gateway refused the order: {error}") + } + } +} + +fn gateway_side(detail: &str) -> String { + format!("the cycles gateway is temporarily unable to take orders ({detail}); try again later") +} + +fn cycles_text(cycles: &candid::Nat) -> String { + cycles + .0 + .to_u128() + .map(format_cycles) + .unwrap_or_else(|| format!("{cycles} cycles")) +} + +fn open_order_message(order: &OrderView, open: u128, max: u128) -> String { + let mut message = format!( + "you already have an open order {} (status {}; {open} of {max} allowed), so no new order was created.", + order.id, order.status + ); + if let Some(url) = &order.checkout_url + && order.status == OrderStatus::Created + { + message.push_str(&format!("\n Pay it here: {url}")); + } + message.push_str(&format!( + "\n Keep waiting: icp cycles buy --resume {}\n Cancel it: icp cycles buy --cancel {}", + order.id, order.id + )); + message +} + +/// The name behind an identity selection, for the confirmation block. Best +/// effort: a name that cannot be read is simply left out. +async fn identity_name(ctx: &Context, selection: &IdentitySelection) -> Option { + match selection { + IdentitySelection::Named(name) => Some(name.clone()), + IdentitySelection::Default => { + let dirs = ctx.dirs.identity().ok()?; + dirs.with_read(async |dirs| IdentityDefaults::load_from(dirs)) + .await + .ok()? + .ok() + .map(|defaults| defaults.default) + } + IdentitySelection::Anonymous => None, + } +} + +fn format_ns(timestamp_ns: i64) -> String { + OffsetDateTime::from_unix_timestamp_nanos(timestamp_ns as i128) + .ok() + .and_then(|dt| dt.format(&Rfc3339).ok()) + .unwrap_or_else(|| timestamp_ns.to_string()) +} + +fn print_json(value: &T) -> Result<(), anyhow::Error> { + serde_json::to_writer(stdout(), value)?; + println!(); + Ok(()) +} + +#[derive(Serialize)] +struct JsonOrderCreated<'a> { + order_id: &'a str, + checkout_url: &'a str, + /// Exact cycles promised, as a decimal string. + locked_cycles: String, + /// RFC 3339. + expires_at: Option, +} + +#[derive(Serialize)] +struct JsonOrderFinal<'a> { + order_id: &'a str, + status: &'static str, + /// Exact cycles delivered, as a decimal string; only when delivered. + cycles_delivered: Option, + /// The buyer's cycles-ledger balance afterwards, as a decimal string. + balance: Option, +} + +#[cfg(test)] +mod tests { + use candid::Nat; + + use super::*; + + fn buyer() -> Principal { + Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap() + } + + #[test] + fn refusal_messages_state_both_figures() { + let msg = refusal_message( + &CreateOrderError::NotAdmitted(Reason::AmountBelowMin { + min_usd_cents: Nat::from(500u32), + usd_cents: Nat::from(100u32), + }), + buyer(), + ); + assert_eq!( + msg, + "amount 1.00 USD is below the gateway minimum of 5.00 USD" + ); + + let msg = refusal_message( + &CreateOrderError::NotAdmitted(Reason::ReserveShort { + available: Nat::from(1_000_000_000_000u64), + requested: Nat::from(7_238_000_000_000u64), + }), + buyer(), + ); + assert!( + msg.contains("7.238T cycles") && msg.contains("1T cycles"), + "{msg}" + ); + } + + #[test] + fn gateway_side_refusals_say_try_later() { + for error in [ + CreateOrderError::RateUnavailable, + CreateOrderError::ReserveUnavailable, + CreateOrderError::SessionUnavailable("stripe down".into()), + CreateOrderError::NotAdmitted(Reason::CanisterCyclesLow { + balance: Nat::from(1u8), + min: Nat::from(2u8), + }), + ] { + let msg = refusal_message(&error, buyer()); + assert!(msg.contains("try again later"), "{msg}"); + } + } + + #[test] + fn open_order_message_offers_resume_and_cancel() { + let order = OrderView { + id: "o1".into(), + status: OrderStatus::Created, + locked_cycles: 1, + checkout_url: Some("https://checkout.example/o1".into()), + expires_at_ns: None, + paid: None, + expired_by: None, + abandoned_reason: None, + }; + let msg = open_order_message(&order, 1, 1); + assert!(msg.contains("https://checkout.example/o1"), "{msg}"); + assert!(msg.contains("--resume o1"), "{msg}"); + assert!(msg.contains("--cancel o1"), "{msg}"); + } + + #[test] + fn quote_line_shows_amount_cycles_and_fee() { + let line = quote_line(&Quote { + amount: Money::from_minor_units(1000, purchase::Currency::Usd), + fee: Money::from_minor_units(59, purchase::Currency::Usd), + cycles: 7_238_000_000_000, + }); + assert_eq!( + line, + "10.00 USD -> 7.238T cycles (includes a 0.59 USD card fee; rate locked at order creation)" + ); + } + + #[test] + fn format_ns_is_rfc3339() { + assert_eq!(format_ns(1_700_000_000_000_000_000), "2023-11-14T22:13:20Z"); + } +} diff --git a/crates/icp-cli/src/commands/cycles/mod.rs b/crates/icp-cli/src/commands/cycles/mod.rs index e46750762..84807bc32 100644 --- a/crates/icp-cli/src/commands/cycles/mod.rs +++ b/crates/icp-cli/src/commands/cycles/mod.rs @@ -1,6 +1,7 @@ use clap::Subcommand; pub(crate) mod balance; +pub(crate) mod buy; pub(crate) mod mint; pub(crate) mod transfer; @@ -8,6 +9,7 @@ pub(crate) mod transfer; #[derive(Subcommand, Debug)] pub(crate) enum Command { Balance(balance::BalanceArgs), + Buy(buy::BuyArgs), Mint(mint::MintArgs), Transfer(transfer::TransferArgs), } diff --git a/crates/icp-cli/src/commands/parsers.rs b/crates/icp-cli/src/commands/parsers.rs index c53bc580b..1981a79cf 100644 --- a/crates/icp-cli/src/commands/parsers.rs +++ b/crates/icp-cli/src/commands/parsers.rs @@ -1,3 +1,5 @@ +use icp_app::operations::cycles_purchase::{Currency, Money}; + pub(crate) fn parse_subaccount(input: &str) -> Result<[u8; 32], String> { if input.len() > 64 { return Err(format!( @@ -12,3 +14,8 @@ pub(crate) fn parse_subaccount(input: &str) -> Result<[u8; 32], String> { .try_into() .expect("Hex string should be 32 bytes after padding")) } + +/// A decimal USD amount for `--amount`: `10`, `12.50`. +pub(crate) fn parse_usd_amount(input: &str) -> Result { + Money::parse_decimal(input, Currency::Usd).map_err(|e| e.to_string()) +} diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index cf0e7479e..c2971959a 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -334,6 +334,8 @@ async fn dispatch(ctx: &icp_app::context::Context, command: Command) -> Result<( commands::cycles::balance::exec(ctx, &args).await? } + commands::cycles::Command::Buy(args) => commands::cycles::buy::exec(ctx, &args).await?, + commands::cycles::Command::Mint(args) => { commands::cycles::mint::exec(ctx, &args).await? } diff --git a/crates/icp-cli/tests/cycles_tests.rs b/crates/icp-cli/tests/cycles_tests.rs index 4722dd9fa..c3ef4e42d 100644 --- a/crates/icp-cli/tests/cycles_tests.rs +++ b/crates/icp-cli/tests/cycles_tests.rs @@ -418,3 +418,105 @@ async fn cycles_mint_to_subaccount() { .stdout(contains("Balance: 3_519_900_000_000 cycles")) .success(); } + +/// The three modes of `cycles buy` are exclusive, and so are its two +/// machine-readable outputs; none of this needs a network. +#[test] +fn cycles_buy_rejects_conflicting_arguments() { + let ctx = TestContext::new(); + + ctx.icp() + .args(["cycles", "buy", "--amount", "10", "--resume", "o1"]) + .assert() + .stderr(contains("cannot be used with")) + .failure(); + + ctx.icp() + .args(["cycles", "buy", "--resume", "o1", "--cancel", "o1"]) + .assert() + .stderr(contains("cannot be used with")) + .failure(); + + ctx.icp() + .args(["cycles", "buy", "--amount", "10", "--json", "--quiet"]) + .assert() + .stderr(contains("cannot be used with")) + .failure(); + + ctx.icp() + .args(["cycles", "buy"]) + .assert() + .stderr(contains("required arguments were not provided")) + .failure(); + + ctx.icp() + .args(["cycles", "buy", "--amount", "10.005"]) + .assert() + .stderr(contains("more than 2 decimal places")) + .failure(); + + ctx.icp() + .args(["cycles", "buy", "--amount", "0"]) + .assert() + .stderr(contains("greater than zero")) + .failure(); +} + +/// The anonymous identity cannot own an order, so the command refuses before +/// touching the network. +#[test] +fn cycles_buy_refuses_the_anonymous_identity() { + let ctx = TestContext::new(); + + ctx.icp() + .args([ + "cycles", + "buy", + "--amount", + "10", + "--yes", + "--identity", + "anonymous", + "-n", + "ic", + ]) + .assert() + .stderr(contains("anonymous identity")) + .failure(); +} + +/// A local network has no cycles gateway: the command says so and points at +/// mainnet rather than failing on an opaque rejection. +#[tokio::test] +async fn cycles_buy_on_a_local_network_reports_the_missing_gateway() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + + let pm = formatdoc! {r#" + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + write_string(&project_dir.join("icp.yaml"), &pm).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"); + + clients::icp(&ctx, &project_dir, Some("random-environment".to_string())) + .use_new_random_identity(); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "cycles", + "buy", + "--amount", + "10", + "--yes", + "--environment", + "random-environment", + ]) + .assert() + .stderr(contains("does not exist on this network")) + .stderr(contains("-n ic")) + .failure(); +} diff --git a/docs/guides/deploying-to-mainnet.md b/docs/guides/deploying-to-mainnet.md index b1523f309..232adadef 100644 --- a/docs/guides/deploying-to-mainnet.md +++ b/docs/guides/deploying-to-mainnet.md @@ -72,7 +72,13 @@ Save this principal — you'll need it to receive ICP tokens. ## Acquiring Cycles -Now you need to get ICP tokens and convert them to cycles. +Now you need cycles. The quickest route is to buy them with a card for your identity: + +```bash +icp cycles buy --amount 10 -n ic +``` + +See [Buying Cycles with a Card](tokens-and-cycles.md#buying-cycles-with-a-card). Otherwise, get ICP tokens and convert them to cycles. ### Getting ICP diff --git a/docs/guides/tokens-and-cycles.md b/docs/guides/tokens-and-cycles.md index b91134c5b..f97af850a 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -114,6 +114,37 @@ Verify your cycles balance: icp cycles balance -n ic ``` +## Buying Cycles with a Card + +If you have a card but no ICP, you can buy cycles directly for the current identity through a cycles gateway canister: + +```bash +icp cycles buy --amount 10 -n ic +``` + +The command quotes what the amount buys, shows who is paying and which gateway canister the order goes to, and asks you to confirm: + +``` +Buyer: rv4ez-fgjbj-...-vae (identity 'default') +Gateway: saz2a-riaaa-aaaay-aadha-cai (canister id) +Quote: 10.00 USD -> 7.238T cycles (includes a 0.59 USD card fee; rate locked at order creation) +Create this order? [y/N] +``` + +It then creates the order as your identity, prints the hosted checkout URL (and opens it in your browser when run from a terminal), and waits until the cycles land on your identity's own cycles-ledger account. The card is charged on the payment processor's hosted page; the CLI never sees card details. Amounts are in USD, which is the only currency the gateway accepts today. + +Useful flags: + +- `--no-open` prints the URL without opening a browser, for SSH sessions and containers. You can pay from any device. +- `--no-wait` returns right after printing the URL. `--resume ` waits on an order later, and prints its URL again while it is still payable. +- `--cancel ` cancels an open order. An unpaid order also expires on its own at the deadline the command prints. +- `--yes` skips the confirmation, which is required when there is no terminal to ask. `--json` prints one object after the order is created and one when it settles; `-q` prints only the URL, then the final status. +- `--gateway ` buys through another gateway canister. The `ICP_CYCLES_GATEWAY_CANISTER_ID` environment variable does the same. + +Pressing Ctrl-C while waiting leaves the order payable; the command prints how to resume or cancel it. If the order ends up needing review, your card was charged and the gateway operator resolves delivery; keep the order id. + +The gateway is a third-party canister that takes card payments. The default is the CyclePay backend on mainnet. The cycles arrive in your identity's default cycles-ledger account; fund a canister from there with `icp canister top-up`, or run `icp deploy -e ic`. + ## Transferring ICP Tokens Send ICP tokens to another principal: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 124ecdf22..3315bf339 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -37,6 +37,7 @@ This document contains the help content for the `icp` command-line program. * [`icp completions`↴](#icp-completions) * [`icp cycles`↴](#icp-cycles) * [`icp cycles balance`↴](#icp-cycles-balance) +* [`icp cycles buy`↴](#icp-cycles-buy) * [`icp cycles mint`↴](#icp-cycles-mint) * [`icp cycles transfer`↴](#icp-cycles-transfer) * [`icp deploy`↴](#icp-deploy) @@ -880,6 +881,7 @@ Mint and manage cycles ###### **Subcommands:** * `balance` — Display the cycles balance +* `buy` — Buy cycles with a card * `mint` — Convert ICP to cycles * `transfer` — Transfer cycles to another principal @@ -904,6 +906,34 @@ Display the cycles balance +## `icp cycles buy` + +Buy cycles with a card. + +Creates an order on a cycles gateway canister as the current identity, prints the hosted checkout URL (and opens it in a browser when run from a terminal), then waits for the cycles to land on the identity's own cycles-ledger account. Fund a canister from there with `icp canister top-up`. The gateway accepts USD only. + +Exactly one of --amount, --resume or --cancel must be given. + +**Usage:** `icp cycles buy [OPTIONS]` + +###### **Options:** + +* `--amount ` — Amount to spend, in USD, as a decimal (e.g. 10 or 12.50) +* `--resume ` — Continue waiting on an existing order instead of creating a new one +* `--cancel ` — Cancel an open order +* `--gateway ` — Canister id of the cycles gateway to buy through. Defaults to the mainnet gateway, or to ICP_CYCLES_GATEWAY_CANISTER_ID when set +* `-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 `. One of `mainnet`, `fetch`, or a 266-character hex-encoded root key +* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--identity ` — The user identity to run this command as +* `-y`, `--yes` — Skip confirmation prompts +* `--no-open` — Do not open the checkout URL in a browser +* `--no-wait` — Print the checkout URL and return without waiting for the order to complete +* `--json` — Output command results as JSON +* `-q`, `--quiet` — Suppress human-readable output; print only the checkout URL, then the final status + + + ## `icp cycles mint` Convert ICP to cycles. From a7b40e6e2482b8e35fdc98f0f00ff0c4986e45b0 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 7 Oct 2026 23:14:45 +0000 Subject: [PATCH 2/8] feat(cycles buy): take `--currency ` alongside `--amount` `--amount` is now a plain decimal in the currency `--currency` names, defaulting to USD. Only USD is accepted until the gateway's `Amount` grows a currency, so the flag's job today is to fix the surface: adding a currency later is a `Currency` variant, not a new flag. The amount is validated before any identity is unlocked or network reached, so a malformed value fails fast. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh --- CHANGELOG.md | 2 +- .../src/operations/cycles_purchase/money.rs | 4 ++- crates/icp-cli/src/commands/cycles/buy.rs | 35 ++++++++++++------- crates/icp-cli/src/commands/parsers.rs | 7 ---- docs/guides/tokens-and-cycles.md | 2 +- docs/reference/cli.md | 7 ++-- 6 files changed, 32 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd3772870..5b4b8ff4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ air-gapped signing # Unreleased -* feat: `icp cycles buy --amount ` buys cycles with a card for the current identity through a cycles gateway canister. It quotes the amount, asks for confirmation, creates the order as the identity, prints the hosted checkout URL (and opens it in a browser from a terminal) and waits for the cycles to land on the identity's cycles-ledger account. `--resume` and `--cancel` continue or cancel an existing order; `--gateway` or `ICP_CYCLES_GATEWAY_CANISTER_ID` picks another gateway canister. +* feat: `icp cycles buy --amount [--currency USD]` buys cycles with a card for the current identity through a cycles gateway canister. It quotes the amount, asks for confirmation, creates the order as the identity, prints the hosted checkout URL (and opens it in a browser from a terminal) and waits for the cycles to land on the identity's cycles-ledger account. `--resume` and `--cancel` continue or cancel an existing order; `--gateway` or `ICP_CYCLES_GATEWAY_CANISTER_ID` picks another gateway canister. * feat: `icp deploy` and `icp canister create` take `--engine ` to target a cloud engine by name. The name is resolved to the engine's subnet through the engine canister, among the engines visible to the identity in use, and then behaves as `--subnet` would with that subnet; a subnet id passed as the engine is used as-is. `name/slug` or the engine's id picks one of several engines sharing a name. * fix: reject path-like network and environment names diff --git a/crates/icp-app/src/operations/cycles_purchase/money.rs b/crates/icp-app/src/operations/cycles_purchase/money.rs index eb17a2952..8f2285a8c 100644 --- a/crates/icp-app/src/operations/cycles_purchase/money.rs +++ b/crates/icp-app/src/operations/cycles_purchase/money.rs @@ -51,7 +51,9 @@ impl FromStr for Currency { fn from_str(s: &str) -> Result { match s.trim().to_ascii_uppercase().as_str() { "USD" => Ok(Self::Usd), - other => Err(format!("unsupported currency '{other}'")), + other => Err(format!( + "unsupported currency '{other}'; only USD is supported" + )), } } } diff --git a/crates/icp-cli/src/commands/cycles/buy.rs b/crates/icp-cli/src/commands/cycles/buy.rs index 1bca03b02..bfde81f18 100644 --- a/crates/icp-cli/src/commands/cycles/buy.rs +++ b/crates/icp-cli/src/commands/cycles/buy.rs @@ -8,7 +8,7 @@ use icp_app::context::Context; use icp_app::identity::IdentitySelection; use icp_app::identity::manifest::IdentityDefaults; use icp_app::operations::cycles_purchase::{ - self as purchase, CyclesPurchaseError, Money, OrderView, Quote, WaitOptions, + self as purchase, Currency, CyclesPurchaseError, Money, OrderView, Quote, WaitOptions, }; use icp_app::operations::token::balance::get_raw_balance; use icp_app::operations::token::format_cycles; @@ -24,7 +24,6 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::{info, warn}; use crate::commands::args::TokenCommandArgs; -use crate::commands::parsers::parse_usd_amount; use crate::render::{ProgressManager, ProgressManagerSettings}; /// Buy cycles with a card. @@ -33,20 +32,23 @@ use crate::render::{ProgressManager, ProgressManagerSettings}; /// prints the hosted checkout URL (and opens it in a browser when run from a /// terminal), then waits for the cycles to land on the identity's own /// cycles-ledger account. Fund a canister from there with `icp canister -/// top-up`. The gateway accepts USD only. +/// top-up`. Amounts are in USD unless --currency says otherwise; the gateway accepts USD only today. /// /// Exactly one of --amount, --resume or --cancel must be given. #[derive(Debug, Args)] pub(crate) struct BuyArgs { - /// Amount to spend, in USD, as a decimal (e.g. 10 or 12.50) + /// Amount to spend, as a decimal (e.g. 10 or 12.50), in the currency given by --currency #[arg( long, - value_name = "USD", - value_parser = parse_usd_amount, + value_name = "AMOUNT", required_unless_present_any = ["resume", "cancel"], conflicts_with_all = ["resume", "cancel"] )] - pub(crate) amount: Option, + pub(crate) amount: Option, + + /// ISO 4217 code of the currency to pay in. The gateway accepts USD only. + #[arg(long, value_name = "CODE", default_value = "USD")] + pub(crate) currency: Currency, /// Continue waiting on an existing order instead of creating a new one #[arg(long, value_name = "ORDER_ID", conflicts_with = "cancel")] @@ -105,6 +107,14 @@ impl Output { } pub(crate) async fn exec(ctx: &Context, args: &BuyArgs) -> Result<(), anyhow::Error> { + // A malformed amount should fail before any identity is unlocked or any + // network reached. + let amount = args + .amount + .as_deref() + .map(|amount| Money::parse_decimal(amount, args.currency)) + .transpose()?; + let selections = args.token_command_args.selections(); if matches!(selections.identity, IdentitySelection::Anonymous) { bail!("cycles cannot be bought as the anonymous identity; choose one with --identity"); @@ -158,9 +168,8 @@ pub(crate) async fn exec(ctx: &Context, args: &BuyArgs) -> Result<(), anyhow::Er order } None => { - let amount = args - .amount - .expect("clap requires --amount unless --resume or --cancel is given"); + let amount = + amount.expect("clap requires --amount unless --resume or --cancel is given"); let identity = identity_name(ctx, &selections.identity).await; let order = create_order(calls, gateway, buyer, identity.as_deref(), amount, args).await?; @@ -434,7 +443,7 @@ fn waiting_message(status: OrderStatus) -> String { /// What a gateway refusal means for this buyer, with the flag to reach for. fn refusal_message(error: &CreateOrderError, buyer: Principal) -> String { let usd = |cents: &candid::Nat| { - Money::from_nat_minor_units(cents, purchase::Currency::Usd) + Money::from_nat_minor_units(cents, Currency::Usd) .map(|m| m.to_string()) .unwrap_or_else(|| format!("{cents} USD cents")) }; @@ -647,8 +656,8 @@ mod tests { #[test] fn quote_line_shows_amount_cycles_and_fee() { let line = quote_line(&Quote { - amount: Money::from_minor_units(1000, purchase::Currency::Usd), - fee: Money::from_minor_units(59, purchase::Currency::Usd), + amount: Money::from_minor_units(1000, Currency::Usd), + fee: Money::from_minor_units(59, Currency::Usd), cycles: 7_238_000_000_000, }); assert_eq!( diff --git a/crates/icp-cli/src/commands/parsers.rs b/crates/icp-cli/src/commands/parsers.rs index 1981a79cf..c53bc580b 100644 --- a/crates/icp-cli/src/commands/parsers.rs +++ b/crates/icp-cli/src/commands/parsers.rs @@ -1,5 +1,3 @@ -use icp_app::operations::cycles_purchase::{Currency, Money}; - pub(crate) fn parse_subaccount(input: &str) -> Result<[u8; 32], String> { if input.len() > 64 { return Err(format!( @@ -14,8 +12,3 @@ pub(crate) fn parse_subaccount(input: &str) -> Result<[u8; 32], String> { .try_into() .expect("Hex string should be 32 bytes after padding")) } - -/// A decimal USD amount for `--amount`: `10`, `12.50`. -pub(crate) fn parse_usd_amount(input: &str) -> Result { - Money::parse_decimal(input, Currency::Usd).map_err(|e| e.to_string()) -} diff --git a/docs/guides/tokens-and-cycles.md b/docs/guides/tokens-and-cycles.md index f97af850a..d49fd44b3 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -131,7 +131,7 @@ Quote: 10.00 USD -> 7.238T cycles (includes a 0.59 USD card fee; rate locked Create this order? [y/N] ``` -It then creates the order as your identity, prints the hosted checkout URL (and opens it in your browser when run from a terminal), and waits until the cycles land on your identity's own cycles-ledger account. The card is charged on the payment processor's hosted page; the CLI never sees card details. Amounts are in USD, which is the only currency the gateway accepts today. +It then creates the order as your identity, prints the hosted checkout URL (and opens it in your browser when run from a terminal), and waits until the cycles land on your identity's own cycles-ledger account. The card is charged on the payment processor's hosted page; the CLI never sees card details. `--amount` is in the currency given by `--currency`, which defaults to `USD`; USD is the only currency the gateway accepts today. Useful flags: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 3315bf339..c294414ba 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -910,7 +910,7 @@ Display the cycles balance Buy cycles with a card. -Creates an order on a cycles gateway canister as the current identity, prints the hosted checkout URL (and opens it in a browser when run from a terminal), then waits for the cycles to land on the identity's own cycles-ledger account. Fund a canister from there with `icp canister top-up`. The gateway accepts USD only. +Creates an order on a cycles gateway canister as the current identity, prints the hosted checkout URL (and opens it in a browser when run from a terminal), then waits for the cycles to land on the identity's own cycles-ledger account. Fund a canister from there with `icp canister top-up`. Amounts are in USD unless --currency says otherwise; the gateway accepts USD only today. Exactly one of --amount, --resume or --cancel must be given. @@ -918,7 +918,10 @@ Exactly one of --amount, --resume or --cancel must be given. ###### **Options:** -* `--amount ` — Amount to spend, in USD, as a decimal (e.g. 10 or 12.50) +* `--amount ` — Amount to spend, as a decimal (e.g. 10 or 12.50), in the currency given by --currency +* `--currency ` — ISO 4217 code of the currency to pay in. The gateway accepts USD only + + Default value: `USD` * `--resume ` — Continue waiting on an existing order instead of creating a new one * `--cancel ` — Cancel an open order * `--gateway ` — Canister id of the cycles gateway to buy through. Defaults to the mainnet gateway, or to ICP_CYCLES_GATEWAY_CANISTER_ID when set From bdde8f9991043a97389c06a9ac375071462a7f79 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 7 Oct 2026 23:21:24 +0000 Subject: [PATCH 3/8] fix(cycles buy): name the deploy target that was actually used The closing hint after delivery was hardcoded to `icp deploy -e ic`. It now names the environment that was selected, `-e ic` only when the `ic` network was, plain `icp deploy` for a project's default environment, and nothing for a network named any other way, which has no environment to point at. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh --- crates/icp-cli/src/commands/cycles/buy.rs | 74 ++++++++++++++++++++++- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/icp-cli/src/commands/cycles/buy.rs b/crates/icp-cli/src/commands/cycles/buy.rs index bfde81f18..cad086898 100644 --- a/crates/icp-cli/src/commands/cycles/buy.rs +++ b/crates/icp-cli/src/commands/cycles/buy.rs @@ -4,7 +4,7 @@ use anyhow::{anyhow, bail}; use candid::Principal; use clap::Args; use dialoguer::Confirm; -use icp_app::context::Context; +use icp_app::context::{Context, NetworkSelection}; use icp_app::identity::IdentitySelection; use icp_app::identity::manifest::IdentityDefaults; use icp_app::operations::cycles_purchase::{ @@ -17,6 +17,8 @@ use icp_canister_interfaces::cycles_gateway::{ }; use icp_canister_interfaces::cycles_ledger::CYCLES_LEDGER_PRINCIPAL; use icp_project::calls::CanisterCalls; +use icp_project::host::EnvironmentSelection; +use icp_project::prelude::IC; use icp_project::signal::stop_signal; use num_traits::ToPrimitive; use serde::Serialize; @@ -188,7 +190,34 @@ pub(crate) async fn exec(ctx: &Context, args: &BuyArgs) -> Result<(), anyhow::Er return Ok(()); } - wait_and_report(calls, &agent, gateway, buyer, order, output).await + let next = deploy_hint(&selections.environment, &selections.network); + wait_and_report( + calls, + &agent, + gateway, + buyer, + order, + output, + next.as_deref(), + ) + .await +} + +/// The deploy command that spends what was bought, if one can be named: the +/// environment that was selected, or the implicit `ic` environment when the +/// `ic` network was. A network named any other way has no environment to +/// point at, and a project's default environment is `icp deploy` itself. +fn deploy_hint(environment: &EnvironmentSelection, network: &NetworkSelection) -> Option { + match (environment, network) { + (EnvironmentSelection::Named(env), _) => Some(format!("icp deploy -e {env}")), + (EnvironmentSelection::Default, NetworkSelection::Named(name)) if name == IC => { + Some(format!("icp deploy -e {IC}")) + } + (EnvironmentSelection::Default, NetworkSelection::Default) => Some("icp deploy".to_owned()), + (EnvironmentSelection::Default, NetworkSelection::Named(_) | NetworkSelection::Url(..)) => { + None + } + } } /// Quote, confirm, create. Re-quotes and re-confirms once if the rate moved @@ -325,6 +354,7 @@ async fn wait_and_report( buyer: Principal, order: OrderView, output: Output, + next: Option<&str>, ) -> Result<(), anyhow::Error> { let progress = ProgressManager::new(ProgressManagerSettings { hidden: output != Output::Human, @@ -402,7 +432,9 @@ async fn wait_and_report( if let Some(balance) = balance { println!("Balance: {}", format_cycles(balance)); } - info!("Next: icp deploy -e ic"); + if let Some(next) = next { + info!("Next: {next}"); + } } Ok(()) } @@ -666,6 +698,42 @@ mod tests { ); } + #[test] + fn deploy_hint_names_the_target_that_was_used() { + let url = NetworkSelection::Url( + "http://localhost:8000".parse().unwrap(), + icp_project::network::RootKeySpec::Fetch, + ); + assert_eq!( + deploy_hint( + &EnvironmentSelection::Named("staging".into()), + &NetworkSelection::Default + ) + .as_deref(), + Some("icp deploy -e staging") + ); + assert_eq!( + deploy_hint( + &EnvironmentSelection::Default, + &NetworkSelection::Named("ic".into()) + ) + .as_deref(), + Some("icp deploy -e ic") + ); + assert_eq!( + deploy_hint(&EnvironmentSelection::Default, &NetworkSelection::Default).as_deref(), + Some("icp deploy") + ); + assert_eq!( + deploy_hint( + &EnvironmentSelection::Default, + &NetworkSelection::Named("local".into()) + ), + None + ); + assert_eq!(deploy_hint(&EnvironmentSelection::Default, &url), None); + } + #[test] fn format_ns_is_rfc3339() { assert_eq!(format_ns(1_700_000_000_000_000_000), "2023-11-14T22:13:20Z"); From 28f77d3bb8c2d41d0db3cbee307d390761150727 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 8 Oct 2026 12:12:32 +0000 Subject: [PATCH 4/8] feat(cycles buy): draw the checkout URL as a QR code too Paying from a terminal usually means reaching for a phone, so the human output now draws the checkout URL as a QR code under it, in half-block characters, whenever stderr is a terminal. `--json` and `-q` are unchanged. Adds `qrcode` (no default features) as a workspace dependency. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh --- CHANGELOG.md | 2 +- Cargo.lock | 7 ++++ Cargo.toml | 1 + crates/icp-cli/Cargo.toml | 1 + crates/icp-cli/src/commands/cycles/buy.rs | 41 +++++++++++++++++++++++ docs/guides/tokens-and-cycles.md | 2 +- 6 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4b8ff4c..d282dd8c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ air-gapped signing # Unreleased -* feat: `icp cycles buy --amount [--currency USD]` buys cycles with a card for the current identity through a cycles gateway canister. It quotes the amount, asks for confirmation, creates the order as the identity, prints the hosted checkout URL (and opens it in a browser from a terminal) and waits for the cycles to land on the identity's cycles-ledger account. `--resume` and `--cancel` continue or cancel an existing order; `--gateway` or `ICP_CYCLES_GATEWAY_CANISTER_ID` picks another gateway canister. +* feat: `icp cycles buy --amount [--currency USD]` buys cycles with a card for the current identity through a cycles gateway canister. It quotes the amount, asks for confirmation, creates the order as the identity, prints the hosted checkout URL with a QR code to scan (and opens the URL in a browser from a terminal) and waits for the cycles to land on the identity's cycles-ledger account. `--resume` and `--cancel` continue or cancel an existing order; `--gateway` or `ICP_CYCLES_GATEWAY_CANISTER_ID` picks another gateway canister. * feat: `icp deploy` and `icp canister create` take `--engine ` to target a cloud engine by name. The name is resolved to the engine's subnet through the engine canister, among the engines visible to the identity in use, and then behaves as `--subnet` would with that subnet; a subnet id passed as the engine is used as-is. `name/slug` or the engine's id picks one of several engines sharing a name. * fix: reject path-like network and environment names diff --git a/Cargo.lock b/Cargo.lock index da7e6a85e..0af00d753 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3684,6 +3684,7 @@ dependencies = [ "pem", "pkcs8", "predicates", + "qrcode", "rand 0.10.3", "regex", "reqwest", @@ -5683,6 +5684,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quinn" version = "0.11.12" diff --git a/Cargo.toml b/Cargo.toml index 8eaca939f..3afef0ac0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ nix = { version = "0.31.2", features = ["process", "signal"] } notify = "8.2.0" num-bigint = "0.4.6" open = "5" +qrcode = { version = "0.14.1", default-features = false } num-integer = "0.1.46" num-traits = "0.2.19" p256 = { version = "0.13.2", features = ["pem", "pkcs8", "std"] } diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 319d5e1e3..d24fee5cf 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -51,6 +51,7 @@ lazy_static.workspace = true num-integer.workspace = true num-traits.workspace = true open.workspace = true +qrcode.workspace = true p256.workspace = true pem.workspace = true pkcs8.workspace = true diff --git a/crates/icp-cli/src/commands/cycles/buy.rs b/crates/icp-cli/src/commands/cycles/buy.rs index cad086898..efa4bd557 100644 --- a/crates/icp-cli/src/commands/cycles/buy.rs +++ b/crates/icp-cli/src/commands/cycles/buy.rs @@ -337,6 +337,13 @@ fn announce_payable(order: &OrderView, output: Output, no_open: bool) { .unwrap_or_default(); info!("Order {}{until}. Pay here:", order.id); println!("{url}"); + // A phone camera is the usual way to reach a card from a terminal; + // the code is for eyes, so it only goes where there are some. + if stderr().is_terminal() + && let Some(qr) = render_qr(url) + { + eprintln!("{qr}"); + } if !no_open && stderr().is_terminal() { match open::that(url) { Ok(()) => info!("Opened the checkout page in your browser."), @@ -465,6 +472,21 @@ async fn wait_and_report( } } +/// The URL as a QR code drawn with half-block characters, two modules per +/// row, for scanning off the terminal. Inverted (light modules on the +/// terminal's dark background) because that is what most terminals are and +/// scanners read either way. `None` only if the URL is too long to encode. +fn render_qr(url: &str) -> Option { + use qrcode::render::unicode::Dense1x2; + let code = qrcode::QrCode::new(url.as_bytes()).ok()?; + Some( + code.render::() + .dark_color(Dense1x2::Light) + .light_color(Dense1x2::Dark) + .build(), + ) +} + fn waiting_message(status: OrderStatus) -> String { match status { OrderStatus::Paid => "Payment received, delivering cycles...".to_owned(), @@ -734,6 +756,25 @@ mod tests { assert_eq!(deploy_hint(&EnvironmentSelection::Default, &url), None); } + #[test] + fn qr_renders_a_checkout_url() { + let qr = + render_qr("https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4e5F6g7H8i9J0").unwrap(); + let lines: Vec<&str> = qr.lines().collect(); + // Square-ish: every row is the same width and the block characters + // are the only ink. + let width = lines[0].chars().count(); + assert!(width > 20, "{width}"); + assert!(lines.iter().all(|l| l.chars().count() == width)); + assert!( + qr.chars() + .all(|c| matches!(c, '█' | '▀' | '▄' | ' ' | '\n')), + "{qr}" + ); + // Far beyond a QR code's capacity. + assert!(render_qr(&"x".repeat(10_000)).is_none()); + } + #[test] fn format_ns_is_rfc3339() { assert_eq!(format_ns(1_700_000_000_000_000_000), "2023-11-14T22:13:20Z"); diff --git a/docs/guides/tokens-and-cycles.md b/docs/guides/tokens-and-cycles.md index d49fd44b3..a01d59433 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -131,7 +131,7 @@ Quote: 10.00 USD -> 7.238T cycles (includes a 0.59 USD card fee; rate locked Create this order? [y/N] ``` -It then creates the order as your identity, prints the hosted checkout URL (and opens it in your browser when run from a terminal), and waits until the cycles land on your identity's own cycles-ledger account. The card is charged on the payment processor's hosted page; the CLI never sees card details. `--amount` is in the currency given by `--currency`, which defaults to `USD`; USD is the only currency the gateway accepts today. +It then creates the order as your identity, prints the hosted checkout URL together with a QR code to scan with a phone (and opens the URL in your browser when run from a terminal), and waits until the cycles land on your identity's own cycles-ledger account. The card is charged on the payment processor's hosted page; the CLI never sees card details. `--amount` is in the currency given by `--currency`, which defaults to `USD`; USD is the only currency the gateway accepts today. Useful flags: From 161c51dff1b913cc2ccf5fe3ecef9eb9a857e92c Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 8 Oct 2026 18:58:06 +0000 Subject: [PATCH 5/8] fix(cycles buy): drop the `Next: icp deploy` hint after delivery The delivered report now ends with the cycles delivered and the balance. Which command spends them is the user's business, and a hint that guessed a deploy target was wrong as often as it was right. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh --- crates/icp-cli/src/commands/cycles/buy.rs | 73 +---------------------- docs/guides/tokens-and-cycles.md | 2 +- 2 files changed, 3 insertions(+), 72 deletions(-) diff --git a/crates/icp-cli/src/commands/cycles/buy.rs b/crates/icp-cli/src/commands/cycles/buy.rs index efa4bd557..366363e34 100644 --- a/crates/icp-cli/src/commands/cycles/buy.rs +++ b/crates/icp-cli/src/commands/cycles/buy.rs @@ -4,7 +4,7 @@ use anyhow::{anyhow, bail}; use candid::Principal; use clap::Args; use dialoguer::Confirm; -use icp_app::context::{Context, NetworkSelection}; +use icp_app::context::Context; use icp_app::identity::IdentitySelection; use icp_app::identity::manifest::IdentityDefaults; use icp_app::operations::cycles_purchase::{ @@ -17,8 +17,6 @@ use icp_canister_interfaces::cycles_gateway::{ }; use icp_canister_interfaces::cycles_ledger::CYCLES_LEDGER_PRINCIPAL; use icp_project::calls::CanisterCalls; -use icp_project::host::EnvironmentSelection; -use icp_project::prelude::IC; use icp_project::signal::stop_signal; use num_traits::ToPrimitive; use serde::Serialize; @@ -190,34 +188,7 @@ pub(crate) async fn exec(ctx: &Context, args: &BuyArgs) -> Result<(), anyhow::Er return Ok(()); } - let next = deploy_hint(&selections.environment, &selections.network); - wait_and_report( - calls, - &agent, - gateway, - buyer, - order, - output, - next.as_deref(), - ) - .await -} - -/// The deploy command that spends what was bought, if one can be named: the -/// environment that was selected, or the implicit `ic` environment when the -/// `ic` network was. A network named any other way has no environment to -/// point at, and a project's default environment is `icp deploy` itself. -fn deploy_hint(environment: &EnvironmentSelection, network: &NetworkSelection) -> Option { - match (environment, network) { - (EnvironmentSelection::Named(env), _) => Some(format!("icp deploy -e {env}")), - (EnvironmentSelection::Default, NetworkSelection::Named(name)) if name == IC => { - Some(format!("icp deploy -e {IC}")) - } - (EnvironmentSelection::Default, NetworkSelection::Default) => Some("icp deploy".to_owned()), - (EnvironmentSelection::Default, NetworkSelection::Named(_) | NetworkSelection::Url(..)) => { - None - } - } + wait_and_report(calls, &agent, gateway, buyer, order, output).await } /// Quote, confirm, create. Re-quotes and re-confirms once if the rate moved @@ -361,7 +332,6 @@ async fn wait_and_report( buyer: Principal, order: OrderView, output: Output, - next: Option<&str>, ) -> Result<(), anyhow::Error> { let progress = ProgressManager::new(ProgressManagerSettings { hidden: output != Output::Human, @@ -439,9 +409,6 @@ async fn wait_and_report( if let Some(balance) = balance { println!("Balance: {}", format_cycles(balance)); } - if let Some(next) = next { - info!("Next: {next}"); - } } Ok(()) } @@ -720,42 +687,6 @@ mod tests { ); } - #[test] - fn deploy_hint_names_the_target_that_was_used() { - let url = NetworkSelection::Url( - "http://localhost:8000".parse().unwrap(), - icp_project::network::RootKeySpec::Fetch, - ); - assert_eq!( - deploy_hint( - &EnvironmentSelection::Named("staging".into()), - &NetworkSelection::Default - ) - .as_deref(), - Some("icp deploy -e staging") - ); - assert_eq!( - deploy_hint( - &EnvironmentSelection::Default, - &NetworkSelection::Named("ic".into()) - ) - .as_deref(), - Some("icp deploy -e ic") - ); - assert_eq!( - deploy_hint(&EnvironmentSelection::Default, &NetworkSelection::Default).as_deref(), - Some("icp deploy") - ); - assert_eq!( - deploy_hint( - &EnvironmentSelection::Default, - &NetworkSelection::Named("local".into()) - ), - None - ); - assert_eq!(deploy_hint(&EnvironmentSelection::Default, &url), None); - } - #[test] fn qr_renders_a_checkout_url() { let qr = diff --git a/docs/guides/tokens-and-cycles.md b/docs/guides/tokens-and-cycles.md index a01d59433..35fde3a6c 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -143,7 +143,7 @@ Useful flags: Pressing Ctrl-C while waiting leaves the order payable; the command prints how to resume or cancel it. If the order ends up needing review, your card was charged and the gateway operator resolves delivery; keep the order id. -The gateway is a third-party canister that takes card payments. The default is the CyclePay backend on mainnet. The cycles arrive in your identity's default cycles-ledger account; fund a canister from there with `icp canister top-up`, or run `icp deploy -e ic`. +The gateway is a third-party canister that takes card payments. The default is the CyclePay backend on mainnet. The cycles arrive in your identity's default cycles-ledger account, where `icp deploy` and `icp canister top-up` spend from. ## Transferring ICP Tokens From e7918def8fc56b6ac28e058cf1e03319db7bad51 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 8 Oct 2026 23:27:30 +0000 Subject: [PATCH 6/8] feat(cycles buy): take `--cycles ` and pay the least that buys them `--cycles 5t` names the cycles to receive instead of the sum to spend. The gateway only tells what a given amount buys, so the operation reads its fee-plus-rate model back off two anchor quotes, names the cent that should just clear the target, quotes it and its neighbours for real and takes the least that clears; if rounding leaves them all short it re-anchors and tries again. The order's floor is then the figure asked for rather than 5 % under the quote, so a rate move is refused and re-quoted rather than delivering fewer cycles than requested. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh --- CHANGELOG.md | 2 +- .../src/operations/cycles_purchase/mod.rs | 232 ++++++++++++++++-- .../src/operations/cycles_purchase/tests.rs | 200 ++++++++++++++- crates/icp-cli/src/commands/cycles/buy.rs | 58 +++-- crates/icp-cli/tests/cycles_tests.rs | 18 ++ docs/guides/tokens-and-cycles.md | 6 + docs/reference/cli.md | 5 +- 7 files changed, 467 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d282dd8c5..3d5aa1ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ air-gapped signing # Unreleased -* feat: `icp cycles buy --amount [--currency USD]` buys cycles with a card for the current identity through a cycles gateway canister. It quotes the amount, asks for confirmation, creates the order as the identity, prints the hosted checkout URL with a QR code to scan (and opens the URL in a browser from a terminal) and waits for the cycles to land on the identity's cycles-ledger account. `--resume` and `--cancel` continue or cancel an existing order; `--gateway` or `ICP_CYCLES_GATEWAY_CANISTER_ID` picks another gateway canister. +* feat: `icp cycles buy --amount [--currency USD]` buys cycles with a card for the current identity through a cycles gateway canister; `--cycles ` instead names the cycles to receive and pays the least amount that buys them, card fee included. It quotes the amount, asks for confirmation, creates the order as the identity, prints the hosted checkout URL with a QR code to scan (and opens the URL in a browser from a terminal) and waits for the cycles to land on the identity's cycles-ledger account. `--resume` and `--cancel` continue or cancel an existing order; `--gateway` or `ICP_CYCLES_GATEWAY_CANISTER_ID` picks another gateway canister. * feat: `icp deploy` and `icp canister create` take `--engine ` to target a cloud engine by name. The name is resolved to the engine's subnet through the engine canister, among the engines visible to the identity in use, and then behaves as `--subnet` would with that subnet; a subnet id passed as the engine is used as-is. `name/slug` or the engine's id picks one of several engines sharing a name. * fix: reject path-like network and environment names diff --git a/crates/icp-app/src/operations/cycles_purchase/mod.rs b/crates/icp-app/src/operations/cycles_purchase/mod.rs index da687abc9..db25442e2 100644 --- a/crates/icp-app/src/operations/cycles_purchase/mod.rs +++ b/crates/icp-app/src/operations/cycles_purchase/mod.rs @@ -71,6 +71,12 @@ pub enum CyclesPurchaseError { ))] RateUnavailable { gateway: Principal }, + #[snafu(display( + "cycles gateway {gateway} quotes no amount that buys {cycles} cycles; \ + try a different amount, or spend a sum with --amount" + ))] + CyclesUnpriceable { gateway: Principal, cycles: u128 }, + #[snafu(display("failed to create an order on cycles gateway {gateway}"))] CreateOrder { gateway: Principal, @@ -146,6 +152,16 @@ pub enum CyclesPurchaseError { }, } +/// What the buyer asked for: a sum to spend, or cycles to receive. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Target { + /// Spend exactly this much; take whatever cycles it buys. + Spend(Money), + /// Receive at least this many cycles; spend the least `currency` that + /// buys them. + Receive { cycles: u128, currency: Currency }, +} + /// What an amount buys, as the gateway priced it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Quote { @@ -155,14 +171,29 @@ pub struct Quote { pub fee: Money, /// The cycles delivered for `amount` at the current rate. pub cycles: u128, + /// The fewest cycles the buyer will accept. Passed to the gateway so a + /// rate move against the buyer is refused rather than silently + /// delivering less: the quote less [`QUOTE_SLIPPAGE_PERCENT`] when + /// spending a sum, or the cycles asked for when buying a number of them. + pub minimum_cycles: u128, +} + +/// The fewest cycles a buyer who is spending a sum will accept for it. +fn with_slippage(cycles: u128) -> u128 { + cycles * (100 - QUOTE_SLIPPAGE_PERCENT) / 100 } -impl Quote { - /// The fewest cycles the buyer will accept: the quote less - /// [`QUOTE_SLIPPAGE_PERCENT`]. Passed to the gateway so a rate move - /// against the buyer is refused rather than silently delivering less. - pub fn minimum_cycles(&self) -> u128 { - self.cycles * (100 - QUOTE_SLIPPAGE_PERCENT) / 100 +/// Price a [`Target`]. +pub async fn quote_target( + calls: &dyn CanisterCalls, + gateway: Principal, + target: Target, +) -> Result { + match target { + Target::Spend(amount) => quote(calls, gateway, amount).await, + Target::Receive { cycles, currency } => { + quote_for_cycles(calls, gateway, cycles, currency).await + } } } @@ -226,11 +257,46 @@ pub async fn quote( gateway: Principal, amount: Money, ) -> Result { - let minor_units = match amount.currency { - Currency::Usd => Nat::from(amount.minor_units), - }; + let previews = previews(calls, gateway, &[amount]).await?; + let preview = previews + .into_iter() + .next() + .context(QuoteMissingSnafu { gateway, amount })?; + let cycles = preview.cycles.context(RateUnavailableSnafu { gateway })?; + Ok(Quote { + amount, + fee: preview.fee, + cycles, + minimum_cycles: with_slippage(cycles), + }) +} + +/// A gateway quote with its numbers converted, keyed by the amount quoted. +#[derive(Clone, Copy, Debug)] +struct Preview { + amount: Money, + fee: Money, + /// What `amount` buys; `None` when the gateway has no rate, or the fee + /// swallows the whole amount. + cycles: Option, + /// `amount` less `fee`; `None` when the fee swallows it. + net: Option, +} + +/// Quote several amounts in one round trip. +async fn previews( + calls: &dyn CanisterCalls, + gateway: Principal, + amounts: &[Money], +) -> Result, CyclesPurchaseError> { + let minor_units: Vec = amounts + .iter() + .map(|amount| match amount.currency { + Currency::Usd => Nat::from(amount.minor_units), + }) + .collect(); let (previews,): (QuotePreviews,) = - query_typed(calls, gateway, QUOTE_PREVIEWS_METHOD, (vec![minor_units],)) + query_typed(calls, gateway, QUOTE_PREVIEWS_METHOD, (minor_units,)) .await .map_err(|source| { map_absent(source, gateway, |source| { @@ -240,24 +306,136 @@ pub async fn quote( } }) })?; - let preview = previews + previews .quotes .into_iter() - .next() - .context(QuoteMissingSnafu { gateway, amount })?; - let cycles = preview - .cycles - .context(RateUnavailableSnafu { gateway })? - .0 - .to_u128() - .context(ConvertFieldSnafu { field: "cycles" })?; - let fee = Money::from_nat_minor_units(&preview.fee_cents, amount.currency) - .context(ConvertFieldSnafu { field: "feeCents" })?; - Ok(Quote { - amount, - fee, - cycles, - }) + .zip(amounts) + .map(|(preview, amount)| { + let currency = amount.currency; + Ok(Preview { + amount: Money::from_nat_minor_units(&preview.usd_cents, currency) + .context(ConvertFieldSnafu { field: "usdCents" })?, + fee: Money::from_nat_minor_units(&preview.fee_cents, currency) + .context(ConvertFieldSnafu { field: "feeCents" })?, + cycles: preview + .cycles + .map(|cycles| { + cycles + .0 + .to_u128() + .context(ConvertFieldSnafu { field: "cycles" }) + }) + .transpose()?, + net: preview + .net_cents + .map(|net| { + Money::from_nat_minor_units(&net, currency) + .context(ConvertFieldSnafu { field: "netCents" }) + }) + .transpose()?, + }) + }) + .collect() +} + +/// Amounts whose quotes anchor the search in [`quote_for_cycles`]: large +/// enough that the fee does not swallow them, far enough apart that the +/// fee's variable part shows between them. +const PROBE_MINOR_UNITS: [u64; 2] = [1_000, 2_000]; + +/// Rounds of estimate-and-check before giving up on a cycles target. +const CYCLES_SEARCH_ROUNDS: usize = 4; + +/// Find the least `currency` whose quote delivers at least `cycles`. +/// +/// The gateway prices an amount as `(amount - fee) * rate` with a fee of a +/// fixed part plus a share of the amount, but tells only the outcome, so two +/// anchor quotes are read back into that model, the model names the amount +/// that should just clear the target, and that amount and its neighbours +/// are quoted for real; the least that clears wins. If rounding leaves all +/// of them short, the shortfall re-anchors the model and it tries again. +pub async fn quote_for_cycles( + calls: &dyn CanisterCalls, + gateway: Principal, + cycles: u128, + currency: Currency, +) -> Result { + let unpriceable = || CyclesUnpriceableSnafu { gateway, cycles }; + let probes = PROBE_MINOR_UNITS.map(|units| Money::from_minor_units(units, currency)); + let quoted = previews(calls, gateway, &probes).await?; + let [mut low, mut high]: [Preview; 2] = quoted.try_into().ok().context(QuoteMissingSnafu { + gateway, + amount: probes[1], + })?; + if high.cycles.is_none() { + return RateUnavailableSnafu { gateway }.fail(); + } + + for _ in 0..CYCLES_SEARCH_ROUNDS { + let estimate = estimate_amount(cycles, &low, &high).context(unpriceable())?; + let candidates: Vec = (estimate.saturating_sub(1)..=estimate.saturating_add(2)) + .filter(|units| *units > 0) + .map(|units| Money::from_minor_units(units, currency)) + .collect(); + let quoted = previews(calls, gateway, &candidates).await?; + if let Some(hit) = quoted + .iter() + .filter(|preview| preview.cycles.is_some_and(|got| got >= cycles)) + .min_by_key(|preview| preview.amount.minor_units) + { + return Ok(Quote { + amount: hit.amount, + fee: hit.fee, + cycles: hit.cycles.unwrap_or(cycles), + minimum_cycles: cycles, + }); + } + // Every candidate fell short: re-anchor on the two largest real + // quotes and let the model correct itself. + let mut priced: Vec = quoted + .into_iter() + .filter(|preview| preview.cycles.is_some()) + .collect(); + priced.sort_by_key(|preview| preview.amount.minor_units); + match priced.as_slice() { + [.., a, b] => { + low = *a; + high = *b; + } + _ => return unpriceable().fail(), + } + } + unpriceable().fail() +} + +/// The amount that should deliver `cycles`, read off two priced quotes: +/// the rate is cycles per net minor unit on `high`, the fee's variable +/// share is the fee difference over the amount difference, and the fixed +/// part is what remains. `None` when the figures do not fit or do not +/// describe a price. +fn estimate_amount(cycles: u128, low: &Preview, high: &Preview) -> Option { + let rate_cycles = high.cycles?; + let rate_net = u128::from(high.net?.minor_units); + if rate_cycles == 0 || rate_net == 0 { + return None; + } + let net_needed = cycles.checked_mul(rate_net)?.div_ceil(rate_cycles).max(1); + + let amount_delta = u128::from(high.amount.minor_units) + .checked_sub(u128::from(low.amount.minor_units)) + .filter(|delta| *delta > 0)?; + let fee_delta = u128::from(high.fee.minor_units) + .saturating_sub(u128::from(low.fee.minor_units)) + .min(amount_delta - 1); + // fixed = fee(low) - share * amount(low), kept over `amount_delta`. + let fixed_scaled = (u128::from(low.fee.minor_units) * amount_delta) + .saturating_sub(fee_delta * u128::from(low.amount.minor_units)); + // amount = (net + fixed) / (1 - share) + let amount = net_needed + .checked_mul(amount_delta)? + .checked_add(fixed_scaled)? + .div_ceil(amount_delta - fee_delta); + u64::try_from(amount).ok() } /// Create an order for `quote`, delivering to the caller's own default @@ -280,7 +458,7 @@ pub async fn place_order( owner: calls.caller(), subaccount: None, }); - let min_cycles = Some(Nat::from(quote.minimum_cycles())); + let min_cycles = Some(Nat::from(quote.minimum_cycles)); let (result,): (CreateOrderResult,) = update_typed( calls, diff --git a/crates/icp-app/src/operations/cycles_purchase/tests.rs b/crates/icp-app/src/operations/cycles_purchase/tests.rs index 8826c74e2..9258c60cf 100644 --- a/crates/icp-app/src/operations/cycles_purchase/tests.rs +++ b/crates/icp-app/src/operations/cycles_purchase/tests.rs @@ -73,12 +73,44 @@ fn rejected(message: &str) -> CallError { } } +/// How a fake gateway prices an amount: a card fee of `fee_fixed` plus +/// `fee_bps` basis points of the amount, rounded up, then `rate` cycles per +/// net cent. +#[derive(Clone, Copy, Debug)] +struct Pricing { + rate: u128, + fee_fixed: u64, + fee_bps: u64, +} + +impl Pricing { + fn preview(&self, cents: u64) -> QuotePreview { + let fee = (cents * self.fee_bps).div_ceil(10_000) + self.fee_fixed; + let net = cents.checked_sub(fee).filter(|net| *net > 0); + QuotePreview { + cycles: net.map(|net| Nat::from(u128::from(net) * self.rate)), + fee_cents: Nat::from(fee), + net_cents: net.map(Nat::from), + usd_cents: Nat::from(cents), + } + } + + fn cycles_for(&self, cents: u64) -> u128 { + self.preview(cents) + .cycles + .map(|c| c.0.try_into().unwrap()) + .unwrap_or(0) + } +} + /// A cycles gateway whose replies are scripted per method. #[derive(Default)] struct FakeGateway { - /// The one preview `quote_previews` answers with; `None` answers with - /// no previews at all. + /// The one preview `quote_previews` answers with, whatever was asked; + /// `None` answers with no previews at all. quote: Option, + /// When set, `quote_previews` prices each amount asked for instead. + pricing: Option, /// One reply per `create_order` call, in order. create: Mutex>, /// One reply per `get_order` poll, in order. An `Err` is the network @@ -182,10 +214,16 @@ impl CanisterCalls for FakeGateway { self.record(&call)?; match call.method.as_str() { QUOTE_PREVIEWS_METHOD => { - let previews = QuotePreviews { - quotes: self.quote.iter().cloned().collect(), + let asked = Decode!(&call.arg, Vec).unwrap(); + let quotes = match (self.pricing, &self.quote) { + (Some(pricing), _) => asked + .iter() + .map(|cents| pricing.preview(cents.0.clone().try_into().unwrap())) + .collect(), + (None, Some(quote)) => vec![quote.clone(); asked.len()], + (None, None) => vec![], }; - Ok(Encode!(&previews).unwrap()) + Ok(Encode!(&QuotePreviews { quotes }).unwrap()) } GET_ORDER_METHOD => { let reply = self @@ -248,6 +286,22 @@ fn ten_dollars() -> Quote { amount: usd(1000), fee: usd(59), cycles: 7_238_000_000_000, + minimum_cycles: 7_238_000_000_000 * 95 / 100, + } +} + +/// Roughly the mainnet gateway's pricing on 2026-10-07: a 2.9 % + 30 c card +/// fee, and 7.69 G cycles per net cent. +const STRIPE_LIKE: Pricing = Pricing { + rate: 7_690_000_000, + fee_fixed: 30, + fee_bps: 290, +}; + +fn priced(pricing: Pricing) -> FakeGateway { + FakeGateway { + pricing: Some(pricing), + ..FakeGateway::default() } } @@ -258,7 +312,6 @@ async fn quote_maps_preview_to_quote() { let fake = FakeGateway::with_quote(1000, 59, Some(7_238_000_000_000)); let q = quote(&fake, gateway(), usd(1000)).await.unwrap(); assert_eq!(q, ten_dollars()); - assert_eq!(q.minimum_cycles(), 7_238_000_000_000 * 95 / 100); let amounts = Decode!(&fake.arg_of(QUOTE_PREVIEWS_METHOD), Vec).unwrap(); assert_eq!(amounts, vec![Nat::from(1000u32)]); } @@ -296,6 +349,141 @@ async fn quote_on_missing_gateway_is_gateway_not_found() { ); } +// --- quote_for_cycles ------------------------------------------------------ + +/// `cents` is the least amount `pricing` delivers `cycles` for: it clears, +/// and one cent less does not. Pricing is monotone, so that is enough. +fn assert_cheapest(pricing: Pricing, cycles: u128, cents: u64) { + assert!( + pricing.cycles_for(cents) >= cycles, + "{pricing:?}: {cents} c does not buy {cycles}" + ); + assert!( + pricing.cycles_for(cents - 1) < cycles, + "{pricing:?}: {} c already buys {cycles}", + cents - 1 + ); +} + +#[tokio::test] +async fn quote_for_cycles_finds_the_least_amount_that_clears() { + let fake = priced(STRIPE_LIKE); + let target = 5_000_000_000_000; + let q = quote_for_cycles(&fake, gateway(), target, Currency::Usd) + .await + .unwrap(); + assert_cheapest(STRIPE_LIKE, target, q.amount.minor_units); + assert_eq!(q.amount, usd(702)); + assert_eq!(q.fee, usd(51)); + assert_eq!(q.cycles, STRIPE_LIKE.cycles_for(702)); + assert!(q.cycles >= target); + assert_eq!(q.minimum_cycles, target); + // One batch of anchors, one batch of candidates. + assert_eq!(fake.count_of(QUOTE_PREVIEWS_METHOD), 2); +} + +#[tokio::test] +async fn quote_for_cycles_is_cheapest_across_fee_shapes() { + let shapes = [ + STRIPE_LIKE, + Pricing { + rate: 1, + fee_fixed: 0, + fee_bps: 0, + }, + Pricing { + rate: 123_456_789, + fee_fixed: 100, + fee_bps: 0, + }, + Pricing { + rate: 999_999_999_999, + fee_fixed: 0, + fee_bps: 1_500, + }, + ]; + for pricing in shapes { + for target in [ + 1, + 1_000, + 7_690_000_000, + 5_000_000_000_000, + 123_456_789_012_345, + ] { + let fake = priced(pricing); + let q = quote_for_cycles(&fake, gateway(), target, Currency::Usd) + .await + .unwrap_or_else(|e| panic!("{pricing:?} x {target}: {e}")); + assert_cheapest(pricing, target, q.amount.minor_units); + assert!(fake.count_of(QUOTE_PREVIEWS_METHOD) <= 1 + CYCLES_SEARCH_ROUNDS); + } + } +} + +#[tokio::test] +async fn quote_for_cycles_without_rate_is_rate_unavailable() { + let fake = FakeGateway::with_quote(1000, 59, None); + let err = quote_for_cycles(&fake, gateway(), 1, Currency::Usd) + .await + .unwrap_err(); + assert!( + matches!(err, CyclesPurchaseError::RateUnavailable { .. }), + "{err}" + ); +} + +#[tokio::test] +async fn quote_for_cycles_beyond_any_amount_is_unpriceable() { + let fake = priced(STRIPE_LIKE); + let err = quote_for_cycles(&fake, gateway(), u128::MAX, Currency::Usd) + .await + .unwrap_err(); + assert!( + matches!(err, CyclesPurchaseError::CyclesUnpriceable { .. }), + "{err}" + ); +} + +#[tokio::test] +async fn quote_target_prices_either_kind() { + let fake = priced(STRIPE_LIKE); + let spend = quote_target(&fake, gateway(), Target::Spend(usd(1000))) + .await + .unwrap(); + assert_eq!(spend.amount, usd(1000)); + assert_eq!(spend.minimum_cycles, with_slippage(spend.cycles)); + let receive = quote_target( + &fake, + gateway(), + Target::Receive { + cycles: 5_000_000_000_000, + currency: Currency::Usd, + }, + ) + .await + .unwrap(); + assert_eq!(receive.amount, usd(702)); + assert_eq!(receive.minimum_cycles, 5_000_000_000_000); +} + +#[tokio::test] +async fn place_order_sends_the_quotes_own_minimum() { + let fake = FakeGateway::default().creates(vec![created(order("o1", OrderStatus::Created))]); + let quote = Quote { + minimum_cycles: 5_000_000_000_000, + ..ten_dollars() + }; + place_order(&fake, gateway(), "e).await.unwrap(); + let (_, _, min) = Decode!( + &fake.arg_of(CREATE_ORDER_METHOD), + Amount, + Destination, + Option + ) + .unwrap(); + assert_eq!(min, Some(Nat::from(5_000_000_000_000u64))); +} + // --- place_order ----------------------------------------------------------- #[tokio::test] diff --git a/crates/icp-cli/src/commands/cycles/buy.rs b/crates/icp-cli/src/commands/cycles/buy.rs index 366363e34..db520e501 100644 --- a/crates/icp-cli/src/commands/cycles/buy.rs +++ b/crates/icp-cli/src/commands/cycles/buy.rs @@ -8,7 +8,7 @@ use icp_app::context::Context; use icp_app::identity::IdentitySelection; use icp_app::identity::manifest::IdentityDefaults; use icp_app::operations::cycles_purchase::{ - self as purchase, Currency, CyclesPurchaseError, Money, OrderView, Quote, WaitOptions, + self as purchase, Currency, CyclesPurchaseError, Money, OrderView, Quote, Target, WaitOptions, }; use icp_app::operations::token::balance::get_raw_balance; use icp_app::operations::token::format_cycles; @@ -17,6 +17,7 @@ use icp_canister_interfaces::cycles_gateway::{ }; use icp_canister_interfaces::cycles_ledger::CYCLES_LEDGER_PRINCIPAL; use icp_project::calls::CanisterCalls; +use icp_project::parsers::CyclesAmount; use icp_project::signal::stop_signal; use num_traits::ToPrimitive; use serde::Serialize; @@ -32,20 +33,28 @@ use crate::render::{ProgressManager, ProgressManagerSettings}; /// prints the hosted checkout URL (and opens it in a browser when run from a /// terminal), then waits for the cycles to land on the identity's own /// cycles-ledger account. Fund a canister from there with `icp canister -/// top-up`. Amounts are in USD unless --currency says otherwise; the gateway accepts USD only today. +/// top-up`. Say either how much to spend (--amount, in the currency given +/// by --currency) or how many cycles to receive (--cycles); the latter is +/// quoted as the least amount that buys them once the card fee is taken out. +/// The gateway accepts USD only today. /// -/// Exactly one of --amount, --resume or --cancel must be given. +/// Exactly one of --amount, --cycles, --resume or --cancel must be given. #[derive(Debug, Args)] pub(crate) struct BuyArgs { /// Amount to spend, as a decimal (e.g. 10 or 12.50), in the currency given by --currency #[arg( long, value_name = "AMOUNT", - required_unless_present_any = ["resume", "cancel"], - conflicts_with_all = ["resume", "cancel"] + required_unless_present_any = ["cycles", "resume", "cancel"], + conflicts_with_all = ["cycles", "resume", "cancel"] )] pub(crate) amount: Option, + /// Cycles to receive; the amount to pay is worked out from the gateway's quote, fee included. + /// Supports suffixes: k (thousand), m (million), b (billion), t (trillion). + #[arg(long, value_name = "CYCLES", conflicts_with_all = ["resume", "cancel"])] + pub(crate) cycles: Option, + /// ISO 4217 code of the currency to pay in. The gateway accepts USD only. #[arg(long, value_name = "CODE", default_value = "USD")] pub(crate) currency: Currency, @@ -109,11 +118,19 @@ impl Output { pub(crate) async fn exec(ctx: &Context, args: &BuyArgs) -> Result<(), anyhow::Error> { // A malformed amount should fail before any identity is unlocked or any // network reached. - let amount = args - .amount - .as_deref() - .map(|amount| Money::parse_decimal(amount, args.currency)) - .transpose()?; + let target = match (args.amount.as_deref(), args.cycles.as_ref()) { + (Some(amount), _) => Some(Target::Spend(Money::parse_decimal(amount, args.currency)?)), + (None, Some(cycles)) => { + if cycles.get() == 0 { + bail!("--cycles must be greater than zero"); + } + Some(Target::Receive { + cycles: cycles.get(), + currency: args.currency, + }) + } + (None, None) => None, + }; let selections = args.token_command_args.selections(); if matches!(selections.identity, IdentitySelection::Anonymous) { @@ -168,11 +185,11 @@ pub(crate) async fn exec(ctx: &Context, args: &BuyArgs) -> Result<(), anyhow::Er order } None => { - let amount = - amount.expect("clap requires --amount unless --resume or --cancel is given"); + let target = target + .expect("clap requires --amount or --cycles unless --resume or --cancel is given"); let identity = identity_name(ctx, &selections.identity).await; let order = - create_order(calls, gateway, buyer, identity.as_deref(), amount, args).await?; + create_order(calls, gateway, buyer, identity.as_deref(), target, args).await?; announce_payable(&order, output, args.no_open); order } @@ -198,11 +215,11 @@ async fn create_order( gateway: Principal, buyer: Principal, identity: Option<&str>, - amount: Money, + target: Target, args: &BuyArgs, ) -> Result { - let mut quote = purchase::quote(calls, gateway, amount).await?; - confirm("e, gateway, buyer, identity, args.yes)?; + let mut quote = purchase::quote_target(calls, gateway, target).await?; + confirm("e, target, gateway, buyer, identity, args.yes)?; for attempt in 0..2 { match purchase::place_order(calls, gateway, "e).await { @@ -210,7 +227,7 @@ async fn create_order( Err(CyclesPurchaseError::OrderRefused { error: CreateOrderError::QuoteChanged { .. }, }) if attempt == 0 => { - quote = purchase::quote(calls, gateway, amount).await?; + quote = purchase::quote_target(calls, gateway, target).await?; if args.yes { info!( "The exchange rate moved. New quote: {}. Retrying once.", @@ -218,7 +235,7 @@ async fn create_order( ); } else { warn!("The exchange rate moved since the quote."); - confirm("e, gateway, buyer, identity, false)?; + confirm("e, target, gateway, buyer, identity, false)?; } } Err(CyclesPurchaseError::OrderRefused { error }) => { @@ -237,6 +254,7 @@ async fn create_order( /// to ask, so `--yes` is required. fn confirm( quote: &Quote, + target: Target, gateway: Principal, buyer: Principal, identity: Option<&str>, @@ -247,6 +265,9 @@ fn confirm( .unwrap_or_default(); info!("Buyer: {buyer}{identity}"); info!("Gateway: {gateway} (canister id)"); + if let Target::Receive { cycles, .. } = target { + info!("Wanted: at least {}", format_cycles(cycles)); + } info!("Quote: {}", quote_line(quote)); if yes { @@ -680,6 +701,7 @@ mod tests { amount: Money::from_minor_units(1000, Currency::Usd), fee: Money::from_minor_units(59, Currency::Usd), cycles: 7_238_000_000_000, + minimum_cycles: 0, }); assert_eq!( line, diff --git a/crates/icp-cli/tests/cycles_tests.rs b/crates/icp-cli/tests/cycles_tests.rs index c3ef4e42d..c0f18b508 100644 --- a/crates/icp-cli/tests/cycles_tests.rs +++ b/crates/icp-cli/tests/cycles_tests.rs @@ -460,6 +460,24 @@ fn cycles_buy_rejects_conflicting_arguments() { .assert() .stderr(contains("greater than zero")) .failure(); + + ctx.icp() + .args(["cycles", "buy", "--amount", "10", "--cycles", "5t"]) + .assert() + .stderr(contains("cannot be used with")) + .failure(); + + ctx.icp() + .args(["cycles", "buy", "--cycles", "5t", "--resume", "o1"]) + .assert() + .stderr(contains("cannot be used with")) + .failure(); + + ctx.icp() + .args(["cycles", "buy", "--cycles", "0"]) + .assert() + .stderr(contains("greater than zero")) + .failure(); } /// The anonymous identity cannot own an order, so the command refuses before diff --git a/docs/guides/tokens-and-cycles.md b/docs/guides/tokens-and-cycles.md index 35fde3a6c..bcac86d9b 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -119,7 +119,11 @@ icp cycles balance -n ic If you have a card but no ICP, you can buy cycles directly for the current identity through a cycles gateway canister: ```bash +# Spend 10 USD icp cycles buy --amount 10 -n ic + +# Or ask for the cycles you need, and pay the least that buys them +icp cycles buy --cycles 5t -n ic ``` The command quotes what the amount buys, shows who is paying and which gateway canister the order goes to, and asks you to confirm: @@ -133,6 +137,8 @@ Create this order? [y/N] It then creates the order as your identity, prints the hosted checkout URL together with a QR code to scan with a phone (and opens the URL in your browser when run from a terminal), and waits until the cycles land on your identity's own cycles-ledger account. The card is charged on the payment processor's hosted page; the CLI never sees card details. `--amount` is in the currency given by `--currency`, which defaults to `USD`; USD is the only currency the gateway accepts today. +With `--cycles`, the CLI asks the gateway for quotes and picks the smallest amount, to the cent, whose quote delivers at least that many cycles once the card fee is taken out. The confirmation then shows what you asked for above the quote, and the order is created with that figure as its floor: if the rate moves before the order is created so that the amount would deliver less, the gateway refuses it and the CLI re-quotes once rather than delivering fewer cycles than you asked for. (With `--amount` the floor is 5% below the quote instead.) + Useful flags: - `--no-open` prints the URL without opening a browser, for SSH sessions and containers. You can pay from any device. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c294414ba..676cc0744 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -910,15 +910,16 @@ Display the cycles balance Buy cycles with a card. -Creates an order on a cycles gateway canister as the current identity, prints the hosted checkout URL (and opens it in a browser when run from a terminal), then waits for the cycles to land on the identity's own cycles-ledger account. Fund a canister from there with `icp canister top-up`. Amounts are in USD unless --currency says otherwise; the gateway accepts USD only today. +Creates an order on a cycles gateway canister as the current identity, prints the hosted checkout URL (and opens it in a browser when run from a terminal), then waits for the cycles to land on the identity's own cycles-ledger account. Fund a canister from there with `icp canister top-up`. Say either how much to spend (--amount, in the currency given by --currency) or how many cycles to receive (--cycles); the latter is quoted as the least amount that buys them once the card fee is taken out. The gateway accepts USD only today. -Exactly one of --amount, --resume or --cancel must be given. +Exactly one of --amount, --cycles, --resume or --cancel must be given. **Usage:** `icp cycles buy [OPTIONS]` ###### **Options:** * `--amount ` — Amount to spend, as a decimal (e.g. 10 or 12.50), in the currency given by --currency +* `--cycles ` — Cycles to receive; the amount to pay is worked out from the gateway's quote, fee included. Supports suffixes: k (thousand), m (million), b (billion), t (trillion) * `--currency ` — ISO 4217 code of the currency to pay in. The gateway accepts USD only Default value: `USD` From 9ee62e4daa3e840c82b96b962ea1609dd1771eb2 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Fri, 9 Oct 2026 16:03:40 +0000 Subject: [PATCH 7/8] fix(cycles buy): address review on #813 - Report the wait as icp-events tasks, one per purchase phase, instead of a callback into a bespoke spinner; a `CyclesPurchaseTask` joins the task vocabulary and the command renders it like any other operation. - Race the interrupt against the poll itself, not only the sleep after it, so Ctrl-C lands during a stalled call and during the first poll. - Under `--yes`, a re-quote after `quoteChanged` is placed only when it stays within the slippage tolerance of the quote that was shown; past it the command stops without an order. - `--resume` checks the payment deadline before calling an order payable and opening its checkout page. - `--no-wait` on an order with nothing left to pay prints its status in JSON and quiet modes rather than exiting silently. - The JSON created-order object carries the quoted amount and fee. - Drop `Order.destination`, a single-case variant the CLI never read that would stop every order decoding once the gateway adds a case. - Overflow-proof the slippage floor and `format_cycles` on values the gateway supplies. - The deploy readiness probe no longer reads IC0301 as "serving": an HTTP gateway's canister_not_found now maps to that code, and routing that has not caught up with a creation is not readiness. - Share the IC0301 constant from icp-project instead of duplicating it. - Replace the unreachable bail after the retry loop. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh --- crates/icp-app/src/calls.rs | 6 +- .../src/operations/cycles_purchase/mod.rs | 138 +++++++++-- .../src/operations/cycles_purchase/tests.rs | 169 ++++++++++++-- crates/icp-app/src/operations/token/mod.rs | 6 +- .../src/cycles_gateway.rs | 11 +- crates/icp-cli/src/commands/cycles/buy.rs | 220 ++++++++++++------ crates/icp-project/src/calls.rs | 4 +- crates/icp-project/src/operations/deploy.rs | 22 +- crates/icp-project/src/operations/task.rs | 72 ++++++ docs/guides/tokens-and-cycles.md | 2 +- 10 files changed, 508 insertions(+), 142 deletions(-) diff --git a/crates/icp-app/src/calls.rs b/crates/icp-app/src/calls.rs index 25153b9ae..153048c7d 100644 --- a/crates/icp-app/src/calls.rs +++ b/crates/icp-app/src/calls.rs @@ -21,7 +21,7 @@ use ic_agent::{ }; use ic_management_canister_types::{CanisterMetadataArgs, CanisterMetadataResult}; use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult}; -use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo}; +use icp_project::calls::{Authority, CANISTER_NOT_FOUND, Call, CallError, CanisterCalls, RouteTo}; /// [`CanisterCalls`] over an `ic-agent`, optionally forwarding through a proxy /// canister. @@ -414,10 +414,6 @@ impl CanisterCalls for AgentCalls { } } -/// The replica's error code for a call addressed to a canister that does not -/// exist, which [`CallError::is_canister_not_found`] recognizes. -const CANISTER_NOT_FOUND: &str = "IC0301"; - /// Whether an HTTP gateway's error says the canister it was asked to route to /// does not exist: a 4xx whose body is the gateway's `canister_not_found`. fn is_canister_not_found_http(payload: &HttpErrorPayload) -> bool { diff --git a/crates/icp-app/src/operations/cycles_purchase/mod.rs b/crates/icp-app/src/operations/cycles_purchase/mod.rs index db25442e2..4c58a00cf 100644 --- a/crates/icp-app/src/operations/cycles_purchase/mod.rs +++ b/crates/icp-app/src/operations/cycles_purchase/mod.rs @@ -6,9 +6,10 @@ //! hosted checkout page the order carries, then wait for the gateway to flip //! the order to `delivered`. //! -//! Nothing here prints. [`wait_for_order`] reports each status change through -//! a callback and hands the last order it saw back inside its error when it -//! is interrupted, so the caller can say how to resume. +//! Nothing here prints. [`wait_for_order`] reports its progress as +//! [`icp_events`] tasks, one per [`PurchasePhase`], and hands the last order +//! it saw back inside its error when it is interrupted, so the caller can say +//! how to resume. mod money; @@ -26,7 +27,9 @@ use icp_canister_interfaces::cycles_gateway::{ ExpiredBy, GET_ORDER_METHOD, LIST_ORDERS_METHOD, ListOrdersResult, Order, OrderStatus, QUOTE_PREVIEWS_METHOD, QuotePreviews, Reason, }; +use icp_events::TaskOutcome; use icp_project::calls::{CanisterCalls, RouteTo, TypedCallError, query_typed, update_typed}; +use icp_project::operations::task::{PurchasePhase, Reporter, Task, TaskReporter}; use num_traits::ToPrimitive; use snafu::{OptionExt, Snafu}; use tokio::{select, time::sleep}; @@ -178,9 +181,28 @@ pub struct Quote { pub minimum_cycles: u128, } +impl Quote { + /// Whether a fresh quote for the same target is still within + /// [`QUOTE_SLIPPAGE_PERCENT`] of this one: at least the cycles this one + /// would accept, for no more than this one's amount plus the slippage. + /// A buyer who was shown this quote and is not asked again can be held + /// to that much, and no more. + pub fn tolerates(&self, fresh: &Quote) -> bool { + fresh.cycles >= self.minimum_cycles + && fresh.amount.currency == self.amount.currency + && fresh.amount.minor_units <= with_slippage_up(self.amount.minor_units) + } +} + /// The fewest cycles a buyer who is spending a sum will accept for it. +/// Divides first: the figure is the gateway's, and may be any size. fn with_slippage(cycles: u128) -> u128 { - cycles * (100 - QUOTE_SLIPPAGE_PERCENT) / 100 + cycles / 100 * (100 - QUOTE_SLIPPAGE_PERCENT) +} + +/// The most a buyer who asked for a number of cycles will pay for them. +fn with_slippage_up(minor_units: u64) -> u64 { + minor_units.saturating_add((minor_units / 100).saturating_mul(QUOTE_SLIPPAGE_PERCENT as u64)) } /// Price a [`Target`]. @@ -617,13 +639,51 @@ impl Default for WaitOptions { } } +/// The legs of a wait, reported as one [`Task`] each so a consumer can tick +/// off "payment received" before "delivered". +struct Phases<'a> { + reporter: &'a Reporter, + id: &'a str, + current: Option<(PurchasePhase, TaskReporter)>, +} + +impl Phases<'_> { + fn of(status: OrderStatus) -> PurchasePhase { + match status { + OrderStatus::Paid => PurchasePhase::Delivering, + _ => PurchasePhase::AwaitingPayment, + } + } + + /// Move to the phase `status` is in, finishing the one before it. + fn enter(&mut self, status: OrderStatus) { + let phase = Self::of(status); + if self.current.as_ref().is_some_and(|(p, _)| *p == phase) { + return; + } + self.finish(TaskOutcome::succeeded()); + let task = self.reporter.task(Task::cycles_purchase(self.id, phase)); + self.current = Some((phase, task)); + } + + fn finish(&mut self, outcome: TaskOutcome) { + if let Some((_, task)) = self.current.take() { + task.finish(outcome); + } + } +} + /// Poll the gateway until the order reaches a terminal status. /// -/// `initial` is the order as last seen, if the caller has it: it seeds the -/// change detection so `on_change` fires only on a transition, and it is what +/// `initial` is the order as last seen, if the caller has it: it names the +/// phase the wait starts in, and it is what /// [`CyclesPurchaseError::Interrupted`] carries if `interrupt` resolves before -/// the first poll answers. `on_change` runs for every status change, including -/// the terminal one. +/// the first poll answers. Each [`PurchasePhase`] the order passes through is +/// reported as a task on `reporter`, finished when the next begins or the +/// wait ends. +/// +/// `interrupt` is raced against every poll as well as the sleep between +/// them, so an interrupt lands even while a call is stalled. /// /// A poll the network never answered is retried; any other failure returns at /// once. A terminal status is a success here whatever it is — expired is an @@ -635,24 +695,56 @@ pub async fn wait_for_order( initial: Option, options: &WaitOptions, interrupt: impl Future, - on_change: &mut dyn FnMut(&OrderView), + reporter: &Reporter, ) -> Result { let mut interrupt = pin!(interrupt); + let mut phases = Phases { + reporter, + id, + current: None, + }; + phases.enter(initial.as_ref().map_or(OrderStatus::Created, |o| o.status)); let mut last = initial; let mut transient_streak = 0u32; let started = Instant::now(); let mut paid_since: Option = None; + let interrupted = |last: Option| { + let order = last.unwrap_or_else(|| OrderView { + id: id.to_owned(), + status: OrderStatus::Created, + locked_cycles: 0, + checkout_url: None, + expires_at_ns: None, + paid: None, + expired_by: None, + abandoned_reason: None, + }); + InterruptedSnafu { + order: Box::new(order), + } + .fail() + }; + loop { - match get_order(calls, gateway, id).await { + let polled = select! { + polled = get_order(calls, gateway, id) => polled, + _ = &mut interrupt => { + phases.finish(TaskOutcome::failed("interrupted")); + return interrupted(last); + } + }; + match polled { Ok(order) => { transient_streak = 0; - if last.as_ref().map(|l| l.status) != Some(order.status) { - on_change(&order); - } if order.status.is_terminal() { + phases.finish(match order.status { + OrderStatus::Delivered => TaskOutcome::succeeded(), + status => TaskOutcome::failed(format!("order {status}")), + }); return Ok(order); } + phases.enter(order.status); if order.status == OrderStatus::Paid && paid_since.is_none() { paid_since = Some(Instant::now()); } @@ -666,6 +758,7 @@ pub async fn wait_for_order( (_, None) => started.elapsed() > options.max_without_expiry, }; if overdue { + phases.finish(TaskOutcome::failed("gave up waiting")); return WaitTimedOutSnafu { order: Box::new(order), } @@ -676,6 +769,7 @@ pub async fn wait_for_order( Err(CyclesPurchaseError::GetOrder { source, .. }) if is_transient(&source) => { transient_streak += 1; if transient_streak > options.max_consecutive_transient { + phases.finish(TaskOutcome::failed("lost contact with the gateway")); return Err(CyclesPurchaseError::WaitUnanswered { gateway, id: id.to_owned(), @@ -683,23 +777,17 @@ pub async fn wait_for_order( }); } } - Err(error) => return Err(error), + Err(error) => { + phases.finish(TaskOutcome::failed(error.to_string())); + return Err(error); + } } select! { _ = sleep(options.poll_interval) => {} _ = &mut interrupt => { - let order = last.unwrap_or_else(|| OrderView { - id: id.to_owned(), - status: OrderStatus::Created, - locked_cycles: 0, - checkout_url: None, - expires_at_ns: None, - paid: None, - expired_by: None, - abandoned_reason: None, - }); - return InterruptedSnafu { order: Box::new(order) }.fail(); + phases.finish(TaskOutcome::failed("interrupted")); + return interrupted(last); } } } diff --git a/crates/icp-app/src/operations/cycles_purchase/tests.rs b/crates/icp-app/src/operations/cycles_purchase/tests.rs index 9258c60cf..2cebbdaee 100644 --- a/crates/icp-app/src/operations/cycles_purchase/tests.rs +++ b/crates/icp-app/src/operations/cycles_purchase/tests.rs @@ -11,7 +11,9 @@ use icp_canister_interfaces::cycles_gateway::{ LIST_ORDERS_METHOD, ListOrdersResult, Order, OrderStatus, QUOTE_PREVIEWS_METHOD, QuotePreview, QuotePreviews, Reason, }; +use icp_events::EventKind; use icp_project::calls::{Authority, Call, CallError, CanisterCalls}; +use icp_project::operations::task::{PurchasePhase, Reporter}; use super::*; @@ -41,10 +43,6 @@ fn order(id: &str, status: OrderStatus) -> Order { paid_usd_cents: None, expired_by: None, abandoned_reason: None, - destination: Destination::CyclesLedgerAccount(Account { - owner: principal(BUYER), - subaccount: None, - }), } } @@ -123,6 +121,8 @@ struct FakeGateway { calls_seen: Mutex)>>, /// Rejects every call as if the canister did not exist. absent: bool, + /// Never answers `get_order`, as a gateway behind a stalled connection. + stall_polls: bool, } impl FakeGateway { @@ -226,6 +226,9 @@ impl CanisterCalls for FakeGateway { Ok(Encode!(&QuotePreviews { quotes }).unwrap()) } GET_ORDER_METHOD => { + if self.stall_polls { + pending::<()>().await; + } let reply = self .get_order .lock() @@ -686,12 +689,16 @@ async fn cancel_order_maps_refusal() { // --- wait_for_order -------------------------------------------------------- +/// A reported phase and how it ended: `Err` carries the failure message. +type Phase = (PurchasePhase, Result<(), String>); + +/// Run the wait and collect the phases it reported, each with its outcome. async fn wait( fake: &FakeGateway, initial: Option, options: &WaitOptions, -) -> (Result, Vec) { - let mut seen = Vec::new(); +) -> (Result, Vec) { + let (reporter, mut events) = icp_events::channel(); let result = wait_for_order( fake, gateway(), @@ -699,10 +706,43 @@ async fn wait( initial, options, pending(), - &mut |o| seen.push(o.status), + &reporter, ) .await; - (result, seen) + drop(reporter); + let mut phases = Vec::new(); + while let Some(event) = events.recv().await { + match event.kind { + EventKind::TaskStarted { + task: Task::CyclesPurchase(task), + .. + } => phases.push((task.phase, None)), + EventKind::TaskCompleted { outcome } => { + let (_, slot) = phases.last_mut().expect("completed before started"); + *slot = Some(match outcome { + TaskOutcome::Succeeded { .. } => Ok(()), + TaskOutcome::Failed { message, .. } => Err(message), + TaskOutcome::Skipped { reason } => Err(reason), + }); + } + other => panic!("unexpected event {other:?}"), + } + } + ( + result, + phases + .into_iter() + .map(|(phase, outcome)| (phase, outcome.expect("phase never finished"))) + .collect(), + ) +} + +fn succeeded(phase: PurchasePhase) -> Phase { + (phase, Ok(())) +} + +fn failed(phase: PurchasePhase, message: &str) -> Phase { + (phase, Err(message.to_owned())) } #[tokio::test] @@ -713,29 +753,29 @@ async fn wait_happy_path_created_paid_delivered() { Ok(Some(order("o1", OrderStatus::Paid))), Ok(Some(order("o1", OrderStatus::Delivered))), ]); - let (result, seen) = wait(&fake, None, &fast()).await; + let (result, phases) = wait(&fake, None, &fast()).await; assert_eq!(result.unwrap().status, OrderStatus::Delivered); assert_eq!( - seen, + phases, vec![ - OrderStatus::Created, - OrderStatus::Paid, - OrderStatus::Delivered + succeeded(PurchasePhase::AwaitingPayment), + succeeded(PurchasePhase::Delivering), ] ); assert_eq!(fake.count_of(GET_ORDER_METHOD), 4); } #[tokio::test] -async fn wait_does_not_report_the_initial_status_again() { +async fn wait_starts_in_the_initial_orders_phase() { let fake = FakeGateway::default().polls(vec![ - Ok(Some(order("o1", OrderStatus::Created))), + Ok(Some(order("o1", OrderStatus::Paid))), Ok(Some(order("o1", OrderStatus::Delivered))), ]); - let initial: OrderView = order("o1", OrderStatus::Created).try_into().unwrap(); - let (result, seen) = wait(&fake, Some(initial), &fast()).await; + let initial: OrderView = order("o1", OrderStatus::Paid).try_into().unwrap(); + let (result, phases) = wait(&fake, Some(initial), &fast()).await; assert_eq!(result.unwrap().status, OrderStatus::Delivered); - assert_eq!(seen, vec![OrderStatus::Delivered]); + // Already paid: no "awaiting payment" leg is reported. + assert_eq!(phases, vec![succeeded(PurchasePhase::Delivering)]); } #[tokio::test] @@ -747,9 +787,16 @@ async fn wait_returns_every_terminal_status_as_ok() { OrderStatus::NeedsReview, ] { let fake = FakeGateway::default().polls(vec![Ok(Some(order("o1", status)))]); - let (result, seen) = wait(&fake, None, &fast()).await; + let (result, phases) = wait(&fake, None, &fast()).await; assert_eq!(result.unwrap().status, status); - assert_eq!(seen, vec![status]); + // An answer, but not the one hoped for: the leg is shown as failed. + assert_eq!( + phases, + vec![failed( + PurchasePhase::AwaitingPayment, + &format!("order {status}") + )] + ); } } @@ -819,15 +866,18 @@ async fn wait_times_out_after_expiry_grace() { expiry_grace: Duration::ZERO, ..fast() }; - let (result, seen) = wait(&fake, None, &options).await; + let (result, phases) = wait(&fake, None, &options).await; match result.unwrap_err() { CyclesPurchaseError::WaitTimedOut { order } => { assert_eq!(order.status, OrderStatus::Created); } other => panic!("unexpected {other:?}"), } - // The status was still reported before giving up. - assert_eq!(seen, vec![OrderStatus::Created]); + // The leg was closed out before giving up. + assert_eq!( + phases, + vec![failed(PurchasePhase::AwaitingPayment, "gave up waiting")] + ); } #[tokio::test] @@ -865,15 +915,18 @@ async fn wait_times_out_on_a_stuck_paid_order() { #[tokio::test] async fn wait_interrupted_returns_last_order() { + // The interrupt is ready from the start, so whether it or the first poll + // wins the race, the order handed back is the last one known. let fake = FakeGateway::default().polls(vec![Ok(Some(order("o1", OrderStatus::Paid)))]); + let initial: OrderView = order("o1", OrderStatus::Paid).try_into().unwrap(); let result = wait_for_order( &fake, gateway(), "o1", - None, + Some(initial), &WaitOptions::default(), ready(()), - &mut |_| {}, + &Reporter::null(), ) .await; match result.unwrap_err() { @@ -884,3 +937,69 @@ async fn wait_interrupted_returns_last_order() { other => panic!("unexpected {other:?}"), } } + +#[tokio::test] +async fn wait_interrupt_lands_during_a_stalled_poll() { + // The gateway never answers; the interrupt must still get through, with + // the phase closed out so a renderer does not spin on. + let fake = FakeGateway { + stall_polls: true, + ..FakeGateway::default() + }; + let (reporter, mut events) = icp_events::channel(); + let result = wait_for_order( + &fake, + gateway(), + "o1", + None, + &WaitOptions::default(), + ready(()), + &reporter, + ) + .await; + drop(reporter); + assert!(matches!( + result.unwrap_err(), + CyclesPurchaseError::Interrupted { .. } + )); + let mut completed = 0; + while let Some(event) = events.recv().await { + if let EventKind::TaskCompleted { + outcome: TaskOutcome::Failed { message, .. }, + } = event.kind + { + assert_eq!(message, "interrupted"); + completed += 1; + } + } + assert_eq!(completed, 1); +} + +#[test] +fn quote_tolerates_only_small_moves() { + let shown = Quote { + amount: usd(1000), + fee: usd(59), + cycles: 7_238_000_000_000, + minimum_cycles: 6_876_100_000_000, + }; + // Same price, slightly fewer cycles but above the floor: fine. + assert!(shown.tolerates(&Quote { + cycles: 7_000_000_000_000, + ..shown.clone() + })); + // Below the floor: the buyer was promised more. + assert!(!shown.tolerates(&Quote { + cycles: 6_000_000_000_000, + ..shown.clone() + })); + // Up to 5 % dearer for the same cycles: fine; beyond it: not. + assert!(shown.tolerates(&Quote { + amount: usd(1050), + ..shown.clone() + })); + assert!(!shown.tolerates(&Quote { + amount: usd(1051), + ..shown.clone() + })); +} diff --git a/crates/icp-app/src/operations/token/mod.rs b/crates/icp-app/src/operations/token/mod.rs index 4c31e60fb..191b117de 100644 --- a/crates/icp-app/src/operations/token/mod.rs +++ b/crates/icp-app/src/operations/token/mod.rs @@ -67,8 +67,8 @@ pub fn format_cycles(cycles: u128) -> String { /// `cycles / unit` to three decimals, trailing zeros and a bare point dropped. fn scaled(cycles: u128, unit: u128) -> String { - let thousandths = cycles * 1000 / unit; - let s = format!("{}.{:03}", thousandths / 1000, thousandths % 1000); + // Split before scaling so the remainder, not the whole, is multiplied. + let s = format!("{}.{:03}", cycles / unit, cycles % unit * 1000 / unit); s.trim_end_matches('0').trim_end_matches('.').to_owned() } @@ -87,5 +87,7 @@ mod tests { assert_eq!(format_cycles(999_999_999), "999_999_999 cycles"); assert_eq!(format_cycles(42), "42 cycles"); assert_eq!(format_cycles(0), "0 cycles"); + // A gateway-supplied figure may be anything; no size may panic. + assert!(format_cycles(u128::MAX).ends_with("T cycles")); } } diff --git a/crates/icp-canister-interfaces/src/cycles_gateway.rs b/crates/icp-canister-interfaces/src/cycles_gateway.rs index c2222015f..bf1455925 100644 --- a/crates/icp-canister-interfaces/src/cycles_gateway.rs +++ b/crates/icp-canister-interfaces/src/cycles_gateway.rs @@ -204,7 +204,8 @@ pub struct Order { pub expired_by: Option, #[serde(rename = "abandonedReason")] pub abandoned_reason: Option, - pub destination: Destination, + // `destination` is deliberately not read back: it is a variant, and a + // case this CLI does not know would stop every order decoding. } /// The price of one amount, as [`QUOTE_PREVIEWS_METHOD`] reports it. @@ -563,13 +564,7 @@ mod tests { order.stripe_session_url.as_deref(), Some("https://checkout.example/pay") ); - assert_eq!( - order.destination, - Destination::CyclesLedgerAccount(Account { - owner, - subaccount: None - }) - ); + assert_eq!(order.paid_usd_cents, Some(Nat::from(1000u32))); } #[test] diff --git a/crates/icp-cli/src/commands/cycles/buy.rs b/crates/icp-cli/src/commands/cycles/buy.rs index db520e501..514d8d04d 100644 --- a/crates/icp-cli/src/commands/cycles/buy.rs +++ b/crates/icp-cli/src/commands/cycles/buy.rs @@ -8,7 +8,8 @@ use icp_app::context::Context; use icp_app::identity::IdentitySelection; use icp_app::identity::manifest::IdentityDefaults; use icp_app::operations::cycles_purchase::{ - self as purchase, Currency, CyclesPurchaseError, Money, OrderView, Quote, Target, WaitOptions, + self as purchase, Currency, CyclesPurchaseError, Money, OrderView, QUOTE_SLIPPAGE_PERCENT, + Quote, Target, WaitOptions, }; use icp_app::operations::token::balance::get_raw_balance; use icp_app::operations::token::format_cycles; @@ -17,6 +18,7 @@ use icp_canister_interfaces::cycles_gateway::{ }; use icp_canister_interfaces::cycles_ledger::CYCLES_LEDGER_PRINCIPAL; use icp_project::calls::CanisterCalls; +use icp_project::operations::task::Reporter; use icp_project::parsers::CyclesAmount; use icp_project::signal::stop_signal; use num_traits::ToPrimitive; @@ -25,7 +27,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::{info, warn}; use crate::commands::args::TokenCommandArgs; -use crate::render::{ProgressManager, ProgressManagerSettings}; +use crate::render::rendered; /// Buy cycles with a card. /// @@ -161,42 +163,47 @@ pub(crate) async fn exec(ctx: &Context, args: &BuyArgs) -> Result<(), anyhow::Er if let Some(id) = &args.cancel { let order = purchase::cancel_order(calls, gateway, id).await?; - match output { - Output::Json => print_json(&JsonOrderFinal { - order_id: &order.id, - status: order.status.tag(), - cycles_delivered: None, - balance: None, - })?, - Output::Quiet => println!("{}", order.status.tag()), - Output::Human => println!("Order {} {}", order.id, order.status), - } + print_final(&order, None, output)?; return Ok(()); } - let order = match &args.resume { + // Whether the checkout URL was handed out: an order that is payable right + // now. Anything else has nothing to pay, and `--no-wait` must still say + // where it stands. + let (order, payable) = match &args.resume { Some(id) => { let order = purchase::get_order(calls, gateway, id).await?; - if order.status == OrderStatus::Created { - announce_payable(&order, output, args.no_open); + let payable = order.status == OrderStatus::Created && !past_deadline(&order); + if payable { + announce_payable(&order, None, output, args.no_open); } else if output == Output::Human { - info!("Order {} is {}", order.id, order.status); + match order.status { + OrderStatus::Created => info!( + "Order {} passed its payment deadline at {}; the gateway has not expired it yet.", + order.id, + order.expires_at_ns.map(format_ns).unwrap_or_default() + ), + status => info!("Order {} is {status}", order.id), + } } - order + (order, payable) } None => { let target = target .expect("clap requires --amount or --cycles unless --resume or --cancel is given"); let identity = identity_name(ctx, &selections.identity).await; - let order = + let (order, quote) = create_order(calls, gateway, buyer, identity.as_deref(), target, args).await?; - announce_payable(&order, output, args.no_open); - order + announce_payable(&order, Some("e), output, args.no_open); + (order, true) } }; if args.no_wait { - if output == Output::Human { + if !payable { + print_final(&order, None, output)?; + } + if output == Output::Human && !order.status.is_terminal() { info!( "Not waiting. Run `icp cycles buy --resume {}` to wait for delivery.", order.id @@ -205,11 +212,26 @@ pub(crate) async fn exec(ctx: &Context, args: &BuyArgs) -> Result<(), anyhow::Er return Ok(()); } - wait_and_report(calls, &agent, gateway, buyer, order, output).await + wait_and_report(calls, &agent, gateway, buyer, order, output, ctx.debug).await +} + +/// Whether an order's payment deadline has passed on this machine's clock. +/// The gateway expires such an order on its own timer, so it may still read +/// as payable for a while. +fn past_deadline(order: &OrderView) -> bool { + let now_ns: i64 = OffsetDateTime::now_utc() + .unix_timestamp_nanos() + .try_into() + .unwrap_or(i64::MAX); + order + .expires_at_ns + .is_some_and(|deadline| deadline < now_ns) } -/// Quote, confirm, create. Re-quotes and re-confirms once if the rate moved -/// against the buyer in between. +/// Quote, confirm, create. If the rate moves against the buyer in between, +/// re-quotes once: a person is asked again, while `--yes` stands only for +/// the quote it was given, so the new one must be within tolerance of it or +/// the command stops. Returns the order with the quote it was placed on. async fn create_order( calls: &dyn CanisterCalls, gateway: Principal, @@ -217,27 +239,41 @@ async fn create_order( identity: Option<&str>, target: Target, args: &BuyArgs, -) -> Result { +) -> Result<(OrderView, Quote), anyhow::Error> { let mut quote = purchase::quote_target(calls, gateway, target).await?; confirm("e, target, gateway, buyer, identity, args.yes)?; + let mut requoted = false; - for attempt in 0..2 { + loop { match purchase::place_order(calls, gateway, "e).await { - Ok(order) => return Ok(order), + Ok(order) => return Ok((order, quote)), Err(CyclesPurchaseError::OrderRefused { error: CreateOrderError::QuoteChanged { .. }, - }) if attempt == 0 => { - quote = purchase::quote_target(calls, gateway, target).await?; + }) if !requoted => { + requoted = true; + let fresh = purchase::quote_target(calls, gateway, target).await?; if args.yes { + if !quote.tolerates(&fresh) { + bail!( + "the exchange rate moved more than {QUOTE_SLIPPAGE_PERCENT}% since the \ + quote ({} is now {}); no order was created. Run the command again to \ + be quoted at the new rate", + quote_figures("e), + quote_figures(&fresh) + ); + } info!( - "The exchange rate moved. New quote: {}. Retrying once.", - quote_line("e) + "The exchange rate moved within tolerance. New quote: {}. Retrying once.", + quote_line(&fresh) ); } else { warn!("The exchange rate moved since the quote."); - confirm("e, target, gateway, buyer, identity, false)?; + confirm(&fresh, target, gateway, buyer, identity, false)?; } + quote = fresh; } + // A second `QuoteChanged` lands here and reads as "moving too + // quickly", which is what it is. Err(CyclesPurchaseError::OrderRefused { error }) => { bail!("{}", refusal_message(&error, buyer)) } @@ -247,7 +283,6 @@ async fn create_order( Err(error) => return Err(error.into()), } } - bail!("the exchange rate is moving too quickly to lock a quote; try again in a minute") } /// Show what is about to happen and ask. Without a terminal there is nobody @@ -292,17 +327,21 @@ fn confirm( fn quote_line(quote: &Quote) -> String { format!( - "{} -> {} (includes a {} card fee; rate locked at order creation)", - quote.amount, - format_cycles(quote.cycles), + "{} (includes a {} card fee; rate locked at order creation)", + quote_figures(quote), quote.fee ) } +/// Just the exchange: `10.00 USD -> 7.238T cycles`. +fn quote_figures(quote: &Quote) -> String { + format!("{} -> {}", quote.amount, format_cycles(quote.cycles)) +} + /// Print the checkout URL, and open it where that makes sense: a person at a /// terminal, who did not ask otherwise. `--json` and `--quiet` are for /// scripts, which have no browser to open. -fn announce_payable(order: &OrderView, output: Output, no_open: bool) { +fn announce_payable(order: &OrderView, quote: Option<&Quote>, output: Output, no_open: bool) { let Some(url) = order.checkout_url.as_deref() else { warn!( "Order {} is {} but the gateway reported no checkout URL", @@ -315,6 +354,8 @@ fn announce_payable(order: &OrderView, output: Output, no_open: bool) { if let Err(e) = print_json(&JsonOrderCreated { order_id: &order.id, checkout_url: url, + amount: quote.map(|q| q.amount), + fee: quote.map(|q| q.fee), locked_cycles: order.locked_cycles.to_string(), expires_at: order.expires_at_ns.map(format_ns), }) { @@ -353,25 +394,27 @@ async fn wait_and_report( buyer: Principal, order: OrderView, output: Output, + debug: bool, ) -> Result<(), anyhow::Error> { - let progress = ProgressManager::new(ProgressManagerSettings { - hidden: output != Output::Human, - }); - let spinner = progress.create_independent_progress_bar(); - spinner.set_message(waiting_message(order.status)); - let id = order.id.clone(); - let result = purchase::wait_for_order( - calls, - gateway, - &id, - Some(order), - &WaitOptions::default(), - stop_signal(), - &mut |order| spinner.set_message(waiting_message(order.status)), - ) - .await; - spinner.finish_and_clear(); + let options = WaitOptions::default(); + let wait = async |reporter: &Reporter| { + purchase::wait_for_order( + calls, + gateway, + &id, + Some(order), + &options, + stop_signal(), + reporter, + ) + .await + }; + // The live view is for a person; a script gets its answer on stdout. + let result = match output { + Output::Human => rendered(debug, wait).await, + Output::Quiet | Output::Json => wait(&Reporter::null()).await, + }; let order = match result { Ok(order) => order, @@ -389,7 +432,16 @@ async fn wait_and_report( } Err(CyclesPurchaseError::WaitTimedOut { order }) => { if output == Output::Human { - info!("Keep waiting: icp cycles buy --resume {}", order.id); + if order.status == OrderStatus::Created && past_deadline(&order) { + info!( + "Order {} passed its payment deadline and the gateway has not expired it yet.", + order.id + ); + info!("Check later: icp cycles buy --resume {}", order.id); + info!("Cancel it: icp cycles buy --cancel {}", order.id); + } else { + info!("Keep waiting: icp cycles buy --resume {}", order.id); + } } return Err(CyclesPurchaseError::WaitTimedOut { order }.into()); } @@ -408,17 +460,7 @@ async fn wait_and_report( None }; - match output { - Output::Json => print_json(&JsonOrderFinal { - order_id: &order.id, - status: order.status.tag(), - cycles_delivered: (order.status == OrderStatus::Delivered) - .then(|| order.locked_cycles.to_string()), - balance: balance.map(|b| b.to_string()), - })?, - Output::Quiet => println!("{}", order.status.tag()), - Output::Human => {} - } + print_final(&order, balance, output)?; match order.status { OrderStatus::Delivered => { @@ -475,10 +517,26 @@ fn render_qr(url: &str) -> Option { ) } -fn waiting_message(status: OrderStatus) -> String { - match status { - OrderStatus::Paid => "Payment received, delivering cycles...".to_owned(), - _ => "Waiting for payment...".to_owned(), +/// Where the order stands, for a script: one JSON object, or the status tag +/// alone. A person is told in prose by the caller. +fn print_final( + order: &OrderView, + balance: Option, + output: Output, +) -> Result<(), anyhow::Error> { + match output { + Output::Json => print_json(&JsonOrderFinal { + order_id: &order.id, + status: order.status.tag(), + cycles_delivered: (order.status == OrderStatus::Delivered) + .then(|| order.locked_cycles.to_string()), + balance: balance.map(|b| b.to_string()), + }), + Output::Quiet => { + println!("{}", order.status.tag()); + Ok(()) + } + Output::Human => Ok(()), } } @@ -608,6 +666,11 @@ fn print_json(value: &T) -> Result<(), anyhow::Error> { struct JsonOrderCreated<'a> { order_id: &'a str, checkout_url: &'a str, + /// What the card will be charged, as quoted. Known only when the order + /// was created by this run; a resumed order's quote is not kept. + amount: Option, + /// The card fee included in `amount`, likewise. + fee: Option, /// Exact cycles promised, as a decimal string. locked_cycles: String, /// RFC 3339. @@ -728,6 +791,23 @@ mod tests { assert!(render_qr(&"x".repeat(10_000)).is_none()); } + #[test] + fn past_deadline_reads_the_orders_expiry() { + let order = |expires_at_ns| OrderView { + id: "o1".into(), + status: OrderStatus::Created, + locked_cycles: 1, + checkout_url: None, + expires_at_ns, + paid: None, + expired_by: None, + abandoned_reason: None, + }; + assert!(past_deadline(&order(Some(1_700_000_000_000_000_000)))); + assert!(!past_deadline(&order(Some(i64::MAX)))); + assert!(!past_deadline(&order(None))); + } + #[test] fn format_ns_is_rfc3339() { assert_eq!(format_ns(1_700_000_000_000_000_000), "2023-11-14T22:13:20Z"); diff --git a/crates/icp-project/src/calls.rs b/crates/icp-project/src/calls.rs index 7537565c6..9bd7d38f1 100644 --- a/crates/icp-project/src/calls.rs +++ b/crates/icp-project/src/calls.rs @@ -242,8 +242,8 @@ impl CallError { } /// The replica's error code for a call addressed to a canister that does not -/// exist. -const CANISTER_NOT_FOUND: &str = "IC0301"; +/// exist, which [`CallError::is_canister_not_found`] recognizes. +pub const CANISTER_NOT_FOUND: &str = "IC0301"; /// How this crate reaches canisters. /// diff --git a/crates/icp-project/src/operations/deploy.rs b/crates/icp-project/src/operations/deploy.rs index 0c52ae9e6..3e1fc498c 100644 --- a/crates/icp-project/src/operations/deploy.rs +++ b/crates/icp-project/src/operations/deploy.rs @@ -801,11 +801,13 @@ async fn wait_until_serving_queries( /// A rejection means the replica processed the request to a verdict (e.g. "no /// such query method"), so the canister is up — unless the rejection says it is /// stopped/stopping (IC0508/IC0509, with a message-substring fallback), which is -/// a replica still lagging behind the restart. A call that reached no verdict at -/// all is inconclusive — not evidence the canister is serving — and returns -/// false so the caller retries rather than proceeding. +/// a replica still lagging behind the restart, or that it does not exist at +/// all (IC0301), which right after creation is routing that has not caught up +/// yet. A call that reached no verdict at all is inconclusive — not evidence +/// the canister is serving — and returns false so the caller retries rather +/// than proceeding. fn is_serving_reject(err: &CallError) -> bool { - if !err.is_rejection() { + if !err.is_rejection() || err.is_canister_not_found() { return false; } let message = err.message().unwrap_or_default(); @@ -841,6 +843,18 @@ mod tests { ))); } + #[test] + fn not_found_rejects_are_not_a_readiness_signal() { + // Routing that has not caught up with the creation yet — whether a + // replica says so, or an HTTP gateway did and the call layer reported + // it as the same verdict. + assert!(!is_serving_reject(&reject( + Some("IC0301"), + "Canister abc not found" + ))); + assert!(!is_serving_reject(&reject(None, "Canister abc not found"))); + } + #[test] fn any_other_reject_means_the_canister_is_serving() { // The replica got far enough to answer for a non-status reason. diff --git a/crates/icp-project/src/operations/task.rs b/crates/icp-project/src/operations/task.rs index d41021015..07ff2f0bd 100644 --- a/crates/icp-project/src/operations/task.rs +++ b/crates/icp-project/src/operations/task.rs @@ -483,6 +483,59 @@ impl Presentation for SnapshotTransferTask { } } +/// Which leg of a card purchase of cycles is in progress. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PurchasePhase { + /// The order is payable and the buyer has not paid yet. + AwaitingPayment, + /// The card was charged; the gateway is sending the cycles. + Delivering, +} + +/// One leg of waiting on a cycles-gateway order. Each phase is its own task +/// so the live view can tick off "payment received" before "delivered". +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename = "cycles_purchase")] +pub struct CyclesPurchaseTask { + pub order_id: String, + pub phase: PurchasePhase, +} + +impl Presentation for CyclesPurchaseTask { + /// An order is not about a canister; the cycles go to an identity. + fn canister(&self) -> Option<&str> { + None + } + + fn widget(&self) -> Widget { + Widget::Indeterminate { + label: format!("order {}", self.order_id), + } + } + + fn running_message(&self) -> Option<&'static str> { + Some(match self.phase { + PurchasePhase::AwaitingPayment => "Waiting for payment...", + PurchasePhase::Delivering => "Payment received; delivering cycles...", + }) + } + + fn success_message(&self) -> Option { + Some( + match self.phase { + PurchasePhase::AwaitingPayment => "Payment received", + PurchasePhase::Delivering => "Cycles delivered", + } + .to_owned(), + ) + } + + fn failure_message(&self, message: &str) -> Option { + Some(message.to_owned()) + } +} + // --------------------------------------------------------------------------- // Envelope // --------------------------------------------------------------------------- @@ -504,6 +557,7 @@ pub enum Task { UpdateEnvironmentVariables(UpdateEnvironmentVariablesTask), CandidCheck(CandidCheckTask), SnapshotTransfer(SnapshotTransferTask), + CyclesPurchase(CyclesPurchaseTask), } impl Task { @@ -520,6 +574,7 @@ impl Task { Task::UpdateEnvironmentVariables(task) => task, Task::CandidCheck(task) => task, Task::SnapshotTransfer(task) => task, + Task::CyclesPurchase(task) => task, } } @@ -594,10 +649,27 @@ impl Task { } } +impl Task { + pub fn cycles_purchase(order_id: impl Into, phase: PurchasePhase) -> Self { + Task::CyclesPurchase(CyclesPurchaseTask { + order_id: order_id.into(), + phase, + }) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn cycles_purchase_wire_format_is_stable() { + assert_eq!( + serde_json::to_value(Task::cycles_purchase("o1", PurchasePhase::Delivering)).unwrap(), + serde_json::json!({ "kind": "cycles_purchase", "order_id": "o1", "phase": "delivering" }) + ); + } + fn cid() -> Principal { Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap() } diff --git a/docs/guides/tokens-and-cycles.md b/docs/guides/tokens-and-cycles.md index bcac86d9b..d1911f167 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -144,7 +144,7 @@ Useful flags: - `--no-open` prints the URL without opening a browser, for SSH sessions and containers. You can pay from any device. - `--no-wait` returns right after printing the URL. `--resume ` waits on an order later, and prints its URL again while it is still payable. - `--cancel ` cancels an open order. An unpaid order also expires on its own at the deadline the command prints. -- `--yes` skips the confirmation, which is required when there is no terminal to ask. `--json` prints one object after the order is created and one when it settles; `-q` prints only the URL, then the final status. +- `--yes` skips the confirmation, which is required when there is no terminal to ask. It stands for the quote it was shown: if the rate moves more than 5% between the quote and the order, the command stops without creating one, and you run it again to be quoted afresh. `--json` prints one object after the order is created (with the quoted amount and fee) and one when it settles; `-q` prints only the URL, then the final status. - `--gateway ` buys through another gateway canister. The `ICP_CYCLES_GATEWAY_CANISTER_ID` environment variable does the same. Pressing Ctrl-C while waiting leaves the order payable; the command prints how to resume or cancel it. If the order ends up needing review, your card was charged and the gateway operator resolves delivery; keep the order id. From 3b306873ce9a80c21cf3768b14d22ed60158a009 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Fri, 9 Oct 2026 20:58:15 +0000 Subject: [PATCH 8/8] feat(cycles buy): ask the gateway to price `--cycles` directly The cycles gateway now answers `quote_for_cycles` (raymondk/cyclepay#12, shipped in raymondk/cyclepay#14 and live on mainnet): for each target the least USD amount whose quote delivers at least that many cycles, the fee split, and what the amount actually buys, inverted from its own pricing rather than searched. The operation's two-anchor estimate-and- check loop over `quote_previews` is gone; `quote_for_cycles` is one query. The outcome variant maps onto errors the command already shows: `stale` is `RateUnavailable`, `unpriceable` carries the gateway's reason, and the amount bounds become `CyclesBelowMinimum` / `CyclesAboveMaximum`, which name the cost and the bound so the buyer knows which way to move. The fake gateway inverts its own pricing the same way, so the fee-shape sweep still proves the figures handed back are the cheapest cent. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh --- .../src/operations/cycles_purchase/mod.rs | 287 ++++++++---------- .../src/operations/cycles_purchase/tests.rs | 151 ++++++++- .../src/cycles_gateway.rs | 146 +++++++++ docs/guides/tokens-and-cycles.md | 2 +- 4 files changed, 413 insertions(+), 173 deletions(-) diff --git a/crates/icp-app/src/operations/cycles_purchase/mod.rs b/crates/icp-app/src/operations/cycles_purchase/mod.rs index 4c58a00cf..7063e2111 100644 --- a/crates/icp-app/src/operations/cycles_purchase/mod.rs +++ b/crates/icp-app/src/operations/cycles_purchase/mod.rs @@ -23,9 +23,10 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use candid::{Nat, Principal}; use icp_canister_interfaces::cycles_gateway::{ Account, Amount, CANCEL_ORDER_METHOD, CREATE_ORDER_METHOD, CYCLES_GATEWAY_CANISTER_ID_ENV, - CancelOrderError, CancelOrderResult, CreateOrderError, CreateOrderResult, Destination, - ExpiredBy, GET_ORDER_METHOD, LIST_ORDERS_METHOD, ListOrdersResult, Order, OrderStatus, - QUOTE_PREVIEWS_METHOD, QuotePreviews, Reason, + CancelOrderError, CancelOrderResult, CreateOrderError, CreateOrderResult, CyclesQuoteOutcome, + CyclesQuotes, Destination, ExpiredBy, GET_ORDER_METHOD, LIST_ORDERS_METHOD, ListOrdersResult, + Order, OrderStatus, QUOTE_FOR_CYCLES_METHOD, QUOTE_PREVIEWS_METHOD, QuotePreviews, Reason, + Unpriceable, }; use icp_events::TaskOutcome; use icp_project::calls::{CanisterCalls, RouteTo, TypedCallError, query_typed, update_typed}; @@ -69,16 +70,48 @@ pub enum CyclesPurchaseError { QuoteMissing { gateway: Principal, amount: Money }, #[snafu(display( - "cycles gateway {gateway} is not pricing orders right now (its quote carries no \ - cycles figure); try again in a few minutes" + "cycles gateway {gateway} is not pricing orders right now (it has no current \ + exchange rate); try again in a few minutes" ))] RateUnavailable { gateway: Principal }, + #[snafu(display("failed to price {cycles} cycles with cycles gateway {gateway}"))] + QuoteForCycles { + gateway: Principal, + cycles: u128, + #[snafu(source(from(TypedCallError, Box::new)))] + source: Box, + }, + + #[snafu(display("cycles gateway {gateway} returned no quote for {cycles} cycles"))] + CyclesQuoteMissing { gateway: Principal, cycles: u128 }, + + #[snafu(display( + "cycles gateway {gateway} quotes no amount that buys {cycles} cycles: {reason}" + ))] + CyclesUnpriceable { + gateway: Principal, + cycles: u128, + reason: Unpriceable, + }, + #[snafu(display( - "cycles gateway {gateway} quotes no amount that buys {cycles} cycles; \ - try a different amount, or spend a sum with --amount" + "{cycles} cycles cost {amount}, below the gateway minimum of {min}; ask for more" ))] - CyclesUnpriceable { gateway: Principal, cycles: u128 }, + CyclesBelowMinimum { + cycles: u128, + amount: Money, + min: Money, + }, + + #[snafu(display( + "{cycles} cycles cost {amount}, above the gateway maximum of {max}; ask for fewer" + ))] + CyclesAboveMaximum { + cycles: u128, + amount: Money, + max: Money, + }, #[snafu(display("failed to create an order on cycles gateway {gateway}"))] CreateOrder { @@ -279,46 +312,11 @@ pub async fn quote( gateway: Principal, amount: Money, ) -> Result { - let previews = previews(calls, gateway, &[amount]).await?; - let preview = previews - .into_iter() - .next() - .context(QuoteMissingSnafu { gateway, amount })?; - let cycles = preview.cycles.context(RateUnavailableSnafu { gateway })?; - Ok(Quote { - amount, - fee: preview.fee, - cycles, - minimum_cycles: with_slippage(cycles), - }) -} - -/// A gateway quote with its numbers converted, keyed by the amount quoted. -#[derive(Clone, Copy, Debug)] -struct Preview { - amount: Money, - fee: Money, - /// What `amount` buys; `None` when the gateway has no rate, or the fee - /// swallows the whole amount. - cycles: Option, - /// `amount` less `fee`; `None` when the fee swallows it. - net: Option, -} - -/// Quote several amounts in one round trip. -async fn previews( - calls: &dyn CanisterCalls, - gateway: Principal, - amounts: &[Money], -) -> Result, CyclesPurchaseError> { - let minor_units: Vec = amounts - .iter() - .map(|amount| match amount.currency { - Currency::Usd => Nat::from(amount.minor_units), - }) - .collect(); + let minor_units = match amount.currency { + Currency::Usd => Nat::from(amount.minor_units), + }; let (previews,): (QuotePreviews,) = - query_typed(calls, gateway, QUOTE_PREVIEWS_METHOD, (minor_units,)) + query_typed(calls, gateway, QUOTE_PREVIEWS_METHOD, (vec![minor_units],)) .await .map_err(|source| { map_absent(source, gateway, |source| { @@ -328,136 +326,103 @@ async fn previews( } }) })?; - previews + let preview = previews .quotes .into_iter() - .zip(amounts) - .map(|(preview, amount)| { - let currency = amount.currency; - Ok(Preview { - amount: Money::from_nat_minor_units(&preview.usd_cents, currency) - .context(ConvertFieldSnafu { field: "usdCents" })?, - fee: Money::from_nat_minor_units(&preview.fee_cents, currency) - .context(ConvertFieldSnafu { field: "feeCents" })?, - cycles: preview - .cycles - .map(|cycles| { - cycles - .0 - .to_u128() - .context(ConvertFieldSnafu { field: "cycles" }) - }) - .transpose()?, - net: preview - .net_cents - .map(|net| { - Money::from_nat_minor_units(&net, currency) - .context(ConvertFieldSnafu { field: "netCents" }) - }) - .transpose()?, - }) - }) - .collect() + .next() + .context(QuoteMissingSnafu { gateway, amount })?; + let cycles = preview + .cycles + .context(RateUnavailableSnafu { gateway })? + .0 + .to_u128() + .context(ConvertFieldSnafu { field: "cycles" })?; + Ok(Quote { + amount, + fee: Money::from_nat_minor_units(&preview.fee_cents, amount.currency) + .context(ConvertFieldSnafu { field: "feeCents" })?, + cycles, + minimum_cycles: with_slippage(cycles), + }) } -/// Amounts whose quotes anchor the search in [`quote_for_cycles`]: large -/// enough that the fee does not swallow them, far enough apart that the -/// fee's variable part shows between them. -const PROBE_MINOR_UNITS: [u64; 2] = [1_000, 2_000]; - -/// Rounds of estimate-and-check before giving up on a cycles target. -const CYCLES_SEARCH_ROUNDS: usize = 4; - -/// Find the least `currency` whose quote delivers at least `cycles`. +/// Ask the gateway for the least `currency` whose quote delivers at least +/// `cycles`. /// -/// The gateway prices an amount as `(amount - fee) * rate` with a fee of a -/// fixed part plus a share of the amount, but tells only the outcome, so two -/// anchor quotes are read back into that model, the model names the amount -/// that should just clear the target, and that amount and its neighbours -/// are quoted for real; the least that clears wins. If rounding leaves all -/// of them short, the shortfall re-anchors the model and it tries again. +/// The gateway inverts its own pricing, so the amount named is the one +/// `create_order` prices; the order is placed for it with `cycles` as the +/// floor, and a rate move against the buyer is refused rather than +/// delivering less than asked for. pub async fn quote_for_cycles( calls: &dyn CanisterCalls, gateway: Principal, cycles: u128, currency: Currency, ) -> Result { - let unpriceable = || CyclesUnpriceableSnafu { gateway, cycles }; - let probes = PROBE_MINOR_UNITS.map(|units| Money::from_minor_units(units, currency)); - let quoted = previews(calls, gateway, &probes).await?; - let [mut low, mut high]: [Preview; 2] = quoted.try_into().ok().context(QuoteMissingSnafu { + let (quotes,): (CyclesQuotes,) = query_typed( + calls, gateway, - amount: probes[1], + QUOTE_FOR_CYCLES_METHOD, + (vec![Nat::from(cycles)],), + ) + .await + .map_err(|source| { + map_absent(source, gateway, |source| { + CyclesPurchaseError::QuoteForCycles { + gateway, + cycles, + source: Box::new(source), + } + }) })?; - if high.cycles.is_none() { - return RateUnavailableSnafu { gateway }.fail(); - } - - for _ in 0..CYCLES_SEARCH_ROUNDS { - let estimate = estimate_amount(cycles, &low, &high).context(unpriceable())?; - let candidates: Vec = (estimate.saturating_sub(1)..=estimate.saturating_add(2)) - .filter(|units| *units > 0) - .map(|units| Money::from_minor_units(units, currency)) - .collect(); - let quoted = previews(calls, gateway, &candidates).await?; - if let Some(hit) = quoted - .iter() - .filter(|preview| preview.cycles.is_some_and(|got| got >= cycles)) - .min_by_key(|preview| preview.amount.minor_units) - { - return Ok(Quote { - amount: hit.amount, - fee: hit.fee, - cycles: hit.cycles.unwrap_or(cycles), - minimum_cycles: cycles, - }); + let answer = quotes + .quotes + .into_iter() + .next() + .context(CyclesQuoteMissingSnafu { gateway, cycles })?; + let money = |field: &'static str, minor_units: &Nat| { + Money::from_nat_minor_units(minor_units, currency).context(ConvertFieldSnafu { field }) + }; + match answer.outcome { + CyclesQuoteOutcome::Ok { + cycles_quoted, + fee_cents, + usd_cents, + .. + } => Ok(Quote { + amount: money("usdCents", &usd_cents)?, + fee: money("feeCents", &fee_cents)?, + cycles: cycles_quoted.0.to_u128().context(ConvertFieldSnafu { + field: "cyclesQuoted", + })?, + minimum_cycles: cycles, + }), + CyclesQuoteOutcome::AmountBelowMin { + min_usd_cents, + usd_cents, + } => CyclesBelowMinimumSnafu { + cycles, + amount: money("usdCents", &usd_cents)?, + min: money("minUsdCents", &min_usd_cents)?, } - // Every candidate fell short: re-anchor on the two largest real - // quotes and let the model correct itself. - let mut priced: Vec = quoted - .into_iter() - .filter(|preview| preview.cycles.is_some()) - .collect(); - priced.sort_by_key(|preview| preview.amount.minor_units); - match priced.as_slice() { - [.., a, b] => { - low = *a; - high = *b; - } - _ => return unpriceable().fail(), + .fail(), + CyclesQuoteOutcome::AmountAboveMax { + max_usd_cents, + usd_cents, + } => CyclesAboveMaximumSnafu { + cycles, + amount: money("usdCents", &usd_cents)?, + max: money("maxUsdCents", &max_usd_cents)?, } + .fail(), + CyclesQuoteOutcome::Stale => RateUnavailableSnafu { gateway }.fail(), + CyclesQuoteOutcome::Unpriceable(reason) => CyclesUnpriceableSnafu { + gateway, + cycles, + reason, + } + .fail(), } - unpriceable().fail() -} - -/// The amount that should deliver `cycles`, read off two priced quotes: -/// the rate is cycles per net minor unit on `high`, the fee's variable -/// share is the fee difference over the amount difference, and the fixed -/// part is what remains. `None` when the figures do not fit or do not -/// describe a price. -fn estimate_amount(cycles: u128, low: &Preview, high: &Preview) -> Option { - let rate_cycles = high.cycles?; - let rate_net = u128::from(high.net?.minor_units); - if rate_cycles == 0 || rate_net == 0 { - return None; - } - let net_needed = cycles.checked_mul(rate_net)?.div_ceil(rate_cycles).max(1); - - let amount_delta = u128::from(high.amount.minor_units) - .checked_sub(u128::from(low.amount.minor_units)) - .filter(|delta| *delta > 0)?; - let fee_delta = u128::from(high.fee.minor_units) - .saturating_sub(u128::from(low.fee.minor_units)) - .min(amount_delta - 1); - // fixed = fee(low) - share * amount(low), kept over `amount_delta`. - let fixed_scaled = (u128::from(low.fee.minor_units) * amount_delta) - .saturating_sub(fee_delta * u128::from(low.amount.minor_units)); - // amount = (net + fixed) / (1 - share) - let amount = net_needed - .checked_mul(amount_delta)? - .checked_add(fixed_scaled)? - .div_ceil(amount_delta - fee_delta); - u64::try_from(amount).ok() } /// Create an order for `quote`, delivering to the caller's own default diff --git a/crates/icp-app/src/operations/cycles_purchase/tests.rs b/crates/icp-app/src/operations/cycles_purchase/tests.rs index 2cebbdaee..237066d6b 100644 --- a/crates/icp-app/src/operations/cycles_purchase/tests.rs +++ b/crates/icp-app/src/operations/cycles_purchase/tests.rs @@ -7,9 +7,10 @@ use async_trait::async_trait; use candid::{Decode, Encode, Int, Nat, Principal}; use icp_canister_interfaces::cycles_gateway::{ Account, Amount, CANCEL_ORDER_METHOD, CREATE_ORDER_METHOD, CancelOrderError, CancelOrderResult, - CreateOrderError, CreateOrderResult, CreatedOrder, Destination, GET_ORDER_METHOD, - LIST_ORDERS_METHOD, ListOrdersResult, Order, OrderStatus, QUOTE_PREVIEWS_METHOD, QuotePreview, - QuotePreviews, Reason, + CreateOrderError, CreateOrderResult, CreatedOrder, CyclesQuote, CyclesQuoteOutcome, + CyclesQuotes, Destination, GET_ORDER_METHOD, LIST_ORDERS_METHOD, ListOrdersResult, Order, + OrderStatus, QUOTE_FOR_CYCLES_METHOD, QUOTE_PREVIEWS_METHOD, QuotePreview, QuotePreviews, + Reason, Unpriceable, }; use icp_events::EventKind; use icp_project::calls::{Authority, Call, CallError, CanisterCalls}; @@ -99,6 +100,39 @@ impl Pricing { .map(|c| c.0.try_into().unwrap()) .unwrap_or(0) } + + /// The least amount that buys `cycles`, as the gateway inverts its own + /// pricing: an estimate from the fee formula, then walked to the exact + /// cent. `None` when no `u64` amount does. + fn least_cents_for(&self, cycles: u128) -> Option { + let net_needed = u64::try_from(cycles.div_ceil(self.rate).max(1)).ok()?; + let mut cents = net_needed + .checked_add(self.fee_fixed)? + .checked_mul(10_000)? + .div_ceil(10_000 - self.fee_bps); + while cents > 1 && self.cycles_for(cents - 1) >= cycles { + cents -= 1; + } + while self.cycles_for(cents) < cycles { + cents = cents.checked_add(1)?; + } + Some(cents) + } + + fn cycles_quote(&self, cycles: u128) -> CyclesQuoteOutcome { + match self.least_cents_for(cycles) { + Some(cents) => { + let preview = self.preview(cents); + CyclesQuoteOutcome::Ok { + cycles_quoted: preview.cycles.unwrap(), + fee_cents: preview.fee_cents, + net_cents: preview.net_cents.unwrap(), + usd_cents: preview.usd_cents, + } + } + None => CyclesQuoteOutcome::Unpriceable(Unpriceable::StripeFee), + } + } } /// A cycles gateway whose replies are scripted per method. @@ -107,8 +141,12 @@ struct FakeGateway { /// The one preview `quote_previews` answers with, whatever was asked; /// `None` answers with no previews at all. quote: Option, - /// When set, `quote_previews` prices each amount asked for instead. + /// When set, `quote_previews` prices each amount asked for instead, and + /// `quote_for_cycles` inverts the same pricing. pricing: Option, + /// The one outcome `quote_for_cycles` answers with for every target, + /// overriding `pricing`. Without either it answers `stale`. + cycles_quote: Option, /// One reply per `create_order` call, in order. create: Mutex>, /// One reply per `get_order` poll, in order. An `Err` is the network @@ -225,6 +263,22 @@ impl CanisterCalls for FakeGateway { }; Ok(Encode!(&QuotePreviews { quotes }).unwrap()) } + QUOTE_FOR_CYCLES_METHOD => { + let asked = Decode!(&call.arg, Vec).unwrap(); + let quotes = asked + .into_iter() + .map(|cycles| { + let target: u128 = cycles.0.clone().try_into().unwrap(); + let outcome = match (&self.cycles_quote, self.pricing) { + (Some(outcome), _) => outcome.clone(), + (None, Some(pricing)) => pricing.cycles_quote(target), + (None, None) => CyclesQuoteOutcome::Stale, + }; + CyclesQuote { cycles, outcome } + }) + .collect(); + Ok(Encode!(&CyclesQuotes { quotes }).unwrap()) + } GET_ORDER_METHOD => { if self.stall_polls { pending::<()>().await; @@ -368,8 +422,15 @@ fn assert_cheapest(pricing: Pricing, cycles: u128, cents: u64) { ); } +fn answering(outcome: CyclesQuoteOutcome) -> FakeGateway { + FakeGateway { + cycles_quote: Some(outcome), + ..FakeGateway::default() + } +} + #[tokio::test] -async fn quote_for_cycles_finds_the_least_amount_that_clears() { +async fn quote_for_cycles_takes_the_gateways_least_amount() { let fake = priced(STRIPE_LIKE); let target = 5_000_000_000_000; let q = quote_for_cycles(&fake, gateway(), target, Currency::Usd) @@ -381,10 +442,16 @@ async fn quote_for_cycles_finds_the_least_amount_that_clears() { assert_eq!(q.cycles, STRIPE_LIKE.cycles_for(702)); assert!(q.cycles >= target); assert_eq!(q.minimum_cycles, target); - // One batch of anchors, one batch of candidates. - assert_eq!(fake.count_of(QUOTE_PREVIEWS_METHOD), 2); + // One query, for the one target. + assert_eq!(fake.count_of(QUOTE_FOR_CYCLES_METHOD), 1); + assert_eq!(fake.count_of(QUOTE_PREVIEWS_METHOD), 0); + let asked = Decode!(&fake.arg_of(QUOTE_FOR_CYCLES_METHOD), Vec).unwrap(); + assert_eq!(asked, vec![Nat::from(target)]); } +/// The fake inverts its own pricing the way the gateway does; this pins +/// that the figures handed back are the cheapest cent across fee shapes, +/// so a mapping slip would show as an amount that does not clear. #[tokio::test] async fn quote_for_cycles_is_cheapest_across_fee_shapes() { let shapes = [ @@ -418,14 +485,15 @@ async fn quote_for_cycles_is_cheapest_across_fee_shapes() { .await .unwrap_or_else(|e| panic!("{pricing:?} x {target}: {e}")); assert_cheapest(pricing, target, q.amount.minor_units); - assert!(fake.count_of(QUOTE_PREVIEWS_METHOD) <= 1 + CYCLES_SEARCH_ROUNDS); + assert_eq!(q.cycles, pricing.cycles_for(q.amount.minor_units)); + assert_eq!(q.minimum_cycles, target); } } } #[tokio::test] -async fn quote_for_cycles_without_rate_is_rate_unavailable() { - let fake = FakeGateway::with_quote(1000, 59, None); +async fn quote_for_cycles_stale_is_rate_unavailable() { + let fake = answering(CyclesQuoteOutcome::Stale); let err = quote_for_cycles(&fake, gateway(), 1, Currency::Usd) .await .unwrap_err(); @@ -442,7 +510,68 @@ async fn quote_for_cycles_beyond_any_amount_is_unpriceable() { .await .unwrap_err(); assert!( - matches!(err, CyclesPurchaseError::CyclesUnpriceable { .. }), + matches!( + &err, + CyclesPurchaseError::CyclesUnpriceable { + reason: Unpriceable::StripeFee, + .. + } + ), + "{err}" + ); + assert!( + err.to_string() + .ends_with("the card fee leaves no amount that buys them") + ); +} + +#[tokio::test] +async fn quote_for_cycles_below_minimum_names_both_figures() { + let fake = answering(CyclesQuoteOutcome::AmountBelowMin { + min_usd_cents: Nat::from(500u32), + usd_cents: Nat::from(131u32), + }); + let err = quote_for_cycles(&fake, gateway(), 7_690_000_000, Currency::Usd) + .await + .unwrap_err(); + assert!( + matches!(&err, CyclesPurchaseError::CyclesBelowMinimum { cycles: 7_690_000_000, amount, min } + if *amount == usd(131) && *min == usd(500)), + "{err}" + ); + assert_eq!( + err.to_string(), + "7690000000 cycles cost 1.31 USD, below the gateway minimum of 5.00 USD; ask for more" + ); +} + +#[tokio::test] +async fn quote_for_cycles_above_maximum_names_both_figures() { + let fake = answering(CyclesQuoteOutcome::AmountAboveMax { + max_usd_cents: Nat::from(10_000u32), + usd_cents: Nat::from(139_733u32), + }); + let err = quote_for_cycles(&fake, gateway(), 1_000_000_000_000, Currency::Usd) + .await + .unwrap_err(); + assert!( + matches!(&err, CyclesPurchaseError::CyclesAboveMaximum { amount, max, .. } + if *amount == usd(139_733) && *max == usd(10_000)), + "{err}" + ); +} + +#[tokio::test] +async fn quote_for_cycles_on_missing_gateway_is_gateway_not_found() { + let fake = FakeGateway { + absent: true, + ..FakeGateway::default() + }; + let err = quote_for_cycles(&fake, gateway(), 1, Currency::Usd) + .await + .unwrap_err(); + assert!( + matches!(err, CyclesPurchaseError::GatewayNotFound { .. }), "{err}" ); } diff --git a/crates/icp-canister-interfaces/src/cycles_gateway.rs b/crates/icp-canister-interfaces/src/cycles_gateway.rs index bf1455925..afabd5074 100644 --- a/crates/icp-canister-interfaces/src/cycles_gateway.rs +++ b/crates/icp-canister-interfaces/src/cycles_gateway.rs @@ -32,6 +32,13 @@ pub const CYCLES_GATEWAY_CANISTER_ID_ENV: &str = "ICP_CYCLES_GATEWAY_CANISTER_ID /// Amounts are USD cents. Computed by the same function that prices an order. pub const QUOTE_PREVIEWS_METHOD: &str = "quote_previews"; +/// Public query pricing a list of cycle targets: `(vec nat) -> (CyclesQuotes)`. +/// For each target, the least amount (USD cents) whose quote delivers at +/// least that many cycles, run through the same function that prices an +/// order, so `create_order` for that amount with the target as `minCycles` +/// is admitted at the figures quoted. +pub const QUOTE_FOR_CYCLES_METHOD: &str = "quote_for_cycles"; + /// Update creating an order for the caller: /// `(Amount, Destination, opt nat) -> (CreateOrderResult)`. pub const CREATE_ORDER_METHOD: &str = "create_order"; @@ -230,6 +237,94 @@ pub struct QuotePreviews { pub quotes: Vec, } +/// Why no amount delivers a number of cycles. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub enum Unpriceable { + /// In simulation mode the cycles are scaled down before delivery, and + /// the scaled figure would not cover the cycles-ledger fee. + #[serde(rename = "simulationScale")] + SimulationScale { + #[serde(rename = "ledgerFee")] + ledger_fee: Nat, + #[serde(rename = "scaledCycles")] + scaled_cycles: Nat, + }, + /// The card fee leaves nothing of any amount that would buy them. + #[serde(rename = "stripeFee")] + StripeFee, +} + +impl fmt::Display for Unpriceable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SimulationScale { + ledger_fee, + scaled_cycles, + } => write!( + f, + "the gateway is in simulation mode and would deliver {scaled_cycles} cycles, \ + which does not cover the ledger fee of {ledger_fee}" + ), + Self::StripeFee => write!(f, "the card fee leaves no amount that buys them"), + } + } +} + +/// What [`QUOTE_FOR_CYCLES_METHOD`] found for one cycle target. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub enum CyclesQuoteOutcome { + /// The least amount that buys the target is above the gateway's maximum. + #[serde(rename = "amountAboveMax")] + AmountAboveMax { + #[serde(rename = "maxUsdCents")] + max_usd_cents: Nat, + #[serde(rename = "usdCents")] + usd_cents: Nat, + }, + /// The least amount that buys the target is below the gateway's minimum. + #[serde(rename = "amountBelowMin")] + AmountBelowMin { + #[serde(rename = "minUsdCents")] + min_usd_cents: Nat, + #[serde(rename = "usdCents")] + usd_cents: Nat, + }, + #[serde(rename = "ok")] + Ok { + /// What `usd_cents` actually buys: at least the target. + #[serde(rename = "cyclesQuoted")] + cycles_quoted: Nat, + /// Card-processing fee, in USD cents. + #[serde(rename = "feeCents")] + fee_cents: Nat, + /// `usd_cents` net of fees, in USD cents. + #[serde(rename = "netCents")] + net_cents: Nat, + /// The least amount that buys the target, in USD cents. + #[serde(rename = "usdCents")] + usd_cents: Nat, + }, + /// The gateway has no current exchange rate. + #[serde(rename = "stale")] + Stale, + #[serde(rename = "unpriceable")] + Unpriceable(Unpriceable), +} + +/// One answer of [`QUOTE_FOR_CYCLES_METHOD`], keyed by the target asked for. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub struct CyclesQuote { + pub cycles: Nat, + pub outcome: CyclesQuoteOutcome, +} + +/// Result of [`QUOTE_FOR_CYCLES_METHOD`]: one answer per target asked for. +/// The `rates` the gateway also reports are deliberately not read back. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub struct CyclesQuotes { + pub quotes: Vec, +} + /// Why the gateway would not admit an order. #[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] pub enum Reason { @@ -587,4 +682,55 @@ mod tests { other => panic!("unexpected {other:?}"), } } + + /// `quote_for_cycles` answers a record with a `rates` field this crate + /// does not model, and its outcome tags are lowercase. + #[test] + fn cycles_quotes_decode_from_full_wire_record() { + #[derive(CandidType)] + struct WireRates { + fetched_at_ns: Int, + } + #[derive(CandidType)] + struct WireCyclesQuotes { + quotes: Vec, + rates: Option, + } + let bytes = Encode!(&WireCyclesQuotes { + quotes: vec![ + CyclesQuote { + cycles: Nat::from(5u8), + outcome: CyclesQuoteOutcome::Ok { + cycles_quoted: Nat::from(6u8), + fee_cents: Nat::from(51u8), + net_cents: Nat::from(651u16), + usd_cents: Nat::from(702u16), + }, + }, + CyclesQuote { + cycles: Nat::from(1u8), + outcome: CyclesQuoteOutcome::Unpriceable(Unpriceable::StripeFee), + }, + CyclesQuote { + cycles: Nat::from(2u8), + outcome: CyclesQuoteOutcome::Stale, + }, + ], + rates: Some(WireRates { + fetched_at_ns: Int::from(7), + }), + }) + .unwrap(); + let decoded = Decode!(&bytes, CyclesQuotes).unwrap(); + assert_eq!(decoded.quotes.len(), 3); + let CyclesQuoteOutcome::Ok { usd_cents, .. } = &decoded.quotes[0].outcome else { + panic!("unexpected {:?}", decoded.quotes[0].outcome); + }; + assert_eq!(*usd_cents, Nat::from(702u16)); + assert_eq!( + decoded.quotes[1].outcome, + CyclesQuoteOutcome::Unpriceable(Unpriceable::StripeFee) + ); + assert_eq!(decoded.quotes[2].outcome, CyclesQuoteOutcome::Stale); + } } diff --git a/docs/guides/tokens-and-cycles.md b/docs/guides/tokens-and-cycles.md index d1911f167..438589680 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -137,7 +137,7 @@ Create this order? [y/N] It then creates the order as your identity, prints the hosted checkout URL together with a QR code to scan with a phone (and opens the URL in your browser when run from a terminal), and waits until the cycles land on your identity's own cycles-ledger account. The card is charged on the payment processor's hosted page; the CLI never sees card details. `--amount` is in the currency given by `--currency`, which defaults to `USD`; USD is the only currency the gateway accepts today. -With `--cycles`, the CLI asks the gateway for quotes and picks the smallest amount, to the cent, whose quote delivers at least that many cycles once the card fee is taken out. The confirmation then shows what you asked for above the quote, and the order is created with that figure as its floor: if the rate moves before the order is created so that the amount would deliver less, the gateway refuses it and the CLI re-quotes once rather than delivering fewer cycles than you asked for. (With `--amount` the floor is 5% below the quote instead.) +With `--cycles`, the gateway names the smallest amount, to the cent, whose quote delivers at least that many cycles once the card fee is taken out. The confirmation then shows what you asked for above the quote, and the order is created with that figure as its floor: if the rate moves before the order is created so that the amount would deliver less, the gateway refuses it and the CLI re-quotes once rather than delivering fewer cycles than you asked for. (With `--amount` the floor is 5% below the quote instead.) Useful flags: