Skip to content

feat: icp cycles buy — buy cycles with a card for the current identity - #813

Open
raymondk wants to merge 8 commits into
mainfrom
rk/cycles-buy
Open

raymondk wants to merge 8 commits into
mainfrom
rk/cycles-buy

Conversation

@raymondk

@raymondk raymondk commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #812.

What

icp cycles buy --amount <amount> [--currency USD] (or --cycles <cycles>, which pays the least amount that buys them once the card fee is taken out) buys cycles with a card for the CLI's current identity, through a cycles gateway canister, with no ICP, no wallet and no browser sign-in. The command quotes the amount, asks for confirmation, creates the order as the local identity, prints the hosted Stripe Checkout URL with a QR code to scan from a phone (and opens the URL from a terminal), then polls the gateway until the cycles land on the identity's own cycles-ledger account.

$ icp cycles buy --amount 10 -n ic
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] y
Order 26dd22b2..., payable until 2026-10-07T12:34:56Z. Pay here:
https://checkout.stripe.com/c/pay/cs_test_...
Delivered 7.238T cycles to rv4ez-fgjbj-...-vae
Balance: 7.238T cycles

Flags: --resume <order-id> and --cancel <order-id> continue or cancel an existing order; --gateway <canister-id> or ICP_CYCLES_GATEWAY_CANISTER_ID picks another gateway; --yes, --no-open, --no-wait, --json, -q. Ctrl-C leaves the order payable and prints the resume and cancel commands. The quoted quantity is pinned at 95 % via minCycles; a quoteChanged refusal re-quotes and re-confirms once.

Decisions (from the issue's open questions)

  • Gateway: the term stays, but it is a canister id and the help text says so. The confirmation block shows it so the user confirms where the order goes. Default is hardcoded with flag and env override, mirroring the engine canister id; a settings key is deferred.
  • Progress: the wait reports one icp-events task per purchase phase (awaiting payment, delivering) and the command renders it like any other operation; a script (--json, -q) runs it with a null reporter.
  • Readiness probe: an HTTP gateway's canister_not_found maps to an IC0301 rejection in AgentCalls, and the deploy readiness probe no longer reads IC0301 as "serving", so routing that has not caught up with a creation is retried as before.
  • Scope: core plus --cycles. A --tolerance flag and other currencies are follow-ups.
  • --cycles: one quote_for_cycles query (Add quote_for_cycles: the least USD amount that buys at least N cycles raymondk/cyclepay#12, live on mainnet) returns the least amount whose quote delivers at least the target, inverted from the gateway's own pricing, so the amount named is the one create_order prices. The order's floor is the figure asked for, not 5 % under the quote, so a rate move is refused and re-quoted rather than delivering less than requested. Amount bounds come back from the quote as CyclesBelowMinimum / CyclesAboveMaximum, naming the cost and the bound. A fee-shape sweep in the fake proves the figures are the cheapest cent.
  • Currencies: --amount is a decimal in the currency --currency names, defaulting to USD. The gateway is USD-only today, so any other code is rejected at parse time; the operation layer models Money { minor_units, currency } and JSON emits {"currency":"USD","value":"10.00"}, so a new currency is a Currency variant plus the gateway mapping, not a new flag.

How

  • icp-canister-interfaces::cycles_gateway: the gateway's Candid types (Motoko camelCase renamed with serde), method names, default canister id. Order names only the fields the CLI reads; a test pins that it decodes from the full wire record.
  • icp-app::operations::cycles_purchase: quote, place_order, get_order, find_open_order, cancel_order, wait_for_order over &dyn CanisterCalls. The wait loop tolerates unanswered polls, bounds on the order's own deadline, and hands the last-seen order back inside Interrupted so the command can print how to resume. tooManyOpenOrders is resolved through the caller-scoped list_orders into the existing order's id and checkout URL.
  • icp-cli commands/cycles/buy.rs: the UX above; the operation never prints. The QR code is drawn with the qrcode crate (no default features) in half-block characters, only when stderr is a terminal.
  • AgentCalls::wrap: an HTTP gateway's canister_not_found (what PocketIC answers for an unknown canister, before any replica rejects) is now reported as an IC0301 rejection, so "the gateway does not exist on this network, use -n ic" works locally. Engine resolution gets the same benefit.
  • Docs: regenerated CLI reference, a "Buying Cycles with a Card" guide section, a pointer from the mainnet guide, changelog.

Testing

  • Unit: 30 tests for the operation against a scripted fake gateway (happy path, every terminal status, transient-error streaks, expiry/paid timeouts, interruption, list_orders paging, minCycles/destination arguments); money parsing and formatting; the format_cycles humanizer; the wrap classification.
  • Integration (cycles_tests.rs): argument conflicts, anonymous refusal, and the missing-gateway message on a local network.
  • Manual against mainnet: the quote path reaches the real gateway, which currently answers cycles = null (simulation mode), so create and wait were only exercised against the fake and a local network.

🤖 Generated with Claude Code

https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh

Adds `icp cycles buy --amount <usd>`: quotes the amount on a cycles gateway
canister, shows buyer, gateway canister id and quote for confirmation,
creates the order as the current identity, prints the hosted checkout URL
(opening it in a browser from a terminal) and waits until the cycles land
on the identity's own cycles-ledger account. `--resume` and `--cancel`
continue or cancel an existing order; `--gateway` or
`ICP_CYCLES_GATEWAY_CANISTER_ID` picks another gateway canister.

- `icp-canister-interfaces::cycles_gateway`: the gateway's Candid
  interface and default canister id.
- `icp-app::operations::cycles_purchase`: quote, create, read, cancel and
  wait over `CanisterCalls`, with a currency-aware `Money` so another
  currency is additive. Unit-tested against a scripted fake gateway.
- `AgentCalls::wrap` reports an HTTP gateway's `canister_not_found` as an
  IC0301 rejection, so "this canister does not exist here" is answered
  the same on a local network as by a replica.

Closes #812

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh
Copilot AI balanced review requested due to automatic review settings October 7, 2026 22:40
@raymondk
raymondk requested a review from a team as a code owner October 7, 2026 22:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Recovery commands can target the wrong context, interruption is delayed during stalled polling, and several output and arithmetic edge cases remain.

5 open findings
What changed in this PR

Adds card-based cycles purchases for the active CLI identity through a configurable gateway canister.

Changes:

  • Adds quote, order, cancellation, resume, and polling workflows.
  • Introduces gateway Candid interfaces, money handling, and CLI output modes.
  • Adds tests, documentation, and improved missing-canister classification.
File Description
CHANGELOG.md Records the new command.
docs/​reference/​cli.md Documents CLI options.
docs/​guides/​tokens-and-cycles.md Adds the card-purchase guide.
docs/​guides/​deploying-to-mainnet.md Links card purchases into deployment setup.
crates/​icp-cli/​tests/​cycles_tests.rs Tests validation and missing gateways.
crates/​icp-cli/​src/​main.rs Dispatches the buy command.
crates/​icp-cli/​src/​commands/​parsers.rs Parses USD amounts.
crates/​icp-cli/​src/​commands/​cycles/​mod.rs Registers the subcommand.
crates/​icp-cli/​src/​commands/​cycles/​buy.rs Implements purchase UX and reporting.
crates/​icp-canister-interfaces/​src/​lib.rs Exports the gateway interface.
crates/​icp-canister-interfaces/​src/​cycles_gateway.rs Defines gateway types and methods.
crates/​icp-app/​src/​operations/​token/​mod.rs Adds human-readable cycle formatting.
crates/​icp-app/​src/​operations/​mod.rs Exports purchase operations.
crates/​icp-app/​src/​operations/​cycles_purchase/​tests.rs Tests purchase operations.
crates/​icp-app/​src/​operations/​cycles_purchase/​money.rs Models and parses currency amounts.
crates/​icp-app/​src/​operations/​cycles_purchase/​mod.rs Implements gateway purchase operations.
crates/​icp-app/​src/​calls.rs Classifies gateway missing-canister responses.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

/// [`QUOTE_SLIPPAGE_PERCENT`]. Passed to the gateway so a rate move
/// against the buyer is refused rather than silently delivering less.
pub fn minimum_cycles(&self) -> u128 {
self.cycles * (100 - QUOTE_SLIPPAGE_PERCENT) / 100

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ee62e4 (divide first).

Comment on lines +70 to +71
let thousandths = cycles * 1000 / unit;
let s = format!("{}.{:03}", thousandths / 1000, thousandths % 1000);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ee62e4 (only the remainder is scaled).

Comment on lines +468 to +469
loop {
match get_order(calls, gateway, id).await {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ee62e4: the interrupt is raced against the poll as well.

Comment on lines +172 to +179
if args.no_wait {
if output == Output::Human {
info!(
"Not waiting. Run `icp cycles buy --resume {}` to wait for delivery.",
order.id
);
}
return Ok(());

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ee62e4: with nothing left to pay, --no-wait prints the order's status in JSON and quiet modes before returning.

Comment on lines +556 to +564
#[derive(Serialize)]
struct JsonOrderCreated<'a> {
order_id: &'a str,
checkout_url: &'a str,
/// Exact cycles promised, as a decimal string.
locked_cycles: String,
/// RFC 3339.
expires_at: Option<String>,
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ee62e4: the created-order object carries amount and fee as Money; on --resume they are null, since the gateway does not return the quote.

raymondk and others added 2 commits October 7, 2026 23:14
`--amount` is now a plain decimal in the currency `--currency` names,
defaulting to USD. Only USD is accepted until the gateway's `Amount`
grows a currency, so the flag's job today is to fix the surface: adding
a currency later is a `Currency` variant, not a new flag.

The amount is validated before any identity is unlocked or network
reached, so a malformed value fails fast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh
The closing hint after delivery was hardcoded to `icp deploy -e ic`. It
now names the environment that was selected, `-e ic` only when the `ic`
network was, plain `icp deploy` for a project's default environment, and
nothing for a network named any other way, which has no environment to
point at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh

@lwshang lwshang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few points inline. #1 and #2 are the ones I'd want resolved before merge; the rest are smaller.

// 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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mapping applies to every AgentCalls user, not just cycles buy. Deploy's readiness probe (is_serving_reject in operations/deploy.rs) treats any non-IC0508/IC0509 rejection as "serving". So a gateway 400 canister_not_found (e.g. stale routing right after create) now counts as ready instead of being retried. Could this be scoped to the cycles-gateway path?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that this leaked into the probe. Rather than scoping the mapping (an HTTP gateway's canister_not_found really is the same verdict as IC0301, and cycles buy needs it as one), is_serving_reject now treats is_canister_not_found() as not serving, which also covers a replica-issued IC0301 that the probe previously counted as ready. Test added; deploy_tests pass. 9ee62e4

error: CreateOrderError::QuoteChanged { .. },
}) if attempt == 0 => {
quote = purchase::quote(calls, gateway, amount).await?;
if args.yes {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With --yes, a quoteChanged re-quotes and places the order at the new rate, only logging it. That gets around the 5% minCycles guard: a 30% rate drop goes through without the user seeing it. Suggest failing under --yes instead (or re-checking the new quote against the original minCycles).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ee62e4: under --yes the re-quote is placed only if Quote::tolerates holds (at least the original minCycles, for at most the original amount plus the 5% slippage, which matters for --cycles where a rate drop raises the price instead). Past that the command stops without an order and says to re-run for a fresh quote.

pub expired_by: Option<ExpiredBy>,
#[serde(rename = "abandonedReason")]
pub abandoned_reason: Option<String>,
pub destination: Destination,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order.destination isn't read, but it's a single-case variant, and older CLIs can't decode a variant case they don't know. If the gateway later adds a destination, one such order breaks decoding of the whole list_orders page, plus --resume/--cancel. Drop it from Order and keep Destination only as the create_order argument?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped from Order in 9ee62e4; Destination is now only the create_order argument.

let mut paid_since: Option<Instant> = None;

loop {
match get_order(calls, gateway, id).await {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interrupt is only raced against the sleep, not against this get_order. Ctrl-C does nothing during a stalled poll. On the first poll the signal handler isn't registered yet, so Ctrl-C kills the process and the --resume/--cancel hint is never printed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed in 9ee62e4: the interrupt is raced against get_order as well as the sleep, so it is polled (and the handler registered) before the first call goes out. Tests cover an interrupt during a stalled poll.

Some(id) => {
let order = purchase::get_order(calls, gateway, id).await?;
if order.status == OrderStatus::Created {
announce_payable(&order, output, args.no_open);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--resume on a Created order whose expires_at_ns has already passed still says "payable" and opens the dead checkout page. Check expiry first?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ee62e4: --resume checks expiresAtNs against the local clock first; a past-deadline created order is no longer announced as payable or opened, and the timed-out path says the gateway has not expired it yet.

/// [`QUOTE_SLIPPAGE_PERCENT`]. Passed to the gateway so a rate move
/// against the buyer is refused rather than silently delivering less.
pub fn minimum_cycles(&self) -> u128 {
self.cycles * (100 - QUOTE_SLIPPAGE_PERCENT) / 100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cycles * 95 is unchecked on a value the gateway supplies (same for the * 1000 in format_cycles). Use checked_mul, or divide first: cycles / 100 * 95.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ee62e4: the floor divides first (cycles / 100 * 95) and format_cycles scales only the remainder; u128::MAX is covered in tests.

initial: Option<OrderView>,
options: &WaitOptions,
interrupt: impl Future<Output = ()>,
on_change: &mut dyn FnMut(&OrderView),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: other operations report progress as icp-events events through a Reporter, not through a custom FnMut callback (see CLAUDE.md, "Commands vs. operations").

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converted in 9ee62e4: wait_for_order takes a Reporter and reports a CyclesPurchaseTask per phase (awaiting payment, delivering), and the command runs it under render::rendered. The bespoke spinner is gone.

Comment thread crates/icp-app/src/calls.rs Outdated

/// The replica's error code for a call addressed to a canister that does not
/// exist, which [`CallError::is_canister_not_found`] recognizes.
const CANISTER_NOT_FOUND: &str = "IC0301";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this duplicates the IC0301 constant in icp-project/src/calls.rs. Make that one pub and import it, so the two can't drift.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9ee62e4: icp_project::calls::CANISTER_NOT_FOUND is pub and icp-app imports it.

Err(error) => return Err(error.into()),
}
}
bail!("the exchange rate is moving too quickly to lock a quote; try again in a minute")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this bail! can't be reached: a second QuoteChanged goes through the OrderRefused arm. Use unreachable!(), or restructure, so the message doesn't repeat the one in refusal_message.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restructured in 9ee62e4: the loop is driven by a requoted flag and a second QuoteChanged falls through to refusal_message, so there is no trailing bail.

raymondk and others added 4 commits October 8, 2026 12:12
Paying from a terminal usually means reaching for a phone, so the human
output now draws the checkout URL as a QR code under it, in half-block
characters, whenever stderr is a terminal. `--json` and `-q` are
unchanged. Adds `qrcode` (no default features) as a workspace dependency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh
The delivered report now ends with the cycles delivered and the
balance. Which command spends them is the user's business, and a hint
that guessed a deploy target was wrong as often as it was right.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh
…s them

`--cycles 5t` names the cycles to receive instead of the sum to spend.
The gateway only tells what a given amount buys, so the operation reads
its fee-plus-rate model back off two anchor quotes, names the cent that
should just clear the target, quotes it and its neighbours for real and
takes the least that clears; if rounding leaves them all short it
re-anchors and tries again. The order's floor is then the figure asked
for rather than 5 % under the quote, so a rate move is refused and
re-quoted rather than delivering fewer cycles than requested.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh
- Report the wait as icp-events tasks, one per purchase phase, instead of
  a callback into a bespoke spinner; a `CyclesPurchaseTask` joins the
  task vocabulary and the command renders it like any other operation.
- Race the interrupt against the poll itself, not only the sleep after
  it, so Ctrl-C lands during a stalled call and during the first poll.
- Under `--yes`, a re-quote after `quoteChanged` is placed only when it
  stays within the slippage tolerance of the quote that was shown; past
  it the command stops without an order.
- `--resume` checks the payment deadline before calling an order payable
  and opening its checkout page.
- `--no-wait` on an order with nothing left to pay prints its status in
  JSON and quiet modes rather than exiting silently.
- The JSON created-order object carries the quoted amount and fee.
- Drop `Order.destination`, a single-case variant the CLI never read
  that would stop every order decoding once the gateway adds a case.
- Overflow-proof the slippage floor and `format_cycles` on values the
  gateway supplies.
- The deploy readiness probe no longer reads IC0301 as "serving": an
  HTTP gateway's canister_not_found now maps to that code, and routing
  that has not caught up with a creation is not readiness.
- Share the IC0301 constant from icp-project instead of duplicating it.
- Replace the unreachable bail after the retry loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh
The cycles gateway now answers `quote_for_cycles` (raymondk/cyclepay#12,
shipped in raymondk/cyclepay#14 and live on mainnet): for each target
the least USD amount whose quote delivers at least that many cycles, the
fee split, and what the amount actually buys, inverted from its own
pricing rather than searched. The operation's two-anchor estimate-and-
check loop over `quote_previews` is gone; `quote_for_cycles` is one query.

The outcome variant maps onto errors the command already shows: `stale`
is `RateUnavailable`, `unpriceable` carries the gateway's reason, and the
amount bounds become `CyclesBelowMinimum` / `CyclesAboveMaximum`, which
name the cost and the bound so the buyer knows which way to move. The
fake gateway inverts its own pricing the same way, so the fee-shape sweep
still proves the figures handed back are the cheapest cent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kch85M4gbbLdDLpwkzvZh

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: icp cycles buy — buy cycles with a card for the current identity via an on-chain gateway

3 participants