From 641003e45644d1db3606ece8f0953a4e027abeec Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:57:11 -0500 Subject: [PATCH] fix(cli): say how to get a USD quote and what it must match A USD payment without a quote now names --quote QUOTE_ID, says it needs a wallet on a USD line of credit, and says how to create the quote, instead of naming the API field quote_id. quotes create rejects a --unit already in the --to currency before sending it, since the quote would convert nothing, and its hint names both directions: the invoice's sats to usd to pay, a USD amount to btc to receive. An invalid_currency rejection of a quoted amount says the quote must convert to exactly the payment's amount and how to quote it. Other invalid_currency rejections get no hint. Closes #39 Closes #35 Closes #38 --- CHANGELOG.md | 2 ++ docs/guides/payments.md | 4 +++ docs/guides/troubleshooting.md | 8 ++++- docs/reference/exit-codes.md | 2 +- src/api.rs | 44 +++++++++++++++++++++--- src/cli.rs | 6 ++-- src/input.rs | 62 +++++++++++++++++++++++++++++----- tests/cli_integration.rs | 5 ++- 8 files changed, 115 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3bd3e4..0abe222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - Errors name the next command: missing scope names the command that lists IDs, credential and profile errors list the saved names, HTTP 401 and 403 point to `voltage auth status` and `voltage context`, and `quote_required` points to `voltage quotes create`. - An API rejection's error leads with the API's reason, and human output shows the rest of the API error as field/value lines instead of JSON. - `-h` shows usage and options; `--help` adds notes, examples, and the guide link. +- A USD payment without a quote names `--quote QUOTE_ID` and how to create one, and an `invalid_currency` rejection of a quoted amount says how to quote the payment's exact amount. +- `quotes create` rejects a `--unit` already in the `--to` currency before sending it, and its help says which currency each flag names. ### Fixed diff --git a/docs/guides/payments.md b/docs/guides/payments.md index eda89f5..b22d533 100644 --- a/docs/guides/payments.md +++ b/docs/guides/payments.md @@ -41,6 +41,10 @@ voltage payments receive --profile prod --wallet WALLET_ID \ --currency usd --kind bolt11 --amount 10 --unit usd --quote QUOTE_ID --wait ready --execute ``` +A quote converts `--amount` from the currency of its `--unit` into the `--to` currency, so the two must differ. With `--unit` and `--to`, the CLI rejects a quote whose `--unit` is already in the `--to` currency before sending it. A complete `--data` body is sent as is, and the API rejects it with `mismatch_currency`. + +The quote must convert to exactly the payment's amount, or the API rejects the payment with `invalid_currency`. `voltage quotes get QUOTE_ID` shows what a quote converts to. To pay a BTC invoice from a USD wallet, quote the invoice's BTC amount with `--unit sats --to usd`. To receive a USD amount, quote it with `--unit usd --to btc`, as above. + A quote is single-use and expires; a quoted receive's expiration is capped by the quote's remaining life. On-chain and BIP21 payments and treasury movements are features Voltage enables per organization; the API rejects them with `feature_flag_disabled` until then, and the CLI says so. diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 6342c00..1e013b9 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -97,6 +97,12 @@ On-chain and BIP21 payments and treasury movements are enabled per organization. ### `quote_required` -A wallet on a USD line of credit needs a quote for every payment. Create a quote with `voltage quotes create`, then pass `--quote QUOTE_ID` and the same USD amount to the payment. See [quotes and lines of credit](payments.md#quotes-and-lines-of-credit). +A wallet on a USD line of credit needs a quote for every payment. Create a quote with `voltage quotes create`, then pass `--quote QUOTE_ID` and the same amount you quoted to the payment: the invoice's BTC amount to pay, or the USD amount to receive. See [quotes and lines of credit](payments.md#quotes-and-lines-of-credit). + +The CLI refuses a USD payment without `--quote` itself, with `A USD payment needs --quote QUOTE_ID`, before it sends anything. A wallet on a BTC line of credit cannot pay or receive in USD; use `--currency btc`. + +### `invalid_currency` with a quoted amount + +When the detail names a quoted amount, the quote does not convert to exactly the payment's amount. Check what it converts to with `voltage quotes get QUOTE_ID`. To pay a BTC invoice from a USD wallet, quote the invoice's BTC amount with `--unit sats --to usd`. See [quotes and lines of credit](payments.md#quotes-and-lines-of-credit). Related: [exit codes](../reference/exit-codes.md), [environment variables](../reference/environment.md), [log in and use profiles](login-and-profiles.md). diff --git a/docs/reference/exit-codes.md b/docs/reference/exit-codes.md index b6e37cb..1631d45 100644 --- a/docs/reference/exit-codes.md +++ b/docs/reference/exit-codes.md @@ -13,4 +13,4 @@ After exit code 4 for a payment, check its ID before you retry; see [payment IDs](../concepts/safety-model.md#payment-ids-and-the-request-journal). After exit code 5, keep watching with `voltage payments get PAYMENT_ID --profile prod --wait completed`. -Many errors for exit codes 2 and 3 carry a `hint:` (or `error.hint` with `--json`) that names the next command: the discovery command for a missing `--org`, `--env`, or `--wallet`, the saved credential and profile names for an unknown or ambiguous selection, `voltage logout --local --account NAME` and then `voltage login --account NAME` for an expired or revoked login, `voltage logout --account NAME` and then `voltage login --account NAME --auth-url URL` to move a browser login to a different auth URL, `voltage auth status` for a rejected account credential (HTTP 401), and `voltage context` for an account credential that cannot reach the selected scope (HTTP 403). A rejected checkout token points to `--token-file` or the variable that supplies it, never to the account credential. A payment rejected with `quote_required` points to `voltage quotes create`. +Many errors for exit codes 2 and 3 carry a `hint:` (or `error.hint` with `--json`) that names the next command: the discovery command for a missing `--org`, `--env`, or `--wallet`, the saved credential and profile names for an unknown or ambiguous selection, `voltage logout --local --account NAME` and then `voltage login --account NAME` for an expired or revoked login, `voltage logout --account NAME` and then `voltage login --account NAME --auth-url URL` to move a browser login to a different auth URL, `voltage auth status` for a rejected account credential (HTTP 401), and `voltage context` for an account credential that cannot reach the selected scope (HTTP 403). A rejected checkout token points to `--token-file` or the variable that supplies it, never to the account credential. A payment rejected with `quote_required`, or a USD payment without `--quote`, points to `voltage quotes create`, and a payment rejected with `invalid_currency` for a quoted amount says how to quote the payment's exact amount. diff --git a/src/api.rs b/src/api.rs index c06e93e..f8afa94 100644 --- a/src/api.rs +++ b/src/api.rs @@ -343,6 +343,12 @@ pub(crate) enum BodyFailure { Interrupted, } +/// How to get the quote that each payment from a wallet on a USD line of credit needs. +const QUOTE_HINT: &str = "A wallet on a USD line of credit needs a quote for each payment. Create one with `voltage quotes create`, wait until `voltage quotes get QUOTE_ID` shows it, then pass --quote QUOTE_ID. A wallet on a BTC line of credit needs no quote."; + +/// How to quote a payment so that the API accepts the quoted amount. +const QUOTE_MISMATCH_HINT: &str = "A quote must convert to exactly the payment's amount; `voltage quotes get QUOTE_ID` shows what it converts to. To pay a BTC invoice from a USD wallet, quote the invoice's BTC amount: --unit sats --to usd. To receive a USD amount, quote it: --unit usd --to btc."; + /// Error types the CLI explains specially; the API's other types are shown as detail. #[derive(Debug, Default, Deserialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] @@ -351,6 +357,9 @@ enum ApiErrorType { /// Staging and production answer a key they do not recognize this way, not with 401. MissingCredentials, QuoteRequired, + /// A quote that does not convert to exactly the payment's amount, among other currency + /// mismatches; only the quote mismatch gets a hint. + InvalidCurrency, #[default] #[serde(other)] Other, @@ -390,12 +399,20 @@ impl ApiErrorBody { } /// The next step an error type names. The CLI has not read the wallet, so a missing - /// quote describes the wallets that need one rather than this wallet. + /// quote describes the wallets that need one rather than this wallet. The API also + /// returns `invalid_currency` for currency mismatches that are not about a quote, so only + /// a detail about the quoted amount gets the quote hint. fn hint(&self) -> Option<&'static str> { match self.kind { - ApiErrorType::QuoteRequired => Some( - "A wallet on a USD line of credit needs a quote for each payment. Create one with `voltage quotes create`, wait until `voltage quotes get QUOTE_ID` shows it, then pass --quote QUOTE_ID. A wallet on a BTC line of credit needs no quote.", - ), + ApiErrorType::QuoteRequired => Some(QUOTE_HINT), + ApiErrorType::InvalidCurrency => self + .detail + .as_deref() + // The backend's quote check words both failures with "quoted amount ": + // "quoted amount X does not match requested amount Y" and "cannot validate + // quoted amount X against requested amount Y". Change both sides together. + .filter(|detail| detail.contains("quoted amount ")) + .map(|_| QUOTE_MISMATCH_HINT), ApiErrorType::FeatureFlagDisabled | ApiErrorType::MissingCredentials | ApiErrorType::Other => None, @@ -1554,6 +1571,25 @@ mod tests { assert!(hint.starts_with("A wallet on a USD line of credit needs a quote")); assert!(hint.contains("voltage quotes create")); assert!(hint.contains("BTC line of credit needs no quote")); + let mismatch = rejection(json!({"error": { + "type": "invalid_currency", + "detail": "Payment currency must be valid: quoted amount $0.01 does not match requested amount 1 sats (converted: 12.232 sats)" + }})); + assert_eq!(mismatch.hint(), Some(QUOTE_MISMATCH_HINT)); + let unvalidated = rejection(json!({"error": { + "type": "invalid_currency", + "detail": "Payment currency must be valid: cannot validate quoted amount 1 sats against requested amount $0.01: overflow" + }})); + assert_eq!(unvalidated.hint(), Some(QUOTE_MISMATCH_HINT)); + for detail in [ + Some( + "Payment currency must be valid: USD payment currency must be on a USD line of credit", + ), + None, + ] { + let other = rejection(json!({"error": {"type": "invalid_currency", "detail": detail}})); + assert_eq!(other.hint(), None); + } assert_eq!( rejection(json!({"error": {"type": "invalid_amount"}})).message(400), "Voltage API returned HTTP 400" diff --git a/src/cli.rs b/src/cli.rs index fb04b70..afcb6fd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -479,13 +479,13 @@ pub struct QuoteFlags { /// Explicit network #[arg(long, value_enum)] pub network: Option, - /// Exact decimal amount + /// Exact decimal amount to convert #[arg(long)] pub amount: Option, - /// Unit of --amount + /// Unit of --amount; its currency is the one converted from #[arg(long, value_enum)] pub unit: Option, - /// Target currency + /// Currency to convert into: the other of btc and usd #[arg(long, value_enum)] pub to: Option, } diff --git a/src/input.rs b/src/input.rs index f8493bf..34eddfa 100644 --- a/src/input.rs +++ b/src/input.rs @@ -529,14 +529,33 @@ fn payment(flags: &PaymentFlags, alias: Option, scope: &Scope) } } +/// How to get the quote a USD payment needs, before the CLI knows the wallet's line of credit. +const USD_PAYMENT_HINT: &str = "A USD payment needs a wallet on a USD line of credit and a quote for the payment. Create one with `voltage quotes create`, wait until `voltage quotes get QUOTE_ID` shows it, then pass --quote QUOTE_ID. A wallet on a BTC line of credit pays and receives in BTC: use --currency btc."; + fn quote(flags: &QuoteFlags) -> Result { let amount = require_text(flags.amount.as_deref(), "amount")?; + let line_of_credit_id = require(flags.credit_line, "credit-line")?; + let network = require(flags.network, "network")?; + let unit = require(flags.unit, "unit")?; + let to = require(flags.to, "to")?; + // A quote converts between BTC and USD, and the line of credit must be one of the two. + // With both sides in one currency there is nothing to convert, and a line in the other + // currency rejects it with `mismatch_currency`. + if unit.currency() == to { + return Err(Error::usage(format!( + "--unit {} is already in the --to currency, {to}; a quote converts between btc and usd", + unit.name() + )) + .with_hint( + "Give --amount in one currency and --to in the other. To pay a BTC invoice from a USD wallet, quote the invoice's BTC amount: --unit sats --to usd. To receive a USD amount, quote it: --unit usd --to btc.", + )); + } Ok(QuoteBody { id: new_id(flags.id), - line_of_credit_id: require(flags.credit_line, "credit-line")?, - network: require(flags.network, "network")?, - amount: Amount::parse(&amount, require(flags.unit, "unit")?)?, - to: require(flags.to, "to")?, + line_of_credit_id, + network, + amount: Amount::parse(&amount, unit)?, + to, }) } @@ -651,9 +670,10 @@ fn check_payload( )); } if payload.is_usd() && !payload.has("quote_id") { - return Err(Error::usage( - "USD payment flows require an explicit quote_id", - )); + return Err( + Error::usage("A USD payment needs --quote QUOTE_ID (quote_id in --data)") + .with_hint(USD_PAYMENT_HINT), + ); } match alias { Some(PaymentDirection::Send) if payload.has("payment_kind") => { @@ -1213,6 +1233,32 @@ mod tests { format!("--{missing} is required (or supply the complete --data payload)") ); } + for (unit, to) in [("sats", "btc"), ("cents", "usd")] { + let args = [ + "quotes", + "create", + "--credit-line", + WALLET, + "--network", + "mutinynet", + "--amount", + "1", + "--unit", + unit, + "--to", + to, + ]; + let error = build(&args).expect_err("same-currency quote"); + assert_eq!( + error.message, + format!( + "--unit {unit} is already in the --to currency, {to}; a quote converts between btc and usd" + ) + ); + let hint = error.hint.unwrap(); + assert!(hint.contains("--unit sats --to usd")); + assert!(hint.contains("--unit usd --to btc")); + } assert_eq!( request_error(&["webhooks", "test", WALLET]), "This operation requires --data @file or --data -" @@ -1261,7 +1307,7 @@ mod tests { None, json!({"id": ORG, "wallet_id": WALLET, "amount": {"currency": "usd"}}) ), - Some("USD payment flows require an explicit quote_id".into()) + Some("A USD payment needs --quote QUOTE_ID (quote_id in --data)".into()) ); assert_eq!( check( diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index e636e9c..4a18d65 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -2663,7 +2663,10 @@ async fn a_usd_wallet_needs_its_amount_unit_named_and_a_quote() { .args(¢s) .assert() .code(2); - assert!(stderr(&error).contains("USD payment flows require an explicit quote_id")); + let error = stderr(&error); + assert!(error.contains("A USD payment needs --quote QUOTE_ID (quote_id in --data)")); + assert!(error.contains("A USD payment needs a wallet on a USD line of credit and a quote")); + assert!(error.contains("use --currency btc")); server.verify().await; }