Repository navigation
Conversation
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
There was a problem hiding this comment.
🟡 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 |
| let thousandths = cycles * 1000 / unit; | ||
| let s = format!("{}.{:03}", thousandths / 1000, thousandths % 1000); |
There was a problem hiding this comment.
Fixed in 9ee62e4 (only the remainder is scaled).
| loop { | ||
| match get_order(calls, gateway, id).await { |
There was a problem hiding this comment.
Fixed in 9ee62e4: the interrupt is raced against the poll as well.
| if args.no_wait { | ||
| if output == Output::Human { | ||
| info!( | ||
| "Not waiting. Run `icp cycles buy --resume {}` to wait for delivery.", | ||
| order.id | ||
| ); | ||
| } | ||
| return Ok(()); |
There was a problem hiding this comment.
Fixed in 9ee62e4: with nothing left to pay, --no-wait prints the order's status in JSON and quiet modes before returning.
| #[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>, | ||
| } |
There was a problem hiding this comment.
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.
`--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
| // 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) => { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
--resume on a Created order whose expires_at_ns has already passed still says "payable" and opens the dead checkout page. Check expiry first?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
nit: other operations report progress as icp-events events through a Reporter, not through a custom FnMut callback (see CLAUDE.md, "Commands vs. operations").
There was a problem hiding this comment.
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.
|
|
||
| /// 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"; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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


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.Flags:
--resume <order-id>and--cancel <order-id>continue or cancel an existing order;--gateway <canister-id>orICP_CYCLES_GATEWAY_CANISTER_IDpicks 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 % viaminCycles; aquoteChangedrefusal re-quotes and re-confirms once.Decisions (from the issue's open questions)
icp-eventstask 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.canister_not_foundmaps to an IC0301 rejection inAgentCalls, 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.--cycles. A--toleranceflag and other currencies are follow-ups.--cycles: onequote_for_cyclesquery (Addquote_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 onecreate_orderprices. 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 asCyclesBelowMinimum/CyclesAboveMaximum, naming the cost and the bound. A fee-shape sweep in the fake proves the figures are the cheapest cent.--amountis a decimal in the currency--currencynames, defaulting toUSD. The gateway is USD-only today, so any other code is rejected at parse time; the operation layer modelsMoney { minor_units, currency }and JSON emits{"currency":"USD","value":"10.00"}, so a new currency is aCurrencyvariant 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.Ordernames 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_orderover&dyn CanisterCalls. The wait loop tolerates unanswered polls, bounds on the order's own deadline, and hands the last-seen order back insideInterruptedso the command can print how to resume.tooManyOpenOrdersis resolved through the caller-scopedlist_ordersinto 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 theqrcodecrate (no default features) in half-block characters, only when stderr is a terminal.AgentCalls::wrap: an HTTP gateway'scanister_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.Testing
list_orderspaging,minCycles/destination arguments); money parsing and formatting; theformat_cycleshumanizer; thewrapclassification.cycles_tests.rs): argument conflicts, anonymous refusal, and the missing-gateway message on a local network.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