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;