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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- A command that takes a resource ID as an argument also accepts `--id ID`, so the flag that `quotes create` and `payments send` take works there too. It takes one of the two, and its usage line shows both, as in `voltage quotes get <QUOTE_ID|--id <ID>>`.
- In human output, an accepted `payments receive` or `quotes create` names the command that shows the invoice, address, or quote, with the organization, environment, and account that the command used.
- A 403 to `VOLTAGE_API_KEY` says that the key came from that variable and where the organization came from, and suggests `--account NAME` or unsetting the variable. `--json` reports both as `error.detail.credential_sources`.
- Without a picker, `payments send --invoice` without `--wallet` names the wallets in scope that match the invoice's network and currency and have funds, counting their credit limit, with `--wallet ID` and the scope flags ready to paste, or says why each wallet does not qualify. A BTC wallet's funds must cover the invoice's amount, and a USD wallet comes with the quote it needs. `--json` reports them in `error.detail`. The list is a shortlist, not a guarantee.
- The wallet picker shows a wallet's currency when its balances report one, and typing a currency filters on it. A wallet with no balance yet shows no currency.

### Changed
Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/safety-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ voltage wallets delete WALLET_ID --profile prod

The description reports the method, URL, query, `Origin` header, and body, plus a `run` object with what a real run would do next: whether it verifies the wallet's environment, the ID it would journal for recovery, whether a failed payment submission is reconciled, and what it waits for. It reports the same `resource_id` a real run would use, so chained commands see the same IDs in both modes. The result is a normal envelope with `outcome: dry_run` and no `http_status`.

Describing a change makes no request for the change itself (including the wallet environment check a real run performs), writes no journal entry, and does not create `--output-file`. It authenticates nothing, with one exception: a payment without `--currency` reads its wallet's currency, as a real run would, so the description shows the same body; pass `--currency` to keep it offline. On a terminal, a missing organization, environment, or wallet can still open a [picker](../guides/login-and-profiles.md#pick-the-scope-interactively), which lists choices from the API; pass the scope, `--no-input`, or `--json` to stay offline. Body and metadata-filter values are replaced with `[OMITTED]` unless the field holds only identifiers, enums, or amounts; `--show-secrets` shows them. `--data -` still reads stdin, and a friendly create generates a fresh ID each run unless you pass `--id`. Passing local validation does not mean the API will accept the request.
Describing a change makes no request for the change itself (including the wallet environment check a real run performs), writes no journal entry, and does not create `--output-file`. It authenticates nothing, with two exceptions. A payment without `--currency` reads its wallet's currency, as a real run would, so the description shows the same body; pass `--currency` to keep it offline. And a BOLT11 `payments send` without `--wallet`, where no picker is offered (as with `--json` or `--no-input`), lists the scope's wallets to [name those that could pay](../guides/payments.md#choose-a-wallet-for-an-invoice), then fails; pass `--wallet` to keep it offline. On a terminal, a missing organization, environment, or wallet can still open a [picker](../guides/login-and-profiles.md#pick-the-scope-interactively), which lists choices from the API; pass the scope, `--no-input`, or `--json` to stay offline. Body and metadata-filter values are replaced with `[OMITTED]` unless the field holds only identifiers, enums, or amounts; `--show-secrets` shows them. `--data -` still reads stdin, and a friendly create generates a fresh ID each run unless you pass `--id`. Passing local validation does not mean the API will accept the request.

## Payment IDs and the request journal

Expand Down
13 changes: 13 additions & 0 deletions docs/guides/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ voltage payments send --profile prod --wallet WALLET_ID \

Amounts are decimals in the unit you name with `--unit` (`msats`, `sats`, `btc`, `cents`, `usd`) and are converted to the API's integer base units exactly; a value with more precision than the unit allows is rejected. Without `--unit`, an amount is in the currency's base unit: msats for BTC and cents for USD, so `--amount 1000` is 1 sat. `--max-fee` without `--fee-unit` is in msats. `--currency` is the wallet currency for sends and the receive currency for open-amount receives. Without it, the payment reads the wallet and uses its line of credit's currency. That read needs a credential, also when the change is only described, and stderr says which currency was used. A `--unit` in another currency is then rejected, and a USD wallet's amount needs `--unit`: a cent is worth more than a sat, so an amount meant in sats would overpay. A wallet that holds an asset needs the complete body with `--data`. Fee limits cover network and provider fees only. A send notes on stderr that its fee limit excludes processing fees.

## Choose a wallet for an invoice

On a terminal, a payment without `--wallet` offers a wallet picker. Elsewhere, such as with `--json`, `--no-input`, or in a script, `payments send --invoice` without `--wallet` lists the organization's wallets (in the `--env` environment when one is given) and names those that could pay. A wallet qualifies when it is active, is on the invoice's network, holds the `--currency` currency (or BTC or USD without `--currency`), and has funds: its available balance plus its credit limit (`limit`) is positive, since the API lets a payment draw the balance down to minus the limit. A BTC balance's funds must also cover the invoice's amount. A USD balance pays through a quote, so its amount is not compared, and the candidate has `needs_quote: true` and a `quote` command that converts the invoice's amount with `--unit sats --to usd`; add `--quote QUOTE_ID` to the send. The invoice prefix gives the network: `lnbc` is mainnet, `lntb` testnet, `lntbs` signet, which includes mutinynet, and `lnbcrt` regtest, which no wallet uses. The error then exits with code 2. Its hint gives `--wallet ID` with the scope flags for each qualifying wallet, or says why each wallet does not qualify. `--json` reports the same in `error.detail`:

```json
{"invoice": {"network": "signet", "amount_msats": 1000},
"candidates": [{"wallet_id": "WALLET_ID", "name": "ops", "network": "mutinynet", "flags": "--wallet WALLET_ID --org ORG_ID", "needs_quote": false}],
"excluded": [{"wallet_id": "OTHER_ID", "name": "treasury", "network": "mainnet", "reason": "wrong_network"}],
"basis": "These wallets match the invoice's network and currency and have a balance. ..."}
```

`reason` is `inactive`, `wrong_network`, `wrong_currency`, `no_balance`, or `insufficient_balance` (a BTC wallet whose funds are below the invoice's amount). The list is a shortlist, not a guarantee: fees and line-of-credit rules are checked only when the payment is sent. Listing the wallets needs a credential, also when the change is only described. An on-chain send without `--wallet` reports only the missing flag.

Run a change without `--execute` to see exactly what it would send, then add `--execute` to send it. There is no confirmation prompt. See the [safety model](../concepts/safety-model.md).

## Wait for a payment
Expand Down
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 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 403 to `VOLTAGE_API_KEY` instead names that variable and where the organization came from, and suggests `--account NAME` or unsetting the variable; `--json` reports both sources as `error.detail.credential_sources`. 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.
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 wallets that could pay a BOLT11 invoice for `payments send --invoice` without `--wallet` (in `error.detail` with `--json`), the saved credential and profile names for an unknown or ambiguous selection, `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 403 to `VOLTAGE_API_KEY` instead names that variable and where the organization came from, and suggests `--account NAME` or unsetting the variable; `--json` reports both sources as `error.detail.credential_sources`. 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.
2 changes: 1 addition & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,7 @@ fn next_step(invocation: &ApiInvocation, scope: &Scope, id: Option<Uuid>) -> Opt

/// The effective scope as flags, so a command in a hint reaches the same organization,
/// environment, and saved credential however the original command selected them.
fn scope_flags(scope: &Scope) -> String {
pub(crate) fn scope_flags(scope: &Scope) -> String {
let org = scope.org.map(|org| format!(" --org {org}"));
let envs = scope.envs.iter().map(|env| format!(" --env {env}"));
let account = scope
Expand Down
13 changes: 12 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ pub enum ErrorDetail {
Credential {
credential_problem: CredentialRejection,
},
/// A BOLT11 send without `--wallet`: the wallets in scope that could pay, and why the
/// others cannot.
WalletShortlist(crate::wallet_shortlist::Shortlist),
}

/// Why the auth service rejected a saved credential. `voltage context` reads no secret and
Expand Down Expand Up @@ -129,11 +132,19 @@ impl ErrorDetail {
}
}

/// A detail the hint already gives in prose, which human output leaves out.
pub fn repeats_hint(&self) -> bool {
matches!(self, Self::WalletShortlist(_))
}

/// API payloads embedded in a detail can echo credentials; scrub them before reporting.
pub fn redact(&mut self, secrets: &[&str]) {
match self {
Self::Http { data, .. } | Self::Payment(data) => redact(data, secrets),
Self::UncertainSubmission { .. } | Self::Pending { .. } | Self::Credential { .. } => {}
Self::UncertainSubmission { .. }
| Self::Pending { .. }
| Self::Credential { .. }
| Self::WalletShortlist(_) => {}
}
}
}
Expand Down
9 changes: 6 additions & 3 deletions src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,10 +515,13 @@ fn send_body(
})?)
}

/// The error for a payment without a wallet, which a BOLT11 send extends with a shortlist.
pub(crate) const WALLET_REQUIRED: &str = "--wallet is required for payment creation";

fn payment(flags: &PaymentFlags, alias: Option<PaymentDirection>, scope: &Scope) -> Result<Value> {
let wallet = scope.wallet.ok_or_else(|| {
Error::usage("--wallet is required for payment creation").with_hint(WALLET_HINT)
})?;
let wallet = scope
.wallet
.ok_or_else(|| Error::usage(WALLET_REQUIRED).with_hint(WALLET_HINT))?;
let checked = checked_payment(flags, alias)?;
let currency = require(flags.currency, "currency")?;
match checked {
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod secret;
mod startup;
mod terminal;
mod wallet_currency;
mod wallet_shortlist;

pub(crate) use error::{Error, Result};
pub use startup::run;
5 changes: 4 additions & 1 deletion src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,10 @@ pub fn report_error(error: &Error, format: OutputFormat) {
// Hints can name saved credentials and profiles, so they are scrubbed like messages.
eprintln!("hint: {}", scrub_line(hint));
}
if let Some(detail) = human_detail(&body["error"]["detail"], &error.message) {
let repeated = error.detail.as_ref().is_some_and(ErrorDetail::repeats_hint);
if let Some(detail) =
human_detail(&body["error"]["detail"], &error.message).filter(|_| !repeated)
{
let mut lines = Vec::new();
if human::detail(&mut lines, &detail).is_ok() {
eprintln!("details:");
Expand Down
11 changes: 7 additions & 4 deletions src/pick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,7 @@ pub async fn fill(
wallet: needs.wallet && scope.wallet.is_none(),
};
let terminal = global.terminal();
if missing == Needs::default()
|| global.output_format() != OutputFormat::Table
|| !terminal.can_pick()
{
if missing == Needs::default() || !can_ask(global) {
return Ok(());
}
let picked = ask(missing, global, resolver, scope).await?;
Expand All @@ -109,6 +106,12 @@ pub async fn fill(
Ok(())
}

/// Whether a missing scope is picked rather than reported: only for table output on an
/// interactive terminal without `--no-input`.
pub fn can_ask(global: &GlobalFlags) -> bool {
global.output_format() == OutputFormat::Table && global.terminal().can_pick()
}

/// The pickers themselves, with one credential resolution and at most one organization token
/// exchange. An API key cannot list organizations or environments, so only its wallets are
/// offered. Returns the flags for what was picked, which Esc or an empty list can cut short.
Expand Down
3 changes: 2 additions & 1 deletion src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::{
pick,
price::{PRICE_URL, PriceService},
terminal::Terminal,
wallet_currency,
wallet_currency, wallet_shortlist,
};
use serde::Serialize;
use std::process::ExitCode;
Expand Down Expand Up @@ -119,6 +119,7 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result
// `VOLTAGE_EXECUTE` and every local check run before the wallet is read.
let execution = Execution::resolve(&api, config::execute_variable()?);
let resolver = session.resolver();
wallet_shortlist::require(&api, &global, resolver, &session.scope).await?;
wallet_currency::fill(&mut api, &global, resolver, &session.scope).await?;
let mut out = output(!execution.describes())?;
api::execute(&api, execution, &global, resolver, &session.scope, &mut out).await
Expand Down
Loading
Loading