From 73d4b3fa6451e9a4c011ac249a27fbf7812b7c30 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:48:26 -0500 Subject: [PATCH] feat(cli): lead wallets get with balance and activity on a terminal On a terminal, voltage wallets get first shows the wallet's name in Voltage orange, each available balance in bold (with a USD estimate for a BTC balance on mainnet, from the price service), and a sparkline of its completed payment volume over the last 14 days from its 100 newest payments. The two extra reads are best effort: a failure leaves its line out. The wallet's fields follow unchanged, and redirected or JSON output makes no extra request. --- CHANGELOG.md | 1 + docs/guides/wallets.md | 2 + src/api.rs | 66 ++++++- src/balance.rs | 387 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/output.rs | 16 ++ src/output/human.rs | 10 + src/style.rs | 8 + tests/cli_integration.rs | 160 ++++++++++++++++ 9 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 src/balance.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1067cd7..3c32db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - Help headers and usage are Voltage orange where the terminal draws 256 or 24-bit color, and bold elsewhere. `FORCE_COLOR` is honored: `0` or `false` turns color off, other values turn it on when redirected; `--color` and `NO_COLOR` still win. - A successful `voltage login` on a terminal shows the `voltage` wordmark, a lightning bolt beside the name in Voltage orange, above `✓ Logged in as`. - `voltage context` and `voltage auth status` draw their fields in a titled card on a terminal, with sources dimmed. Redirected output is unchanged. +- `voltage wallets get` on a terminal leads with the wallet's name, its balances (with a USD estimate on mainnet), and a 14-day sparkline of completed payments. ## 0.2.0 - 2026-10-22 diff --git a/docs/guides/wallets.md b/docs/guides/wallets.md index e6d93de..183c8b3 100644 --- a/docs/guides/wallets.md +++ b/docs/guides/wallets.md @@ -38,6 +38,8 @@ voltage wallets ledger list WALLET_ID --profile prod --all voltage wallets payments summary WALLET_ID --profile prod ``` +On a terminal, `wallets get` first shows the wallet's name, each available balance, labelled `Available` (with a USD estimate for a BTC balance on mainnet, from the price service), and a sparkline of its completed payment volume over the last 14 days. That takes two extra reads, the BTC price on mainnet and the wallet's 100 newest completed payments (payment checks excluded), summed from each payment's amount in the wallet's currency. Each read has 5 seconds at most, and one that fails or is slow leaves its line out. The wallet's fields follow as usual, and redirected or `--json` output is unchanged. + The ledger lists every credit, hold, capture, and release on the wallet. `--all` follows every page; with `--output ndjson` each page is written as it arrives. ## Change a wallet diff --git a/src/api.rs b/src/api.rs index 4628999..16c5432 100644 --- a/src/api.rs +++ b/src/api.rs @@ -9,6 +9,7 @@ use crate::{ Error, Result, auth::{self, ApiCredential, Resolver}, backoff::{Backoff, POLL_CEILING}, + balance, cli::{ApiInvocation, GlobalFlags, Origin, Requested}, config::{ API_URL, EXECUTE_VARIABLE, Scope, Settings, Source, new_private, private_dir, read_private, @@ -19,8 +20,10 @@ use crate::{ input::{PaginationMode, Request}, output::{Envelope, Outcome, Output}, payment::{Currency, PaymentDirection, PaymentView, ReceiveKind, WaitProgress, WaitTarget}, + price::{PRICE_URL, PriceService, UsdPerBtc}, registry::{AuthScheme, Method, Operation, OperationId, operation}, secret::Secret, + style::Depth, terminal::Terminal, timeline::Timeline, }; @@ -32,7 +35,7 @@ use std::{ collections::BTreeSet, io::Write, sync::{Mutex, OnceLock, PoisonError}, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use tokio::time::Instant; use uuid::Uuid; @@ -630,6 +633,7 @@ pub async fn execute( } let call = Call { api, + global, invocation, scope, deadline: Instant::now() + global.timeout, @@ -862,6 +866,7 @@ impl Mutation { /// method, so the request and output are not handed from step to step. struct Call<'a> { api: Api, + global: &'a GlobalFlags, invocation: &'a ApiInvocation, scope: &'a Scope, deadline: Instant, @@ -906,11 +911,70 @@ impl Call<'_> { self.write_pages(response, Outcome::Retrieved, false).await } ReadThen::Pages { follow } => { + if operation.id == OperationId::GetWallet && self.out.on_terminal() { + self.lead_with_balance(&response.body).await?; + } self.write_pages(response, Outcome::Retrieved, follow).await } } } + /// On a terminal, put the wallet's balance and recent activity above its fields. The + /// price and payments reads are best effort: a failure leaves its line out. + async fn lead_with_balance(&self, wallet: &Value) -> Result<()> { + let mut headline = balance::Headline::from_wallet(wallet); + if headline.priceable.is_some() { + let base = self.global.price_url.as_deref().unwrap_or(PRICE_URL); + if let Ok(service) = + PriceService::new(base, balance::READ_LIMIT.min(self.global.timeout)) + && let Ok(quote) = self + .api + .terminal + .during("Reading the BTC price...", service.quote(None)) + .await + && let Ok(rate) = UsdPerBtc::parse("e.price) + { + headline.price(rate); + } + } + // The wallet's completed payments, newest first, as `payments list` would ask. + let uuid = |name: &str| wallet.get(name)?.as_str()?.parse::().ok(); + let scope = Scope { + org: uuid("organization_id"), + envs: uuid("environment_id").into_iter().collect(), + wallet: uuid("id"), + ..Scope::default() + }; + if scope.wallet.is_some() + && let Ok(mut request) = Request::scoped(operation(OperationId::GetPayments), &scope) + { + request.query.extend( + [ + ("statuses[]", "completed"), + // Payment checks are not payments, so they add no volume. + ("check_only", "false"), + ("sort_order", "DESC"), + ("limit", balance::ACTIVITY_LIMIT), + ] + .map(|(name, value)| (name.to_owned(), value.to_owned())), + ); + let read = self + .api + .send(Method::Get, &request.path, &request.query, None); + if let Ok(Ok(page)) = tokio::time::timeout(balance::READ_LIMIT, read).await { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| { + i64::try_from(since.as_secs()).unwrap_or(i64::MAX) + }); + headline.activity = Some(balance::activity(&page.body, now, &headline.currency)); + } + } + let glyphs = self.api.terminal.glyphs(); + let depth = Depth::detect(|name| std::env::var(name).ok()); + self.out.preface(&headline.lines(glyphs, depth)) + } + /// Verify and journal, then send the mutation exactly once. A transport /// failure is reported as an uncertain submission unless reconciliation proves acceptance. async fn mutate( diff --git a/src/balance.rs b/src/balance.rs new file mode 100644 index 0000000..39c0bf4 --- /dev/null +++ b/src/balance.rs @@ -0,0 +1,387 @@ +//! The headline `wallets get` leads with on a terminal: the wallet's name, each balance, a +//! USD estimate for a mainnet BTC balance, and a sparkline of the wallet's completed +//! payments over the last two weeks. Each extra read is best effort; one that fails leaves +//! its line out, and the wallet's fields follow as usual. + +use crate::{ + output::{epoch_seconds, readable_amount}, + price::UsdPerBtc, + style::{Depth, Glyphs, Line, Role}, +}; +use serde_json::{Value, json}; +use std::time::Duration; + +/// Days the activity sparkline covers, ending today. +pub const ACTIVITY_DAYS: usize = 14; +/// Payments read for the sparkline, newest first. +pub const ACTIVITY_LIMIT: &str = "100"; +/// The most each extra read may take, so a slow price service or list cannot hold up the +/// wallet, whatever `--timeout` allows. +pub const READ_LIMIT: Duration = Duration::from_secs(5); +const SPARKS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; +const ASCII_SPARKS: [&str; 8] = ["_", ".", ",", "-", "~", "=", "+", "#"]; +const SECONDS_PER_DAY: i64 = 86_400; + +/// A mainnet BTC balance a USD estimate can price: which balance line, and its signed msats. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Priceable { + pub index: usize, + pub negative: bool, + pub msats: u64, +} + +#[derive(Debug, Default)] +pub struct Headline { + pub name: Option, + /// Each balance's `available` amount, as the API sends it. + pub balances: Vec, + pub priceable: Option, + /// That balance in USD cents, when the price service answered. + pub usd_cents: Option, + /// The currency the activity sums: the wallet's first balance's. + pub currency: String, + /// Completed volume per day in `currency`'s base unit, oldest first. + pub activity: Option>, +} + +impl Headline { + /// What the wallet itself says: its name, balances, and whether a USD estimate fits. + pub fn from_wallet(wallet: &Value) -> Self { + let balances: Vec = wallet + .get("balances") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|balance| balance.get("available").cloned()) + .collect(); + // Test-network coins have no price, so only mainnet gets an estimate. + let mainnet = wallet.get("network").and_then(Value::as_str) == Some("mainnet"); + let priceable = balances + .iter() + .enumerate() + .find_map(|(index, balance)| { + btc_msats(balance).map(|(negative, msats)| Priceable { + index, + negative, + msats, + }) + }) + .filter(|_| mainnet); + let currency = balances + .first() + .and_then(|balance| balance.get("currency")) + .and_then(Value::as_str) + .unwrap_or("btc") + .to_owned(); + Self { + name: wallet + .get("name") + .and_then(Value::as_str) + .map(str::to_owned), + balances, + priceable, + usd_cents: None, + currency, + activity: None, + } + } + + pub fn price(&mut self, rate: UsdPerBtc) { + self.usd_cents = self + .priceable + .and_then(|priceable| rate.msats_to_cents(priceable.msats).ok()); + } + + /// The headline's lines, ending with a blank line before the wallet's fields. + pub fn lines(&self, glyphs: Glyphs, depth: Depth) -> Vec { + let mut lines = Vec::new(); + if let Some(name) = &self.name { + lines.push(Line::default().styled_brand(depth, name)); + } + for (index, balance) in self.balances.iter().enumerate() { + let Some(amount) = readable_amount(balance) else { + continue; + }; + let mut line = Line::default() + .paint(Role::Muted, "Available ") + .styled(Role::Emphasis, &amount); + // The estimate belongs to the balance it priced, wherever that is in the list. + if let (Some(cents), Some(priceable)) = (self.usd_cents, self.priceable) + && priceable.index == index + { + let usd = readable_amount(&json!({ + "amount": cents, "currency": "usd", "negative": priceable.negative + })); + let about = if glyphs == Glyphs::UNICODE { + "≈" + } else { + "~" + }; + line = line.text(&format!(" {about} {}", usd.unwrap_or_default())); + } + lines.push(line); + } + if let Some(days) = &self.activity { + lines.push( + Line::default() + .paint(Role::Muted, "Activity ") + .text(&sparkline(days, glyphs)) + .text(&format!(" {}", caption(days, &self.currency))), + ); + } + if !lines.is_empty() { + lines.push(Line::default()); + } + lines + } +} + +/// A BTC amount in msats, with its sign. +fn btc_msats(amount: &Value) -> Option<(bool, u64)> { + let negative = amount + .get("negative") + .and_then(Value::as_bool) + .unwrap_or(false); + (amount.get("currency").and_then(Value::as_str) == Some("btc")) + .then(|| base_units(amount)) + .flatten() + .map(|msats| (negative, msats)) +} + +/// An `Amount` in its currency's base unit (msats for BTC, cents for USD), whatever unit it +/// names. An amount that is not a whole number of base units cannot be summed exactly. +fn base_units(amount: &Value) -> Option { + let value = amount.get("amount")?.as_u64()?; + let currency = amount.get("currency").and_then(Value::as_str)?; + let factor = match (currency, amount.get("unit").and_then(Value::as_str)) { + ("btc", None | Some("msats")) | ("usd", None | Some("cents")) => 1, + ("btc", Some("sats")) => 1_000, + ("btc", Some("btc")) => 100_000_000_000, + ("usd", Some("usd")) => 100, + (_, None) => 1, + _ => return None, + }; + value.checked_mul(factor) +} + +/// Completed volume per day in `currency`'s base unit for the `ACTIVITY_DAYS` days up to +/// `now`, oldest first, from each payment's `data.amount`. Other statuses and currencies, +/// unreadable amounts, and older payments are left out. +pub fn activity(page: &Value, now: i64, currency: &str) -> Vec { + let mut days = vec![0u64; ACTIVITY_DAYS]; + let today = now.div_euclid(SECONDS_PER_DAY); + let items = page + .get("items") + .or(Some(page)) + .and_then(Value::as_array) + .into_iter() + .flatten(); + for payment in items { + if payment.get("status").and_then(Value::as_str) != Some("completed") { + continue; + } + let volume = payment + .pointer("/data/amount") + .filter(|amount| amount.get("currency").and_then(Value::as_str) == Some(currency)) + .and_then(base_units); + let created = payment + .get("created_at") + .and_then(Value::as_str) + .and_then(epoch_seconds); + let (Some(volume), Some(created)) = (volume, created) else { + continue; + }; + let age = today - created.div_euclid(SECONDS_PER_DAY); + if let Ok(age) = usize::try_from(age) + && age < ACTIVITY_DAYS + { + let slot = &mut days[ACTIVITY_DAYS - 1 - age]; + *slot = slot.saturating_add(volume); + } + } + days +} + +/// One bar per day, scaled to the busiest day; a day with no volume is the lowest bar. +fn sparkline(days: &[u64], glyphs: Glyphs) -> String { + let bars = if glyphs == Glyphs::UNICODE { + SPARKS + } else { + ASCII_SPARKS + }; + let busiest = days.iter().copied().max().unwrap_or(0); + days.iter() + .map(|&volume| { + let level = if busiest == 0 || volume == 0 { + 0 + } else { + // Any volume shows above the floor; the busiest day is the top bar. + let scaled = u128::from(volume) * 6 / u128::from(busiest); + 1 + usize::try_from(scaled).unwrap_or(6) + }; + bars[level.min(bars.len() - 1)] + }) + .collect() +} + +fn caption(days: &[u64], currency: &str) -> String { + let total: u64 = days.iter().fold(0, |sum, day| sum.saturating_add(*day)); + if total == 0 { + return format!("no completed payments in {ACTIVITY_DAYS} days"); + } + let total = + readable_amount(&json!({"amount": total, "currency": currency})).unwrap_or_default(); + format!("{total} completed in {ACTIVITY_DAYS} days") +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: i64 = 1_792_540_800; // 2026-10-21 00:00:00 UTC + + fn wallet(network: &str) -> Value { + json!({"name": "Treasury", "network": network, "balances": [ + {"available": {"amount": 150_000_000, "currency": "btc", "unit": "msats", "negative": true}}, + {"available": {"amount": 1234, "currency": "usd"}} + ]}) + } + + fn plain(lines: &[Line]) -> Vec { + lines + .iter() + .map(|line| anstream::adapter::strip_str(line.as_str()).to_string()) + .collect() + } + + #[test] + fn a_mainnet_btc_balance_gets_a_usd_estimate_and_a_test_network_does_not() { + let mut headline = Headline::from_wallet(&wallet("mainnet")); + assert_eq!( + headline.priceable, + Some(Priceable { + index: 0, + negative: true, + msats: 150_000_000 + }) + ); + headline.price(UsdPerBtc::parse("60000").unwrap()); + assert_eq!( + plain(&headline.lines(Glyphs::UNICODE, Depth::Basic)), + [ + "Treasury", + "Available -150,000 sats ≈ -$90.00", + "Available $12.34", + "" + ] + ); + let mut test = Headline::from_wallet(&wallet("mutinynet")); + test.price(UsdPerBtc::parse("60000").unwrap()); + assert_eq!(test.usd_cents, None); + assert!(!plain(&test.lines(Glyphs::UNICODE, Depth::Basic))[1].contains('≈')); + // ASCII says "about" with a tilde. + assert_eq!( + plain(&headline.lines(Glyphs::ASCII, Depth::Basic))[1], + "Available -150,000 sats ~ -$90.00" + ); + assert!( + Headline::from_wallet(&json!({})) + .lines(Glyphs::ASCII, Depth::Basic) + .is_empty() + ); + } + + #[test] + fn the_estimate_stays_on_the_btc_balance_when_a_usd_balance_comes_first() { + let mut headline = Headline::from_wallet(&json!({"network": "mainnet", "balances": [ + {"available": {"amount": 1234, "currency": "usd"}}, + {"available": {"amount": 2_000_000, "currency": "btc", "unit": "sats"}} + ]})); + assert_eq!(headline.priceable.map(|priceable| priceable.index), Some(1)); + headline.price(UsdPerBtc::parse("60000").unwrap()); + assert_eq!( + plain(&headline.lines(Glyphs::UNICODE, Depth::Basic)), + [ + "Available $12.34", + "Available 2,000,000 sats ≈ $1,200.00", + "" + ] + ); + assert_eq!(headline.currency, "usd"); + } + + #[test] + fn activity_sums_each_payment_amount_in_the_wallet_currency_by_day() { + let page = json!({"items": [ + // Lightning in msats, on-chain in sats, and plain BTC: each counts in msats. + {"status": "completed", "created_at": "2026-10-20T23:59:59Z", + "data": {"amount": {"amount": 1000, "currency": "btc", "unit": "msats"}}}, + {"status": "completed", "created_at": "2026-10-20T01:00:00Z", + "data": {"amount": {"amount": 2, "currency": "btc", "unit": "sats"}, "amount_sats": 2}}, + {"status": "completed", "created_at": "2026-10-08T00:00:00Z", + "data": {"amount": {"amount": 500, "currency": "btc"}}}, + // Too old, another currency, not completed, the deprecated field alone, no time. + {"status": "completed", "created_at": "2026-10-07T23:59:59Z", + "data": {"amount": {"amount": 9, "currency": "btc"}}}, + {"status": "completed", "created_at": "2026-10-20T01:00:00Z", + "data": {"amount": {"amount": 7, "currency": "usd"}}}, + {"status": "expired", "created_at": "2026-10-20T01:00:00Z", + "data": {"amount": {"amount": 7, "currency": "btc"}}}, + {"status": "completed", "created_at": "2026-10-20T01:00:00Z", + "data": {"amount_msats": 7}}, + {"status": "completed", "created_at": "not a time", + "data": {"amount": {"amount": 7, "currency": "btc"}}}, + {"status": "completed", "created_at": "2026-10-22T00:00:00Z", + "data": {"amount": {"amount": 9, "currency": "btc"}}} + ]}); + let days = activity(&page, NOW + 3_600, "btc"); + // Today (2026-10-21) is last, 2026-10-20 the day before, and 2026-10-08 the first. + assert_eq!(days.len(), ACTIVITY_DAYS); + assert_eq!(days[ACTIVITY_DAYS - 1], 0); + assert_eq!(days[ACTIVITY_DAYS - 2], 3000); + assert_eq!(days[0], 500); + assert_eq!(days.iter().sum::(), 3500); + assert_eq!(activity(&page, NOW + 3_600, "usd")[ACTIVITY_DAYS - 2], 7); + } + + #[test] + fn amounts_convert_to_base_units_only_when_exact() { + let amount = |value: Value| base_units(&value); + assert_eq!( + amount(json!({"amount": 3, "currency": "btc", "unit": "btc"})), + Some(300_000_000_000) + ); + assert_eq!( + amount(json!({"amount": 3, "currency": "usd", "unit": "usd"})), + Some(300) + ); + assert_eq!(amount(json!({"amount": 3, "currency": "eur"})), Some(3)); + assert_eq!( + amount(json!({"amount": 3, "currency": "btc", "unit": "bits"})), + None + ); + assert_eq!(amount(json!({"amount": 1.5, "currency": "btc"})), None); + } + + #[test] + fn the_sparkline_scales_to_the_busiest_day_and_floors_empty_days() { + assert_eq!(sparkline(&[0, 1, 50, 100], Glyphs::UNICODE), "▁▂▅█"); + assert_eq!(sparkline(&[0, 0], Glyphs::UNICODE), "▁▁"); + assert_eq!(sparkline(&[0, 100], Glyphs::ASCII), "_#"); + assert_eq!(caption(&[0, 0], "btc"), "no completed payments in 14 days"); + assert_eq!( + caption(&[1_000_000, 500_000], "btc"), + "1,500 sats completed in 14 days" + ); + assert_eq!(caption(&[1234], "usd"), "$12.34 completed in 14 days"); + let headline = Headline { + activity: Some(vec![0, 100]), + currency: "btc".into(), + ..Headline::default() + }; + assert_eq!( + plain(&headline.lines(Glyphs::UNICODE, Depth::Basic)), + ["Activity ▁█ 0.1 sats completed in 14 days", ""] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 68ab5d0..21ca8e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ mod all_orgs; mod api; mod auth; mod backoff; +mod balance; mod cli; pub mod config; mod context; diff --git a/src/output.rs b/src/output.rs index 0aada43..62e0201 100644 --- a/src/output.rs +++ b/src/output.rs @@ -5,6 +5,8 @@ mod human; +pub use human::{epoch_seconds, readable_amount}; + use crate::{ Error, Result, config::{EXECUTE_VARIABLE, new_private}, @@ -246,6 +248,20 @@ impl Output { !self.format.is_machine_readable() } + /// Human output to an interactive stdout, where a headline can lead the result. + pub fn on_terminal(&self) -> bool { + self.format == OutputFormat::Table && self.file.is_none() && std::io::stdout().is_terminal() + } + + /// Lines that lead the result on an interactive stdout, such as a wallet's balance. + pub fn preface(&self, lines: &[Line]) -> Result<()> { + let text: String = lines + .iter() + .map(|line| format!("{}\n", line.as_str())) + .collect(); + write_stdout_styled(text.as_bytes(), self.color) + } + /// JSON output collects `--all` pages into one envelope; NDJSON streams them. pub fn collects_pages(&self) -> bool { self.format == OutputFormat::Json diff --git a/src/output/human.rs b/src/output/human.rs index 1e10d04..af325e7 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -246,6 +246,16 @@ pub fn detail(out: &mut impl Write, detail: &Value) -> Result<()> { } } +/// An API amount as human output shows it, such as `150,000 sats` or `-$2.50`. +pub fn readable_amount(value: &Value) -> Option { + amount(value) +} + +/// An RFC 3339 UTC timestamp's seconds since the epoch. +pub fn epoch_seconds(text: &str) -> Option { + timestamp(text).map(|(_, seconds)| seconds) +} + /// An outcome worth a color: what finished, what is still in flight, and what was not sent. fn outcome_role(outcome: Outcome) -> Option { match outcome { diff --git a/src/style.rs b/src/style.rs index 5bfff46..4a07393 100644 --- a/src/style.rs +++ b/src/style.rs @@ -171,6 +171,14 @@ impl Line { self } + /// Untrusted or variable text in the brand color, such as a wallet's name. + pub fn styled_brand(mut self, depth: Depth, text: &str) -> Self { + let style = brand(depth); + self.0 + .push_str(&format!("{style}{}{style:#}", scrub_line(text))); + self + } + /// Fixed text in the brand color, at the depth the terminal draws. pub fn brand(mut self, depth: Depth, label: &'static str) -> Self { let style = brand(depth); diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 91a80ea..edfb6d5 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -5886,6 +5886,166 @@ async fn a_described_change_on_a_terminal_says_what_it_would_do() { assert!(shown.contains("› Send it: add --execute"), "{shown}"); } +/// Run a command with stdin, stdout, and stderr all on one pseudo-terminal, as a person +/// would, and return everything it showed. +#[cfg(unix)] +async fn fully_on_a_terminal(dir: &Path, server: &MockServer, args: &[&str]) -> String { + use std::io::Read; + use std::process::Stdio; + let (mut controller, terminal) = open_pty(); + // A UTF-8 color terminal, whatever the test runner's own, as `on_a_terminal` pins it. + let child = human_process_at(dir, &server.uri()) + .env("TERM", "xterm-256color") + .env("LANG", "C.UTF-8") + .env_remove("LC_ALL") + .env_remove("LC_CTYPE") + .env_remove("VOLTAGE_SPINNER") + .env("COLUMNS", "200") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::from(terminal.try_clone().unwrap())) + .stderr(Stdio::from(terminal)) + .spawn() + .unwrap(); + let reader = tokio::task::spawn_blocking(move || { + let mut shown = Vec::new(); + // Linux PTYs return EIO rather than EOF when the terminal end closes. + let _ = controller.read_to_end(&mut shown); + shown + }); + let output = tokio::task::spawn_blocking(move || child.wait_with_output().unwrap()) + .await + .unwrap(); + assert!(output.status.success()); + strip_styles(&String::from_utf8_lossy(&reader.await.unwrap())) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_failed_or_unreadable_price_leaves_the_estimate_out() { + let (server, dir) = fixture().await; + route("GET", format!("{}/{WALLET}", wallets_path(ORG))) + .respond_with(ok(json!({"id": WALLET, "name": "Treasury", "network": "mainnet", + "organization_id": ORG, "environment_id": ENV, + "balances": [{"available": {"amount": 150_000_000, "currency": "btc", "unit": "msats"}}]}))) + .mount(&server) + .await; + route("GET", "/currency/BTCUSD/now") + .respond_with(in_order(vec![ + ResponseTemplate::new(500), + ok(json!({"pair": "BTCUSD", "time": "2026-09-18T17:30:00Z", "price": "not a price"})), + ])) + .expect(2) + .mount(&server) + .await; + route("GET", payments_path()) + .respond_with(ok(json!({"items": []}))) + .mount(&server) + .await; + let uri = server.uri(); + let args = ["wallets", "get", WALLET, "--org", ORG, "--price-url", &uri]; + for _ in 0..2 { + let shown = fully_on_a_terminal(dir.path(), &server, &args).await; + assert!( + shown.contains("Treasury\r\nAvailable 150,000 sats\r\n"), + "{shown}" + ); + assert!(!shown.contains('≈'), "{shown}"); + } + server.verify().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_redirected_wallet_read_makes_no_extra_request() { + let (server, dir) = fixture().await; + route("GET", format!("{}/{WALLET}", wallets_path(ORG))) + .respond_with(ok( + json!({"id": WALLET, "name": "Treasury", "network": "mainnet", + "organization_id": ORG, "environment_id": ENV, "balances": []}), + )) + .expect(2) + .mount(&server) + .await; + route("GET", "/currency/BTCUSD/now") + .respond_with(ok(json!({}))) + .expect(0) + .mount(&server) + .await; + route("GET", payments_path()) + .respond_with(ok(json!({"items": []}))) + .expect(0) + .mount(&server) + .await; + let uri = server.uri(); + let get = ["wallets", "get", WALLET, "--org", ORG, "--price-url", &uri]; + cli(dir.path(), &server).args(get).assert().success(); + Command::from_std(human_process_at(dir.path(), &uri)) + .args(get) + .args(["--output", "table"]) + .assert() + .success(); + server.verify().await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_wallet_on_a_terminal_leads_with_its_balance_and_activity() { + let (server, dir) = fixture().await; + let wallet = |network: &str| { + json!({"id": WALLET, "name": "Treasury", "network": network, + "organization_id": ORG, "environment_id": ENV, + "balances": [{"available": {"amount": 150_000_000, "currency": "btc", "unit": "msats"}}]}) + }; + route("GET", format!("{}/{WALLET}", wallets_path(ORG))) + .respond_with(in_order(vec![ok(wallet("mainnet")), ok(wallet("signet"))])) + .mount(&server) + .await; + respond_once( + &server, + route("GET", "/currency/BTCUSD/now"), + ok(json!({"pair":"BTCUSD","time":"2026-09-18T17:30:00Z","price":"60000"})), + ) + .await; + // The activity read asks as `payments list` does: the wallet's completed payments, + // newest first, by cursor. The first answers; the second fails, leaving its line out. + route("GET", payments_path()) + .and(query_param("wallet_id", WALLET)) + .and(query_param("statuses[]", "completed")) + .and(query_param("check_only", "false")) + .and(query_param("sort_order", "DESC")) + .and(query_param("pagination", "cursor")) + .and(query_param("limit", "100")) + .respond_with(in_order(vec![ + ok( + json!({"items": [{"status": "completed", "created_at": "2020-01-01T00:00:00Z", + "data": {"amount": {"amount": 1000, "currency": "btc", "unit": "msats"}}}]}), + ), + ResponseTemplate::new(500), + ])) + .expect(2) + .mount(&server) + .await; + let uri = server.uri(); + let args = ["wallets", "get", WALLET, "--org", ORG, "--price-url", &uri]; + let mainnet = fully_on_a_terminal(dir.path(), &server, &args).await; + assert!( + mainnet.contains( + "Treasury\r\nAvailable 150,000 sats ≈ $90.00\r\n\ + Activity ▁▁▁▁▁▁▁▁▁▁▁▁▁▁ no completed payments in 14 days\r\n\r\n" + ), + "{mainnet}" + ); + assert!(mainnet.contains("balances "), "{mainnet}"); + // A test network gets no USD estimate, and a failed activity read no activity line. + let signet = fully_on_a_terminal(dir.path(), &server, &args).await; + assert!( + signet.contains("Treasury\r\nAvailable 150,000 sats\r\n\r\n"), + "{signet}" + ); + assert!(!signet.contains("Activity"), "{signet}"); + server.verify().await; +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn a_failed_payment_on_a_terminal_ends_with_a_cross_before_the_error() {