diff --git a/CHANGELOG.md b/CHANGELOG.md index 110d252..ac692be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 >`. - 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 diff --git a/docs/concepts/safety-model.md b/docs/concepts/safety-model.md index 95133dd..4b6174a 100644 --- a/docs/concepts/safety-model.md +++ b/docs/concepts/safety-model.md @@ -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 diff --git a/docs/guides/payments.md b/docs/guides/payments.md index 90704c8..49bdb11 100644 --- a/docs/guides/payments.md +++ b/docs/guides/payments.md @@ -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 diff --git a/docs/reference/exit-codes.md b/docs/reference/exit-codes.md index 8a307e9..eb87fd1 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 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. diff --git a/src/api.rs b/src/api.rs index 0ab280c..c7428d6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -945,7 +945,7 @@ fn next_step(invocation: &ApiInvocation, scope: &Scope, id: Option) -> 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 diff --git a/src/error.rs b/src/error.rs index c86c2cf..105dc37 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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 @@ -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(_) => {} } } } diff --git a/src/input.rs b/src/input.rs index 34eddfa..02d6d75 100644 --- a/src/input.rs +++ b/src/input.rs @@ -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, scope: &Scope) -> Result { - 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 { diff --git a/src/lib.rs b/src/lib.rs index 81dd9b3..39b1997 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/output.rs b/src/output.rs index e92f19b..95ee613 100644 --- a/src/output.rs +++ b/src/output.rs @@ -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:"); diff --git a/src/pick.rs b/src/pick.rs index bf3a9a7..f84e205 100644 --- a/src/pick.rs +++ b/src/pick.rs @@ -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?; @@ -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. diff --git a/src/startup.rs b/src/startup.rs index eccfd74..e5db0f2 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -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; @@ -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 diff --git a/src/wallet_shortlist.rs b/src/wallet_shortlist.rs new file mode 100644 index 0000000..f87c37f --- /dev/null +++ b/src/wallet_shortlist.rs @@ -0,0 +1,712 @@ +//! A BOLT11 send without `--wallet`, where no picker is offered, names the wallets that +//! could pay instead of only reporting the missing flag. +//! +//! The invoice's prefix gives its network and amount. One wallet listing in the current scope, +//! the one the picker reads, gives each wallet's network, balances, and credit limit. A wallet +//! is shortlisted when it is active, is on the invoice's network, holds a currency the payment +//! can use, and has funds: its available balance plus its credit limit, which is what the API +//! checks when it holds a payment's amount (`available - amount >= -limit`, in the backend's +//! `Wallet::has_sufficient_balance`). A BTC balance must also cover the invoice's amount; a +//! USD balance pays through a quote, so its amount is not compared. That is a shortlist, not a +//! guarantee: fees and other line-of-credit rules are known only to the API when the payment is +//! sent. + +use crate::{ + Error, Result, api, + auth::Resolver, + cli::{ApiInvocation, BodySource, FriendlyFlags, GlobalFlags}, + config::{Scope, WALLET_HINT}, + error::ErrorDetail, + input::{WALLET_REQUIRED, check_payment}, + payment::Currency, + pick, +}; +use serde::Serialize; +use serde_json::Value; +use uuid::Uuid; + +/// What the shortlist does and does not promise, in the hint and in `--json`. +const BASIS: &str = "These wallets match the invoice's network and currency and have funds, counting their credit limit; a BTC wallet's funds also cover the invoice's amount. That is a shortlist, not a guarantee: fees and line-of-credit rules are checked only when the payment is sent."; + +pub async fn require( + invocation: &ApiInvocation, + global: &GlobalFlags, + resolver: Resolver<'_>, + scope: &Scope, +) -> Result<()> { + let Some(BodySource::Friendly(FriendlyFlags::Payment(flags))) = &invocation.body else { + return Ok(()); + }; + let Some(invoice) = flags.invoice.as_deref() else { + return Ok(()); + }; + // A picker already offers the wallets; without an organization there is nothing to list, + // and the payment reports that instead. + if scope.wallet.is_some() || scope.org.is_none() || pick::can_ask(global) { + return Ok(()); + } + // Local mistakes fail before any credential is resolved or request is sent. + check_payment(flags, invocation.alias)?; + let Some(invoice) = Invoice::parse(invoice) else { + return Err(Error::usage(WALLET_REQUIRED).with_hint(format!( + "The invoice does not start with a BOLT11 prefix (lnbc, lntb, lntbs, or lnbcrt), so no wallet can be matched to it. {WALLET_HINT}" + ))); + }; + let skip = "Or pass --wallet ID to skip listing wallets."; + let credential = resolver + .resolve_organization(scope) + .await + .map_err(|error| error.and_hint(skip))?; + let listed = api::wallets(global, credential, resolver.credential_sources, scope) + .await + .map_err(|error| error.and_hint(skip))?; + let shortlist = Shortlist::new(invoice, flags.currency, &listed, scope); + Err(Error::usage(WALLET_REQUIRED) + .with_hint(shortlist.hint()) + .with_detail(ErrorDetail::WalletShortlist(shortlist))) +} + +/// The network a BOLT11 invoice's prefix names (BOLT 11 `currency` prefixes). +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum InvoiceNetwork { + Mainnet, + Testnet, + Signet, + Regtest, +} + +impl InvoiceNetwork { + /// Longest prefix first, so `tbs` is not read as `tb` and `bcrt` not as `bc`. + const PREFIXES: [(&'static str, Self); 4] = [ + ("bcrt", Self::Regtest), + ("bc", Self::Mainnet), + ("tbs", Self::Signet), + ("tb", Self::Testnet), + ]; + + fn name(self) -> &'static str { + match self { + Self::Mainnet => "mainnet", + Self::Testnet => "testnet", + Self::Signet => "signet", + Self::Regtest => "regtest", + } + } + + /// The wallet `network` values (the API's `Network`) that pay on this network. Mutinynet is + /// a signet. The API has no regtest wallets. + fn pays(self, wallet: &str) -> bool { + match self { + Self::Mainnet => wallet == "mainnet", + Self::Testnet => wallet == "testnet", + Self::Signet => matches!(wallet, "signet" | "mutinynet"), + Self::Regtest => false, + } + } +} + +/// What an invoice's human-readable part declares. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct Invoice { + pub network: InvoiceNetwork, + /// `None` for an invoice without an amount. + pub amount_msats: Option, +} + +impl Invoice { + /// `ln`, a network prefix, and an optional amount with a multiplier, before the last `1`, + /// which bech32 data never contains. `None` when any part is malformed. + fn parse(invoice: &str) -> Option { + let lower = invoice.to_ascii_lowercase(); + let (hrp, _) = lower.rsplit_once('1')?; + let rest = hrp.strip_prefix("ln")?; + let (network, amount) = InvoiceNetwork::PREFIXES + .iter() + .find_map(|(prefix, network)| Some((*network, rest.strip_prefix(prefix)?)))?; + let amount_msats = match amount { + "" => None, + amount => Some(msats(amount)?), + }; + Some(Self { + network, + amount_msats, + }) + } +} + +/// A BOLT11 amount in msats: digits in bitcoin, optionally scaled by `m`, `u`, `n`, or `p`. +/// A pico-bitcoin amount must be a whole number of msats. +fn msats(amount: &str) -> Option { + let (digits, multiplier) = match amount.char_indices().last()? { + (at, unit @ ('m' | 'u' | 'n' | 'p')) => (&amount[..at], Some(unit)), + _ => (amount, None), + }; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let value: u64 = digits.parse().ok()?; + match multiplier { + None => value.checked_mul(100_000_000_000), + Some('m') => value.checked_mul(100_000_000), + Some('u') => value.checked_mul(100_000), + Some('n') => value.checked_mul(100), + _ => value.is_multiple_of(10).then_some(value / 10), + } +} + +/// Why a listed wallet is not shortlisted, checked in this order. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Exclusion { + Inactive, + WrongNetwork, + WrongCurrency, + NoBalance, + /// A BTC balance whose funds, counting the credit limit, are below the invoice's amount. + InsufficientBalance, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Candidate { + pub wallet_id: Uuid, + pub name: String, + pub network: Option, + /// The flags to add to the command, with the effective scope. + pub flags: String, + /// The wallet pays in USD, so the payment also needs `--quote QUOTE_ID`. + pub needs_quote: bool, + /// For a wallet that needs a quote, the command that creates it for the invoice's amount. + #[serde(skip_serializing_if = "Option::is_none")] + pub quote: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Excluded { + pub wallet_id: Uuid, + pub name: String, + pub network: Option, + pub reason: Exclusion, +} + +/// The wallets in scope that could pay an invoice, and why the others cannot. +#[derive(Clone, Debug, Serialize)] +pub struct Shortlist { + pub invoice: Invoice, + pub candidates: Vec, + pub excluded: Vec, + pub basis: &'static str, + #[serde(skip)] + currency: Option, +} + +impl Shortlist { + fn new(invoice: Invoice, currency: Option, listed: &Value, scope: &Scope) -> Self { + let scope = api::scope_flags(scope); + let items = listed + .as_array() + .or_else(|| listed.get("items").and_then(Value::as_array)) + .map(Vec::as_slice) + .unwrap_or_default(); + let mut shortlist = Self { + invoice, + candidates: Vec::new(), + excluded: Vec::new(), + basis: BASIS, + currency, + }; + for wallet in items { + let Some(wallet_id) = wallet + .get("id") + .and_then(Value::as_str) + .and_then(|id| id.parse().ok()) + else { + continue; + }; + let name = text(wallet, "name").unwrap_or("-").to_owned(); + let network = text(wallet, "network").map(str::to_owned); + match shortlist.qualify(wallet) { + Ok(currency) => { + let needs_quote = currency != "btc"; + let quote = needs_quote.then(|| shortlist.quote(wallet, &scope)); + shortlist.candidates.push(Candidate { + wallet_id, + name, + network, + flags: format!("--wallet {wallet_id}{scope}"), + needs_quote, + quote, + }); + } + Err(reason) => shortlist.excluded.push(Excluded { + wallet_id, + name, + network, + reason, + }), + } + } + shortlist + } + + /// The currency a wallet would pay in, BTC when it can, or why it cannot pay. + fn qualify<'a>(&self, wallet: &'a Value) -> std::result::Result<&'a str, Exclusion> { + if wallet.get("active").and_then(Value::as_bool) == Some(false) { + return Err(Exclusion::Inactive); + } + if !text(wallet, "network").is_some_and(|network| self.invoice.network.pays(network)) { + return Err(Exclusion::WrongNetwork); + } + let balances = wallet + .get("balances") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + let usable: Vec<&Value> = balances + .iter() + .filter(|balance| text(balance, "currency").is_some_and(|c| self.usable(c))) + .collect(); + if usable.is_empty() && !balances.is_empty() { + return Err(Exclusion::WrongCurrency); + } + let limit = wallet.get("limit").and_then(Value::as_u64).unwrap_or(0); + let funded: Vec<(&str, i128)> = usable + .iter() + .filter_map(|balance| Some((text(balance, "currency")?, funds(balance, limit)?))) + .filter(|(_, funds)| *funds > 0) + .collect(); + if funded.is_empty() { + return Err(Exclusion::NoBalance); + } + // Only a BTC balance is in the invoice's unit; a USD balance pays through a quote. + let covers = |(currency, funds): &&(&str, i128)| { + *currency != "btc" + || self + .invoice + .amount_msats + .is_none_or(|msats| *funds >= i128::from(msats)) + }; + funded + .iter() + .filter(covers) + .min_by_key(|(currency, _)| *currency != "btc") + .map(|(currency, _)| *currency) + .ok_or(Exclusion::InsufficientBalance) + } + + /// The quote a USD wallet needs to pay the invoice: its BTC amount converted to USD. + fn quote(&self, wallet: &Value, scope: &str) -> String { + let credit_line = text(wallet, "line_of_credit_id").unwrap_or("CREDIT_LINE_ID"); + let network = text(wallet, "network").unwrap_or("NETWORK"); + let amount = match self.invoice.amount_msats { + Some(msats) if msats.is_multiple_of(1000) => format!("{} --unit sats", msats / 1000), + Some(msats) => format!("{msats} --unit msats"), + None => "AMOUNT --unit sats".to_owned(), + }; + format!( + "voltage quotes create --credit-line {credit_line} --network {network} --amount {amount} --to usd{scope} --execute" + ) + } + + /// A currency the payment can be sent in: the `--currency` one, or else any the flags name. + fn usable(&self, currency: &str) -> bool { + match self.currency { + Some(wanted) => currency == wanted.to_string(), + None => matches!(currency, "btc" | "usd"), + } + } + + fn currencies(&self) -> String { + self.currency + .map_or_else(|| "btc or usd".to_owned(), |currency| currency.to_string()) + } + + fn hint(&self) -> String { + let network = self.invoice.network.name(); + if !self.candidates.is_empty() { + let picks: Vec = self + .candidates + .iter() + .map(|candidate| match &candidate.quote { + Some(quote) => format!( + "`{}` ({}, a USD wallet: it needs a quote, so create one with `{quote}` and add --quote QUOTE_ID)", + candidate.flags, candidate.name + ), + None => format!("`{}` ({})", candidate.flags, candidate.name), + }) + .collect(); + return format!( + "{BASIS} Invoice network: {network}. Add one to the command: {}.", + picks.join(", ") + ); + } + if self.excluded.is_empty() { + return "No wallets are in this scope. Create one with `voltage wallets create`, or choose another scope with --org and --env.".into(); + } + let reasons: Vec = self + .excluded + .iter() + .map(|excluded| { + let why = match excluded.reason { + Exclusion::Inactive => "is inactive".to_owned(), + Exclusion::WrongNetwork => format!( + "is on {}", + excluded.network.as_deref().unwrap_or("an unknown network") + ), + Exclusion::WrongCurrency => format!("holds no {}", self.currencies()), + Exclusion::NoBalance => format!("has no {} funds", self.currencies()), + Exclusion::InsufficientBalance => { + "has too little btc for the invoice's amount".to_owned() + } + }; + format!("{} ({}) {why}", excluded.name, excluded.wallet_id) + }) + .collect(); + format!( + "No wallet in this scope is active on {network} with {} funds: {}. Fund a matching wallet, create one with `voltage wallets create`, or choose another scope with --org and --env.", + self.currencies(), + reasons.join("; ") + ) + } +} + +/// A balance's funds in its base unit (msats or cents): its signed available amount plus the +/// wallet's credit limit, which the API lets a payment draw down to. +fn funds(balance: &Value, limit: u64) -> Option { + let available = &balance["available"]; + let amount = i128::from(available["amount"].as_u64()?); + let signed = if available["negative"] == Value::Bool(true) { + -amount + } else { + amount + }; + Some(signed + i128::from(limit)) +} + +fn text<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const A: &str = "11111111-1111-4111-8111-111111111111"; + const B: &str = "22222222-2222-4222-8222-222222222222"; + + fn invoice(text: &str) -> Option { + Invoice::parse(text) + } + + #[test] + fn invoice_prefixes_name_their_network_and_amount() { + let at = |network, amount_msats| { + Some(Invoice { + network, + amount_msats, + }) + }; + assert_eq!(invoice("lnbc1"), at(InvoiceNetwork::Mainnet, None)); + assert_eq!( + invoice("lnbc2500u1pvjluez"), + at(InvoiceNetwork::Mainnet, Some(250_000_000)) + ); + assert_eq!( + invoice("LNTBS10N1QQQ"), + at(InvoiceNetwork::Signet, Some(1_000)) + ); + assert_eq!( + invoice("lntb20m1pvjluez"), + at(InvoiceNetwork::Testnet, Some(2_000_000_000)) + ); + assert_eq!(invoice("lnbcrt1"), at(InvoiceNetwork::Regtest, None)); + assert_eq!( + invoice("lnbcrt10p1qqq"), + at(InvoiceNetwork::Regtest, Some(1)) + ); + assert_eq!( + invoice("lnbc2m1qqq"), + at(InvoiceNetwork::Mainnet, Some(200_000_000)) + ); + assert_eq!(invoice("lnbc3"), None, "no separator before the data part"); + assert_eq!( + invoice("lnbc11qqq"), + at(InvoiceNetwork::Mainnet, Some(100_000_000_000)) + ); + } + + #[test] + fn malformed_invoices_are_not_matched() { + for text in [ + "", + "bc1qaddress", + "lnxx1qqq", + "lnbc", + "lnbcm1qqq", + "lnbc2x1qqq", + "lnbc1p1qqq", + "lnbc99999999999999999991qqq", + ] { + assert_eq!(invoice(text), None, "{text}"); + } + } + + fn wallet(id: &str, network: &str, balances: Value) -> Value { + json!({"id": id, "name": "w", "active": true, "network": network, "balances": balances}) + } + + fn balance(currency: &str, amount: u64, negative: bool) -> Value { + json!({"currency": currency, "available": {"amount": amount, "negative": negative}}) + } + + fn reasons(listed: Value, currency: Option) -> (Vec, Vec) { + let scope = Scope::default(); + let shortlist = Shortlist::new(invoice("lntbs10n1qqq").unwrap(), currency, &listed, &scope); + ( + shortlist.candidates.iter().map(|c| c.wallet_id).collect(), + shortlist.excluded.iter().map(|e| e.reason).collect(), + ) + } + + #[test] + fn wallets_are_excluded_by_activity_network_currency_then_balance() { + let funded = json!([balance("btc", 1_000, false)]); + let mut inactive = wallet(A, "mutinynet", funded.clone()); + inactive["active"] = json!(false); + assert_eq!( + reasons(json!([inactive]), None), + (vec![], vec![Exclusion::Inactive]) + ); + assert_eq!( + reasons(json!([wallet(A, "mainnet", funded.clone())]), None), + (vec![], vec![Exclusion::WrongNetwork]) + ); + assert_eq!( + reasons(json!([{"id": A, "balances": funded}]), None), + (vec![], vec![Exclusion::WrongNetwork]) + ); + assert_eq!( + reasons( + json!([wallet(A, "signet", funded.clone())]), + Some(Currency::Usd) + ), + (vec![], vec![Exclusion::WrongCurrency]) + ); + let asset = json!([balance("asset:00", 5, false)]); + assert_eq!( + reasons(json!([wallet(A, "signet", asset)]), None), + (vec![], vec![Exclusion::WrongCurrency]) + ); + for empty in [ + json!([]), + json!([balance("btc", 0, false)]), + json!([balance("btc", 5, true)]), + ] { + assert_eq!( + reasons(json!([wallet(A, "signet", empty.clone())]), None), + (vec![], vec![Exclusion::NoBalance]), + "{empty}" + ); + } + let usd = json!([balance("usd", 5, false)]); + assert_eq!( + reasons( + json!([ + wallet(A, "mutinynet", usd.clone()), + wallet(B, "signet", funded) + ]), + None + ), + (vec![uuid(A), uuid(B)], vec![]) + ); + // A listing in a page, and an item without a UUID, are read as the picker reads them. + assert_eq!( + reasons( + json!({"items": [{"id": "x"}, wallet(A, "signet", usd)]}), + Some(Currency::Usd) + ), + (vec![uuid(A)], vec![]) + ); + } + + #[test] + fn funds_count_the_credit_limit_and_a_btc_wallet_must_cover_the_amount() { + let with_limit = |available: Value, limit: u64| { + let mut listed = wallet(A, "signet", json!([available])); + listed["limit"] = json!(limit); + json!([listed]) + }; + // The invoice is 1,000 msats. Credit counts as funds, as the API's hold check does. + assert_eq!( + reasons(with_limit(balance("btc", 0, false), 1_000), None), + (vec![uuid(A)], vec![]) + ); + assert_eq!( + reasons(with_limit(balance("btc", 500, true), 1_500), None), + (vec![uuid(A)], vec![]) + ); + // Credit already drawn down to its limit leaves nothing. + assert_eq!( + reasons(with_limit(balance("btc", 2_000, true), 2_000), None), + (vec![], vec![Exclusion::NoBalance]) + ); + // Funds below the invoice's amount. + assert_eq!( + reasons(with_limit(balance("btc", 400, false), 500), None), + (vec![], vec![Exclusion::InsufficientBalance]) + ); + // A USD balance pays through a quote, so its amount is not compared with the invoice. + assert_eq!( + reasons( + json!([wallet(A, "signet", json!([balance("usd", 1, false)]))]), + None + ), + (vec![uuid(A)], vec![]) + ); + // With both, a BTC balance that covers the amount pays without a quote. + let both = json!([balance("usd", 5, false), balance("btc", 1_000, false)]); + let scope = Scope::default(); + let shortlist = Shortlist::new( + invoice("lntbs10n1qqq").unwrap(), + None, + &json!([wallet(A, "signet", both)]), + &scope, + ); + assert!(!shortlist.candidates[0].needs_quote); + // An invoice without an amount is covered by any funds. + let open = Shortlist::new( + invoice("lntbs1qqq").unwrap(), + None, + &json!([wallet(A, "signet", json!([balance("btc", 1, false)]))]), + &scope, + ); + assert_eq!(open.candidates.len(), 1); + } + + #[test] + fn a_usd_candidate_needs_a_quote_for_the_invoices_amount() { + let scope = Scope { + org: Some(uuid(B)), + ..Scope::default() + }; + let mut usd = wallet(A, "mutinynet", json!([balance("usd", 5, false)])); + usd["line_of_credit_id"] = json!("LOC"); + let quoted = |text: &str, listed: &Value| { + let shortlist = Shortlist::new(invoice(text).unwrap(), None, listed, &scope); + let candidate = &shortlist.candidates[0]; + assert!(candidate.needs_quote); + (candidate.quote.clone().unwrap(), shortlist.hint()) + }; + let (quote, hint) = quoted("lntbs10n1qqq", &json!([usd.clone()])); + assert_eq!( + quote, + format!( + "voltage quotes create --credit-line LOC --network mutinynet --amount 1 --unit sats --to usd --org {B} --execute" + ) + ); + assert!(hint.contains("it needs a quote"), "{hint}"); + assert!(hint.contains("e), "{hint}"); + assert!(hint.contains("add --quote QUOTE_ID"), "{hint}"); + let (quote, _) = quoted("lntbs10p1qqq", &json!([usd.clone()])); + assert!(quote.contains("--amount 1 --unit msats"), "{quote}"); + let (quote, _) = quoted("lntbs1qqq", &json!([usd])); + assert!(quote.contains("--amount AMOUNT --unit sats"), "{quote}"); + let bare = json!([{"id": A, "name": "w", "balances": [balance("usd", 5, false)], "network": "signet"}]); + let (quote, _) = quoted("lntbs1qqq", &bare); + assert!( + quote.contains("--credit-line CREDIT_LINE_ID --network signet"), + "{quote}" + ); + let btc = Shortlist::new( + invoice("lntbs1qqq").unwrap(), + None, + &json!([wallet(A, "signet", json!([balance("btc", 1, false)]))]), + &scope, + ); + assert_eq!( + serde_json::to_value(&btc).unwrap()["candidates"][0], + json!({"wallet_id": A, "name": "w", "network": "signet", "flags": format!("--wallet {A} --org {B}"), "needs_quote": false}) + ); + } + + fn uuid(text: &str) -> Uuid { + text.parse().unwrap() + } + + #[test] + fn hints_name_the_flags_to_paste_or_why_no_wallet_qualifies() { + let scope = Scope { + org: Some(uuid(B)), + ..Scope::default() + }; + let signet = || invoice("lntbs10n1qqq").unwrap(); + let mut named = wallet(A, "signet", json!([balance("btc", 1_000, false)])); + named["name"] = json!("ops"); + let found = Shortlist::new(signet(), None, &json!([named]), &scope); + let hint = found.hint(); + assert!(hint.starts_with(BASIS), "{hint}"); + assert!( + hint.contains(&format!("`--wallet {A} --org {B}` (ops)")), + "{hint}" + ); + assert_eq!( + serde_json::to_value(&found).unwrap()["candidates"][0]["flags"], + format!("--wallet {A} --org {B}") + ); + + let empty = Shortlist::new(signet(), None, &json!([]), &scope); + assert!(empty.hint().starts_with("No wallets are in this scope.")); + + let mut inactive = wallet(B, "signet", json!([])); + inactive["active"] = json!(false); + let listed = json!([ + wallet(A, "mainnet", json!([])), + {"id": A, "name": "n"}, + wallet(A, "signet", json!([balance("btc", 1_000, false)])), + wallet(A, "signet", json!([])), + inactive, + ]); + let none = Shortlist::new(signet(), Some(Currency::Usd), &listed, &scope).hint(); + assert!( + none.starts_with("No wallet in this scope is active on signet with usd funds: "), + "{none}" + ); + for part in [ + format!("w ({A}) is on mainnet"), + format!("n ({A}) is on an unknown network"), + format!("w ({A}) holds no usd"), + format!("w ({A}) has no usd funds"), + format!("w ({B}) is inactive"), + ] { + assert!(none.contains(&part), "{part}: {none}"); + } + let any = Shortlist::new( + signet(), + None, + &json!([wallet(A, "signet", json!([]))]), + &scope, + ); + assert!(any.hint().contains("has no btc or usd funds")); + } + + #[test] + fn the_shortlist_keeps_its_documented_shape() { + let listed = json!([wallet(A, "signet", json!([]))]); + let shortlist = Shortlist::new( + invoice("lnbc10u1qqq").unwrap(), + None, + &listed, + &Scope::default(), + ); + assert_eq!( + serde_json::to_value(&shortlist).unwrap(), + json!({ + "invoice": {"network": "mainnet", "amount_msats": 1_000_000}, + "candidates": [], + "excluded": [{"wallet_id": A, "name": "w", "network": "signet", "reason": "wrong_network"}], + "basis": BASIS, + }) + ); + } +} diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 35f4dbf..21d0992 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -2810,6 +2810,209 @@ async fn serve_line_of_credit( respond(server, route("GET", summary_path), summary, times).await; } +#[tokio::test(flavor = "multi_thread")] +async fn a_bolt11_send_without_a_wallet_shortlists_the_wallets_that_could_pay() { + let (server, dir) = fixture().await; + const USD: &str = "55555555-5555-4555-8555-555555555555"; + const MAINNET: &str = "66666666-6666-4666-8666-666666666666"; + const SHORT: &str = "77777777-7777-4777-8777-777777777777"; + let funded = |currency: &str| json!([{"currency": currency, "available": {"amount": 5_000, "negative": false}}]); + Mock::given(method("GET")) + .and(path(wallets_path(ORG))) + .and(query_param("environment_id", ENV)) + .respond_with(ok(json!([ + {"id": WALLET, "name": "ops", "active": true, "network": "mutinynet", + "balances": funded("btc")}, + {"id": USD, "name": "payroll", "active": true, "network": "signet", + "balances": funded("usd")}, + {"id": MAINNET, "name": "treasury", "active": true, "network": "mainnet", + "balances": funded("btc")}, + {"id": RESOURCE, "name": "empty", "active": true, "network": "mutinynet", + "balances": [{"currency": "btc", "available": {"amount": 9, "negative": true}}]}, + {"id": SHORT, "name": "short", "active": true, "network": "signet", "limit": 500, + "balances": [{"currency": "btc", "available": {"amount": 400, "negative": false}}]} + ]))) + .mount(&server) + .await; + let send = |extra: &[&str]| { + let mut args = vec![ + "payments", + "send", + "--org", + ORG, + "--env", + ENV, + "--invoice", + "lntbs10n1qqq", + ]; + args.extend_from_slice(extra); + cli(dir.path(), &server) + .env_remove("VOLTAGE_EXECUTE") + .args(args) + .assert() + }; + let listings = || async { + server + .received_requests() + .await + .unwrap() + .iter() + .filter(|request| request.url.path() == wallets_path(ORG)) + .count() + }; + + // Without --currency, a BTC and a USD wallet on the invoice's signet both qualify; the + // others say why not. A description lists the wallets as a real run would. + let error = json_error(&send(&[]).code(2)); + assert_eq!( + error["error"]["message"], + "--wallet is required for payment creation" + ); + let detail = &error["error"]["detail"]; + assert_eq!( + detail["invoice"], + json!({"network": "signet", "amount_msats": 1000}) + ); + let flags = |id: &str| format!("--wallet {id} --org {ORG} --env {ENV}"); + assert_eq!( + detail["candidates"], + json!([ + {"wallet_id": WALLET, "name": "ops", "network": "mutinynet", "flags": flags(WALLET), + "needs_quote": false}, + {"wallet_id": USD, "name": "payroll", "network": "signet", "flags": flags(USD), + "needs_quote": true, + "quote": format!("voltage quotes create --credit-line CREDIT_LINE_ID --network signet --amount 1 --unit sats --to usd --org {ORG} --env {ENV} --execute")} + ]) + ); + assert_eq!( + detail["excluded"], + json!([ + {"wallet_id": MAINNET, "name": "treasury", "network": "mainnet", "reason": "wrong_network"}, + {"wallet_id": RESOURCE, "name": "empty", "network": "mutinynet", "reason": "no_balance"}, + {"wallet_id": SHORT, "name": "short", "network": "signet", "reason": "insufficient_balance"} + ]) + ); + assert!( + detail["basis"] + .as_str() + .unwrap() + .contains("a shortlist, not a guarantee") + ); + let hint = error["error"]["hint"].as_str().unwrap(); + assert!( + hint.contains(&format!("`{}` (ops)", flags(WALLET))), + "{hint}" + ); + assert_eq!(listings().await, 1); + + // --currency usd leaves only the USD wallet. + let error = json_error(&send(&["--currency", "usd"]).code(2)); + let candidates = error["error"]["detail"]["candidates"].as_array().unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0]["wallet_id"], USD); + assert_eq!(listings().await, 2); + + // When none qualifies, human output names each wallet's reason in the hint. + let human = Command::from_std(human_process_at(dir.path(), &server.uri())) + .env_remove("VOLTAGE_EXECUTE") + .args([ + "payments", + "send", + "--org", + ORG, + "--env", + ENV, + "--currency", + "usd", + "--invoice", + "lnbc10u1qqq", + "--output", + "table", + ]) + .assert() + .code(2); + let shown = stderr(&human); + assert!( + shown.contains("No wallet in this scope is active on mainnet with usd funds"), + "{shown}" + ); + assert!( + shown.contains(&format!("treasury ({MAINNET}) holds no usd")), + "{shown}" + ); + assert!( + shown.contains(&format!("ops ({WALLET}) is on mutinynet")), + "{shown}" + ); + // The hint already gives the shortlist, so human output adds no details block. + assert!(!shown.contains("details:"), "{shown}"); + assert_eq!(listings().await, 3); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bolt11_send_without_a_wallet_fails_before_listing_when_it_cannot_shortlist() { + let (server, dir) = fixture().await; + let send = |args: &[&str]| { + let mut full = vec!["payments", "send", "--env", ENV]; + full.extend_from_slice(args); + cli(dir.path(), &server) + .env_remove("VOLTAGE_EXECUTE") + .args(full) + .assert() + }; + + // An invoice without a BOLT11 prefix cannot be matched, so nothing is listed. + let error = json_error(&send(&["--org", ORG, "--invoice", "bitcoin:bc1q"]).code(2)); + assert_eq!( + error["error"]["message"], + "--wallet is required for payment creation" + ); + assert!( + error["error"]["hint"] + .as_str() + .unwrap() + .starts_with("The invoice does not start with a BOLT11 prefix") + ); + // A local mistake is reported first. + let error = json_error(&send(&["--org", ORG, "--invoice", "lnbc1", "--unit", "sats"]).code(2)); + assert_eq!(error["error"]["message"], "--unit requires --amount"); + // Without an organization there is nothing to list; the payment reports what is missing. + let error = json_error(&send(&["--invoice", "lnbc1"]).code(2)); + assert!(error["error"]["detail"].is_null(), "{error}"); + // An on-chain send is not shortlisted. + let error = json_error(&send(&["--org", ORG, "--address", "bc1q"]).code(2)); + assert_eq!( + error["error"]["message"], + "--wallet is required for payment creation" + ); + assert!(error["error"]["detail"].is_null()); + assert!(server.received_requests().await.unwrap().is_empty()); + + // Without a credential, or when the listing fails, the error says how to skip listing. + let missing = cli(dir.path(), &server) + .env_remove("VOLTAGE_EXECUTE") + .env_remove("VOLTAGE_API_KEY") + .args([ + "payments", + "send", + "--org", + ORG, + "--env", + ENV, + "--invoice", + "lnbc1", + ]) + .assert() + .code(3); + assert!(stderr(&missing).contains("Or pass --wallet ID to skip listing wallets.")); + route("GET", wallets_path(ORG)) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + let failed = send(&["--org", ORG, "--invoice", "lnbc1"]).failure(); + assert!(stderr(&failed).contains("Or pass --wallet ID to skip listing wallets.")); +} + #[tokio::test] async fn a_wallet_whose_currency_cannot_be_read_fails_and_names_the_flag() { let (server, dir) = fixture().await; @@ -4505,7 +4708,8 @@ async fn a_terminal_picks_a_missing_wallet_and_scripts_are_never_asked() { assert_eq!(interrupted.modes.0, interrupted.modes.1); assert_eq!(listings().await, 3); - // JSON output and --no-input neither ask nor list. + // JSON output and --no-input never ask. Without a picker, the BOLT11 send lists the + // wallets once to say which could pay; these report no network, so none qualifies. for extra in ["--json", "--no-input"] { let mut args = send.to_vec(); args.push(extra); @@ -4516,9 +4720,10 @@ async fn a_terminal_picks_a_missing_wallet_and_scripts_are_never_asked() { .contains("--wallet is required for payment creation"), "{extra}" ); + assert!(run.shown.contains("is on an unknown network"), "{extra}"); assert!(!run.shown.contains("Wallet:"), "{extra}"); } - assert_eq!(listings().await, 3); + assert_eq!(listings().await, 5); // An empty list says so instead of opening the picker. Mock::given(method("GET"))