diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c8c1f24..3d5aa1ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +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; `--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/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-app/src/calls.rs b/crates/icp-app/src/calls.rs index bd5b56708..153048c7d 100644 --- a/crates/icp-app/src/calls.rs +++ b/crates/icp-app/src/calls.rs @@ -16,12 +16,12 @@ 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}; 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. @@ -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,13 @@ impl CanisterCalls for AgentCalls { } } +/// 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 +463,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..7063e2111 --- /dev/null +++ b/crates/icp-app/src/operations/cycles_purchase/mod.rs @@ -0,0 +1,789 @@ +//! 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 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; + +#[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, 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}; +use icp_project::operations::task::{PurchasePhase, Reporter, Task, TaskReporter}; +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 (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} cycles cost {amount}, below the gateway minimum of {min}; ask for more" + ))] + 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 { + 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 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 { + /// 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, + /// 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, +} + +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 * (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`]. +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 + } + } +} + +/// 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" })?; + Ok(Quote { + amount, + fee: Money::from_nat_minor_units(&preview.fee_cents, amount.currency) + .context(ConvertFieldSnafu { field: "feeCents" })?, + cycles, + minimum_cycles: with_slippage(cycles), + }) +} + +/// Ask the gateway for the least `currency` whose quote delivers at least +/// `cycles`. +/// +/// 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 (quotes,): (CyclesQuotes,) = query_typed( + calls, + gateway, + 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), + } + }) + })?; + 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)?, + } + .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(), + } +} + +/// 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, + } + } +} + +/// 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 names the +/// phase the wait starts in, and it is what +/// [`CyclesPurchaseError::Interrupted`] carries if `interrupt` resolves before +/// 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 +/// 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, + 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 { + 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 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()); + } + 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 { + phases.finish(TaskOutcome::failed("gave up waiting")); + 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 { + phases.finish(TaskOutcome::failed("lost contact with the gateway")); + return Err(CyclesPurchaseError::WaitUnanswered { + gateway, + id: id.to_owned(), + source, + }); + } + } + Err(error) => { + phases.finish(TaskOutcome::failed(error.to_string())); + return Err(error); + } + } + + select! { + _ = sleep(options.poll_interval) => {} + _ = &mut interrupt => { + phases.finish(TaskOutcome::failed("interrupted")); + return interrupted(last); + } + } + } +} + +/// 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..8f2285a8c --- /dev/null +++ b/crates/icp-app/src/operations/cycles_purchase/money.rs @@ -0,0 +1,276 @@ +//! 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}'; only USD is supported" + )), + } + } +} + +/// 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..237066d6b --- /dev/null +++ b/crates/icp-app/src/operations/cycles_purchase/tests.rs @@ -0,0 +1,1134 @@ +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, 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}; +use icp_project::operations::task::{PurchasePhase, Reporter}; + +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, + } +} + +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(), + } +} + +/// 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) + } + + /// 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. +#[derive(Default)] +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, 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 + /// 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, + /// Never answers `get_order`, as a gateway behind a stalled connection. + stall_polls: 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 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!(&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; + } + 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, + 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() + } +} + +// --- 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()); + 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}" + ); +} + +// --- 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 + ); +} + +fn answering(outcome: CyclesQuoteOutcome) -> FakeGateway { + FakeGateway { + cycles_quote: Some(outcome), + ..FakeGateway::default() + } +} + +#[tokio::test] +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) + .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 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 = [ + 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_eq!(q.cycles, pricing.cycles_for(q.amount.minor_units)); + assert_eq!(q.minimum_cycles, target); + } + } +} + +#[tokio::test] +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(); + 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 { + 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}" + ); +} + +#[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] +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 -------------------------------------------------------- + +/// 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 (reporter, mut events) = icp_events::channel(); + let result = wait_for_order( + fake, + gateway(), + "o1", + initial, + options, + pending(), + &reporter, + ) + .await; + 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] +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, phases) = wait(&fake, None, &fast()).await; + assert_eq!(result.unwrap().status, OrderStatus::Delivered); + assert_eq!( + phases, + vec![ + succeeded(PurchasePhase::AwaitingPayment), + succeeded(PurchasePhase::Delivering), + ] + ); + assert_eq!(fake.count_of(GET_ORDER_METHOD), 4); +} + +#[tokio::test] +async fn wait_starts_in_the_initial_orders_phase() { + let fake = FakeGateway::default().polls(vec![ + Ok(Some(order("o1", OrderStatus::Paid))), + Ok(Some(order("o1", OrderStatus::Delivered))), + ]); + 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); + // Already paid: no "awaiting payment" leg is reported. + assert_eq!(phases, vec![succeeded(PurchasePhase::Delivering)]); +} + +#[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, phases) = wait(&fake, None, &fast()).await; + assert_eq!(result.unwrap().status, 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}") + )] + ); + } +} + +#[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, phases) = wait(&fake, None, &options).await; + match result.unwrap_err() { + CyclesPurchaseError::WaitTimedOut { order } => { + assert_eq!(order.status, OrderStatus::Created); + } + other => panic!("unexpected {other:?}"), + } + // The leg was closed out before giving up. + assert_eq!( + phases, + vec![failed(PurchasePhase::AwaitingPayment, "gave up waiting")] + ); +} + +#[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() { + // 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", + Some(initial), + &WaitOptions::default(), + ready(()), + &Reporter::null(), + ) + .await; + match result.unwrap_err() { + CyclesPurchaseError::Interrupted { order } => { + assert_eq!(order.id, "o1"); + assert_eq!(order.status, OrderStatus::Paid); + } + 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/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..191b117de 100644 --- a/crates/icp-app/src/operations/token/mod.rs +++ b/crates/icp-app/src/operations/token/mod.rs @@ -49,3 +49,45 @@ 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 { + // 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() +} + +#[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"); + // 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 new file mode 100644 index 000000000..afabd5074 --- /dev/null +++ b/crates/icp-canister-interfaces/src/cycles_gateway.rs @@ -0,0 +1,736 @@ +//! 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"; + +/// 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"; + +/// 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, + // `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. +#[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 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 { + #[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.paid_usd_cents, Some(Nat::from(1000u32))); + } + + #[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:?}"), + } + } + + /// `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/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/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 new file mode 100644 index 000000000..514d8d04d --- /dev/null +++ b/crates/icp-cli/src/commands/cycles/buy.rs @@ -0,0 +1,815 @@ +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, 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; +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::operations::task::Reporter; +use icp_project::parsers::CyclesAmount; +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::render::rendered; + +/// 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`. 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, --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 = ["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, + + /// 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> { + // A malformed amount should fail before any identity is unlocked or any + // network reached. + 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) { + 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?; + print_final(&order, None, output)?; + return Ok(()); + } + + // 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?; + let payable = order.status == OrderStatus::Created && !past_deadline(&order); + if payable { + announce_payable(&order, None, output, args.no_open); + } else if output == Output::Human { + 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, 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, quote) = + create_order(calls, gateway, buyer, identity.as_deref(), target, args).await?; + announce_payable(&order, Some("e), output, args.no_open); + (order, true) + } + }; + + if args.no_wait { + 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 + ); + } + return Ok(()); + } + + 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. 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, + buyer: Principal, + identity: Option<&str>, + target: Target, + args: &BuyArgs, +) -> 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; + + loop { + match purchase::place_order(calls, gateway, "e).await { + Ok(order) => return Ok((order, quote)), + Err(CyclesPurchaseError::OrderRefused { + error: CreateOrderError::QuoteChanged { .. }, + }) 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 within tolerance. New quote: {}. Retrying once.", + quote_line(&fresh) + ); + } else { + warn!("The exchange rate moved since the quote."); + 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)) + } + Err(CyclesPurchaseError::OpenOrderExists { order, open, max }) => { + bail!("{}", open_order_message(&order, open, max)) + } + Err(error) => return Err(error.into()), + } + } +} + +/// Show what is about to happen and ask. Without a terminal there is nobody +/// to ask, so `--yes` is required. +fn confirm( + quote: &Quote, + target: Target, + 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)"); + if let Target::Receive { cycles, .. } = target { + info!("Wanted: at least {}", format_cycles(cycles)); + } + 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_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, 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", + order.id, order.status + ); + return; + }; + match output { + Output::Json => { + 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), + }) { + 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}"); + // 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."), + 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, + debug: bool, +) -> Result<(), anyhow::Error> { + let id = order.id.clone(); + 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, + 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 { + 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()); + } + 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 + }; + + print_final(&order, balance, output)?; + + 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)); + } + } + 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) + } + } +} + +/// 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(), + ) +} + +/// 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(()), + } +} + +/// 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, 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, + /// 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. + 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, Currency::Usd), + fee: Money::from_minor_units(59, Currency::Usd), + cycles: 7_238_000_000_000, + minimum_cycles: 0, + }); + assert_eq!( + line, + "10.00 USD -> 7.238T cycles (includes a 0.59 USD card fee; rate locked at order creation)" + ); + } + + #[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 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-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/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..c0f18b508 100644 --- a/crates/icp-cli/tests/cycles_tests.rs +++ b/crates/icp-cli/tests/cycles_tests.rs @@ -418,3 +418,123 @@ 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(); + + 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 +/// 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/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/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..438589680 100644 --- a/docs/guides/tokens-and-cycles.md +++ b/docs/guides/tokens-and-cycles.md @@ -114,6 +114,43 @@ 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 +# 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: + +``` +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 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 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: + +- `--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. 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. + +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 Send ICP tokens to another principal: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 124ecdf22..676cc0744 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,38 @@ 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`. 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, --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` +* `--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.