From d8b58c578549b78a400f03b20ba7a8e038d67893 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:58:50 -0500 Subject: [PATCH] feat(cli): default a payment's currency to its wallet's, and units to base units A friendly payment without --currency now uses its wallet's currency. A receive with --amount and --unit takes the unit's currency without any request. Otherwise the CLI reads the wallet for its line of credit, then the line of credit's summary, and uses that currency, which is the one that decides whether a payment needs a quote. A wallet has no currency field of its own, so this takes two reads. Stderr names the currency used. An explicit --currency is never replaced and needs no credential. The read happens in a description as in a real run, so both show the same body; it is the one request a described change makes, and passing --currency keeps the description offline. Without a credential, the error keeps its own hint and adds "Or pass --currency to skip reading the wallet." An amount without --unit is in the currency's base unit (msats or cents), and --max-fee without --fee-unit is in msats. The base unit is the smallest, so a misread amount errs toward too little: a smaller invoice, a smaller send, or a fee limit that fails the payment. Currency implements Display with the --currency spelling, so the notice formats it directly. execute hands price and convert to a new price_command, which keeps it under the CRAP budget with the added fill step. Tests cover a payment without a wallet and a wallet without a line of credit. --- CHANGELOG.md | 1 + docs/concepts/safety-model.md | 2 +- docs/guides/payments.md | 2 +- src/api.rs | 34 ++++++++- src/cli.rs | 9 +-- src/error.rs | 9 +++ src/input.rs | 70 +++++++++++++++--- src/lib.rs | 1 + src/payment.rs | 21 ++++++ src/startup.rs | 58 ++++++++------- src/wallet_currency.rs | 51 +++++++++++++ tests/cli_integration.rs | 132 ++++++++++++++++++++++++++++++++++ 12 files changed, 351 insertions(+), 39 deletions(-) create mode 100644 src/wallet_currency.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index eec637f..c1599f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - Changes are only described (exit code 6) unless `--execute` or `VOLTAGE_EXECUTE=1` asks to send them; the description is built offline and sends nothing. - `--no-input` and `--quiet`, with interrupt-safe progress and the time waited so far on payment waits and device approval. - `voltage --version` shows the git build. +- A payment without `--currency` uses its wallet's line of credit currency (or, for a receive, the currency of its `--unit`). An amount without `--unit` is in the currency's base unit (msats or cents), and `--max-fee` without `--fee-unit` is in msats. - Every command that changes something has examples in `--help`, and every command links its guide. - Guides for wallets, webhooks, troubleshooting, and getting started, and references for global flags, environment variables, and configuration. diff --git a/docs/concepts/safety-model.md b/docs/concepts/safety-model.md index 66f6d35..95133dd 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 authenticates nothing, 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`. 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 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. ## Payment IDs and the request journal diff --git a/docs/guides/payments.md b/docs/guides/payments.md index 84e0346..827d95d 100644 --- a/docs/guides/payments.md +++ b/docs/guides/payments.md @@ -11,7 +11,7 @@ voltage payments send --profile prod --wallet WALLET_ID \ --currency btc --address BITCOIN_ADDRESS --amount 1000 --unit sats --execute ``` -Amounts are decimals in the unit you name (`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. `--currency` is the wallet currency for sends and the receive currency for open-amount receives. Fee limits cover network and provider fees only. A send notes on stderr that its fee limit excludes processing fees. +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, a receive with `--unit` uses that unit's currency, and any other 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. Fee limits cover network and provider fees only. A send notes on stderr that its fee limit excludes processing fees. 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). diff --git a/src/api.rs b/src/api.rs index f79e9ca..350c93e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -18,7 +18,9 @@ use crate::{ error::{ErrorDetail, ErrorKind}, input::{PaginationMode, Request}, output::{Envelope, Outcome, Output}, - payment::{PaymentDirection, PaymentView, ReceiveKind, StatusText, WaitProgress, WaitTarget}, + payment::{ + Currency, PaymentDirection, PaymentView, ReceiveKind, StatusText, WaitProgress, WaitTarget, + }, registry::{AuthScheme, Method, Operation, OperationId}, secret::Secret, terminal::{self, Terminal}, @@ -551,6 +553,36 @@ pub async fn wallets( Ok(api.send(Method::Get, &path, &query, None).await?.body) } +/// The currency of a wallet's line of credit, which decides how its payments are +/// denominated and whether they need a quote. A wallet has no currency field of its own, so +/// this reads the wallet for its line of credit, then the line of credit's summary. +pub async fn wallet_currency( + global: &GlobalFlags, + credential: ApiCredential, + scope: &Scope, + wallet: Uuid, +) -> Result { + let org = scope.require_org()?; + let api = Api::new(global, None, Authorization::Account(credential))?; + let path = format!("/organizations/{org}/wallets/{wallet}"); + let read = api.send(Method::Get, &path, &[], None).await?.body; + let unknown = || { + Error::usage("Cannot tell this wallet's currency") + .with_hint("Pass --currency btc or --currency usd.") + }; + let line = read + .get("line_of_credit_id") + .and_then(Value::as_str) + .ok_or_else(unknown)?; + let path = format!("/organizations/{org}/lines_of_credit/{line}/summary"); + let summary = api.send(Method::Get, &path, &[], None).await?.body; + summary + .get("currency") + .cloned() + .and_then(|currency| serde_json::from_value(currency).ok()) + .ok_or_else(unknown) +} + /// Whether an API command sends its request, and what decided it. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Execution { diff --git a/src/cli.rs b/src/cli.rs index 5e4bea6..9eba689 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -426,7 +426,8 @@ pub struct PaymentFlags { /// Optional payment UUID #[arg(long)] pub id: Option, - /// Wallet currency for sends, receive currency for any-amount receives + /// Wallet currency for sends, receive currency for any-amount receives. Defaults to the + /// wallet's line of credit currency, which reads the wallet (or a receive's --unit) #[arg(long, value_enum)] pub currency: Option, /// BOLT11 invoice to pay @@ -438,16 +439,16 @@ pub struct PaymentFlags { /// Receive kind; onchain and bip21 need the on_chain and bip21 features enabled by Voltage #[arg(long, value_enum)] pub kind: Option, - /// Exact decimal amount; pair with --unit + /// Exact decimal amount, in --unit #[arg(long)] pub amount: Option, - /// Unit of --amount + /// Unit of --amount. Defaults to the currency's base unit: msats or cents #[arg(long, value_enum)] pub unit: Option, /// Maximum network/provider fee; processing fees are additional #[arg(long)] pub max_fee: Option, - /// Unit of --max-fee: msats, sats, or btc + /// Unit of --max-fee: msats, sats, or btc. Defaults to msats #[arg(long, value_enum)] pub fee_unit: Option, /// Quote UUID required by USD payment flows diff --git a/src/error.rs b/src/error.rs index 83776c4..8d0eab3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -157,6 +157,15 @@ impl Error { self } + /// Add a sentence after the hint the error already has, so neither is lost. + pub fn and_hint(mut self, hint: &'static str) -> Self { + self.hint = Some(match self.hint.take() { + Some(existing) => format!("{existing} {hint}").into(), + None => hint.into(), + }); + self + } + pub fn with_detail(mut self, detail: ErrorDetail) -> Self { self.detail = Some(detail); self diff --git a/src/input.rs b/src/input.rs index 3400f0c..1f959a7 100644 --- a/src/input.rs +++ b/src/input.rs @@ -354,13 +354,14 @@ fn create_wallet(flags: &WalletFlags, scope: &Scope) -> Result }) } -fn amount_with_unit( +/// An amount in its `--unit`, or in `default` when none is given. +fn amount_in( amount: Option<&str>, unit: Option, - unit_flag: &str, + default: AmountUnit, ) -> Result> { amount - .map(|value| Amount::parse(value, require(unit, unit_flag)?)) + .map(|value| Amount::parse(value, unit.unwrap_or(default))) .transpose() } @@ -371,7 +372,7 @@ fn receive(flags: &PaymentFlags, wallet: Uuid, currency: Currency) -> Result Result String { build(args).expect_err("request should fail").message } diff --git a/src/lib.rs b/src/lib.rs index 3c3a23a..81dd9b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod registry; pub mod secret; mod startup; mod terminal; +mod wallet_currency; pub(crate) use error::{Error, Result}; pub use startup::run; diff --git a/src/payment.rs b/src/payment.rs index 85ab80b..63a7e54 100644 --- a/src/payment.rs +++ b/src/payment.rs @@ -44,6 +44,27 @@ pub enum Currency { Usd, } +/// The `--currency` spelling, as in a notice that names the currency in use. +impl std::fmt::Display for Currency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Btc => "btc", + Self::Usd => "usd", + }) + } +} + +impl Currency { + /// The API's integer unit, which an amount without `--unit` uses: the smallest unit, so a + /// misread amount errs toward too little rather than too much. + pub fn base_unit(self) -> AmountUnit { + match self { + Self::Btc => AmountUnit::Msats, + Self::Usd => AmountUnit::Cents, + } + } +} + /// Unit of a decimal `--amount` or `--max-fee`. #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] pub enum AmountUnit { diff --git a/src/startup.rs b/src/startup.rs index 30df1f4..ffff0eb 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -14,6 +14,7 @@ use crate::{ pick, price::{PRICE_URL, PriceService}, terminal::Terminal, + wallet_currency, }; use serde::Serialize; use std::process::ExitCode; @@ -86,7 +87,10 @@ fn process_exit(code: i32) -> ExitCode { } async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result<()> { - let Invocation { global, command } = invocation; + let Invocation { + global, + mut command, + } = invocation; if let Command::Local(LocalCommand::Completions { shell }) = command { // The generator panics on a write error, so render to memory and use the normal // stdout error policy when writing the completed script. @@ -94,7 +98,6 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result clap_complete::generate(shell, &mut cli::command(), BINARY_NAME, &mut script); return write_stdout(&script); } - let terminal = global.terminal(); // `reserve` is false for a description, which goes to stdout and never reserves the file. let output = |reserve: bool| { Output::new( @@ -104,34 +107,15 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result ) }; // Price commands need neither configuration nor credentials. - if let Command::Local(LocalCommand::Price(_) | LocalCommand::Convert(_)) = &command { + if let Command::Local(local @ (LocalCommand::Price(_) | LocalCommand::Convert(_))) = command { let mut out = output(true)?; - let service = PriceService::new( - global.price_url.as_deref().unwrap_or(PRICE_URL), - global.timeout, - )?; - const WAITING: &str = "Waiting for price service..."; - let envelope = match command { - Command::Local(LocalCommand::Price(flags)) => Envelope::local( - terminal - .during(WAITING, service.report(flags.at.as_deref())) - .await?, - )?, - Command::Local(LocalCommand::Convert(flags)) => Envelope::local( - terminal - .during( - WAITING, - service.convert(&flags.request(), flags.at.as_deref()), - ) - .await?, - )?, - _ => unreachable!("matched above"), - }; + let envelope = price_command(local, &global).await?; return out.write(envelope, &[]); } let mut settings = Settings::open(config::directory(global.config_dir.clone())?)?; let mut scope = settings.scope(global.scope_selection())?; pick::fill(&command, &global, &settings, &mut scope, submission).await?; + wallet_currency::fill(&mut command, &global, &settings, &scope, submission).await?; match command { Command::Api(api) => { let execution = Execution::resolve(&api, config::execute_variable()?); @@ -149,6 +133,32 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result } } +/// Read the price service for `price` or `convert`. +async fn price_command(command: LocalCommand, global: &GlobalFlags) -> Result { + let service = PriceService::new( + global.price_url.as_deref().unwrap_or(PRICE_URL), + global.timeout, + )?; + let terminal = global.terminal(); + const WAITING: &str = "Waiting for price service..."; + match command { + LocalCommand::Price(flags) => Envelope::local( + terminal + .during(WAITING, service.report(flags.at.as_deref())) + .await?, + ), + LocalCommand::Convert(flags) => Envelope::local( + terminal + .during( + WAITING, + service.convert(&flags.request(), flags.at.as_deref()), + ) + .await?, + ), + _ => unreachable!("only price commands are passed"), + } +} + async fn local_command( command: LocalCommand, global: &GlobalFlags, diff --git a/src/wallet_currency.rs b/src/wallet_currency.rs new file mode 100644 index 0000000..92831b3 --- /dev/null +++ b/src/wallet_currency.rs @@ -0,0 +1,51 @@ +//! A friendly payment without `--currency` takes its wallet's currency. +//! +//! On a receive, an amount's `--unit` already names the currency, so nothing is read. Otherwise +//! the wallet and its line of credit are read with the account credential, in a description as +//! in a real run, so both show the same request. An explicit `--currency` is never replaced and +//! needs no credential. + +use crate::{ + Result, + api::{self, SubmissionState}, + auth, + cli::{BodySource, Command, FriendlyFlags, GlobalFlags}, + config::{Scope, Settings}, + payment::PaymentDirection, +}; + +pub async fn fill( + command: &mut Command, + global: &GlobalFlags, + settings: &Settings, + scope: &Scope, + submission: &SubmissionState, +) -> Result<()> { + let Command::Api(invocation) = command else { + return Ok(()); + }; + let receive = invocation.alias == Some(PaymentDirection::Receive); + let Some(BodySource::Friendly(FriendlyFlags::Payment(flags))) = &mut invocation.body else { + return Ok(()); + }; + if flags.currency.is_some() { + return Ok(()); + } + if receive && let Some(unit) = flags.unit.filter(|_| flags.amount.is_some()) { + flags.currency = Some(unit.currency()); + return Ok(()); + } + // Without a wallet the payment reports that instead; reading needs one. + let Some(wallet) = scope.wallet else { + return Ok(()); + }; + let credential = auth::resolve_organization(settings, scope, global, submission) + .await + .map_err(|error| error.and_hint("Or pass --currency to skip reading the wallet."))?; + let currency = api::wallet_currency(global, credential, scope, wallet).await?; + global.terminal().notice(format!( + "Using the wallet's currency, {currency}; pass --currency to choose another." + )); + flags.currency = Some(currency); + Ok(()) +} diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index d27168a..a31a386 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -2303,6 +2303,138 @@ async fn a_described_change_reads_raw_bodies_and_shows_them_only_with_show_secre assert!(!dir.path().join("requests").exists()); } +#[tokio::test] +async fn a_payment_without_currency_takes_its_wallets_line_of_credit_currency() { + let (server, dir) = fixture().await; + const LINE: &str = "55555555-5555-4555-8555-555555555555"; + respond_once( + &server, + route("GET", format!("/organizations/{ORG}/wallets/{WALLET}")), + ok(json!({"id": WALLET, "line_of_credit_id": LINE})), + ) + .await; + respond_once( + &server, + route( + "GET", + format!("/organizations/{ORG}/lines_of_credit/{LINE}/summary"), + ), + ok(json!({"id": LINE, "currency": "btc"})), + ) + .await; + let send = [ + "payments", + "send", + "--org", + ORG, + "--env", + ENV, + "--wallet", + WALLET, + "--address", + "bc1qexample", + "--amount", + "1500", + ]; + // A description reads the wallet as a real run would, so both send the same body; the + // amount without --unit is in the currency's base unit. + let described = cli(dir.path(), &server) + .env_remove("VOLTAGE_EXECUTE") + .args(send) + .assert() + .code(6); + let body = json_stdout(&described)["data"]["body"].clone(); + assert_eq!(body["currency"], "btc"); + assert_eq!( + body["data"]["amount"], + json!({"currency": "btc", "amount": 1500}) + ); + assert!(stderr(&described).contains("Using the wallet's currency, btc")); + server.verify().await; + let read = server.received_requests().await.unwrap().len(); + assert_eq!(read, 2); + + // A receive's --unit names its currency, and an explicit --currency is kept: no reads. + for args in [ + &[ + "payments", "receive", "--org", ORG, "--env", ENV, "--wallet", WALLET, "--kind", + "bolt11", "--amount", "2", "--unit", "sats", + ][..], + &[ + "payments", + "send", + "--org", + ORG, + "--env", + ENV, + "--wallet", + WALLET, + "--currency", + "btc", + "--invoice", + "lnbc1", + ], + ] { + let described = cli(dir.path(), &server) + .env_remove("VOLTAGE_EXECUTE") + .args(args) + .assert() + .code(6); + // A receive with an amount carries its currency in the amount. + let body = json_stdout(&described)["data"]["body"].clone(); + let currency = body["currency"] + .as_str() + .or(body["amount"]["currency"].as_str()); + assert_eq!(currency, Some("btc"), "{args:?}"); + assert!( + !stderr(&described).contains("wallet's currency"), + "{args:?}" + ); + } + assert_eq!(server.received_requests().await.unwrap().len(), read); + + // Without a credential there is nothing to read the wallet with; the hint says both fixes. + let missing = cli(dir.path(), &server) + .env_remove("VOLTAGE_EXECUTE") + .env_remove("VOLTAGE_API_KEY") + .args(send) + .assert() + .code(3); + assert!(stderr(&missing).contains("Or pass --currency to skip reading the wallet.")); + assert_eq!(server.received_requests().await.unwrap().len(), read); + + // Without a wallet there is nothing to read; the payment reports the missing wallet. + let unscoped: Vec<&str> = send + .iter() + .copied() + .filter(|arg| !matches!(*arg, "--wallet" | WALLET)) + .collect(); + let error = cli(dir.path(), &server) + .env_remove("VOLTAGE_EXECUTE") + .args(&unscoped) + .assert() + .code(2); + assert!(stderr(&error).contains("--wallet is required")); + assert_eq!(server.received_requests().await.unwrap().len(), read); + + // A wallet without a line of credit has no currency to take, so the hint names the flag. + respond_once( + &server, + route("GET", format!("/organizations/{ORG}/wallets/{RESOURCE}")), + ok(json!({"id": RESOURCE})), + ) + .await; + let mut other = send; + other[7] = RESOURCE; + let error = cli(dir.path(), &server) + .env_remove("VOLTAGE_EXECUTE") + .args(other) + .assert() + .code(2); + assert!(stderr(&error).contains("Cannot tell this wallet's currency")); + assert!(stderr(&error).contains("Pass --currency btc or --currency usd.")); +} + #[tokio::test] async fn checkout_session_reads_send_the_requested_origin() { let (server, dir) = fixture().await;