Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/guides/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 1 addition & 1 deletion docs/reference/exit-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
44 changes: 40 additions & 4 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,13 +479,13 @@ pub struct QuoteFlags {
/// Explicit network
#[arg(long, value_enum)]
pub network: Option<Network>,
/// Exact decimal amount
/// Exact decimal amount to convert
#[arg(long)]
pub amount: Option<String>,
/// Unit of --amount
/// Unit of --amount; its currency is the one converted from
#[arg(long, value_enum)]
pub unit: Option<AmountUnit>,
/// Target currency
/// Currency to convert into: the other of btc and usd
#[arg(long, value_enum)]
pub to: Option<Currency>,
}
Expand Down
62 changes: 54 additions & 8 deletions src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,14 +529,33 @@ fn payment(flags: &PaymentFlags, alias: Option<PaymentDirection>, 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<QuoteBody> {
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,
})
}

Expand Down Expand Up @@ -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") => {
Expand Down Expand Up @@ -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 -"
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2663,7 +2663,10 @@ async fn a_usd_wallet_needs_its_amount_unit_named_and_a_quote() {
.args(&cents)
.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;
}

Expand Down
Loading