From 8280881d996e9a02cf2b76c5056d27eb4d2090da Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:56:49 -0500 Subject: [PATCH] feat(cli): frame the invoice QR code for scanning on any theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a terminal, --qr draws the invoice black on bright white with a two-module quiet zone, so a phone scans it on a dark theme as well as a light one, under a caption led by the network ("● mutinynet", the dot in the website's network color), then a bold "Scan to pay" and the amount, memo, and expiry when the payment has them. The CLI reads the payment's wallet for its network, since mutinynet and signet invoices share a prefix, and falls back to the prefix. A wait that ends at ready shows the code after the result, so it stays on screen; with --wait completed it comes first and the status line counts down to the expiry. Redirected stderr keeps the compact code it always had. --- CHANGELOG.md | 1 + docs/guides/payments.md | 2 +- src/api.rs | 99 +++++++++---- src/cli.rs | 2 +- src/lib.rs | 1 + src/output.rs | 2 +- src/output/human.rs | 5 + src/payment.rs | 60 +++++++- src/qr.rs | 298 +++++++++++++++++++++++++++++++++++++++ src/style.rs | 13 ++ src/terminal.rs | 5 + src/timeline.rs | 29 ++++ tests/cli_integration.rs | 132 +++++++++++++++++ 13 files changed, 615 insertions(+), 34 deletions(-) create mode 100644 src/qr.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c32db3..75d84da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - 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. +- `--qr` on a terminal draws the invoice black on white with a quiet zone, so it scans on dark themes, under a `Scan to pay` caption with the amount, memo, and expiry, and after the result so it stays on screen; `--wait completed` counts down to the expiry. The caption leads with the network, its dot in the website's color. ## 0.2.0 - 2026-10-22 diff --git a/docs/guides/payments.md b/docs/guides/payments.md index 374ff1e..8f8b5c1 100644 --- a/docs/guides/payments.md +++ b/docs/guides/payments.md @@ -38,7 +38,7 @@ voltage payments get PAYMENT_ID --profile prod --wait completed --timeout 120 ## Show an invoice -For a BOLT11 receive, `--qr` prints the invoice as a terminal QR code and `--copy` puts it on the clipboard as soon as it is ready. Either flag implies `--wait ready`; with `--wait completed` the invoice is shown first and polling continues to settlement. +For a BOLT11 receive, `--qr` prints the invoice as a terminal QR code and `--copy` puts it on the clipboard as soon as it is ready. Either flag implies `--wait ready`; with `--wait completed` the invoice is shown first and polling continues to settlement. On a terminal with color the QR code is black on white with the QR spec's four-module quiet zone, so it scans on a dark theme too; with color off (`NO_COLOR`, `--color never`, `TERM=dumb`) it is the compact code, which follows your terminal's theme and may read inverted on a dark one. It appears under a bold `Scan to pay` caption with the amount, memo, and expiry when the payment has them. The caption starts with the network, its dot in the website's color (mainnet orange, mutinynet and signet purple, testnet green); the code itself stays black and white. The CLI reads the payment's wallet for its network, since mutinynet and signet invoices share a prefix, and uses the prefix if that read fails. A wait that ends at `ready` shows the code after the result, so the code stays on screen; with `--wait completed` it comes first, and the status line counts down to the expiry. Redirected stderr gets the compact code it always did. ## Quotes and lines of credit diff --git a/src/api.rs b/src/api.rs index 16c5432..b847494 100644 --- a/src/api.rs +++ b/src/api.rs @@ -21,6 +21,7 @@ use crate::{ output::{Envelope, Outcome, Output}, payment::{Currency, PaymentDirection, PaymentView, ReceiveKind, WaitProgress, WaitTarget}, price::{PRICE_URL, PriceService, UsdPerBtc}, + qr, registry::{AuthScheme, Method, Operation, OperationId, operation}, secret::Secret, style::Depth, @@ -1395,11 +1396,16 @@ impl Call<'_> { if let Some(status) = &view.status { timeline.status(status); } + // Waiting on to settlement needs the invoice now; a wait that ends at `ready` shows + // it after the result, so the code is what stays on screen. if invocation.presents_invoice() + && until == WaitTarget::Completed && !invoice_presented && let Some(invoice) = view.invoice() { - present_invoice(invoice, invocation, api.terminal)?; + let network = self.wallet_network(&view).await; + present_invoice(invoice, &view, network.as_deref(), invocation, api.terminal)?; + timeline.expires_at(view.expires_at()); invoice_presented = true; if until == WaitTarget::Completed { api.terminal @@ -1414,19 +1420,34 @@ impl Call<'_> { .redacted(&api.secrets())); } WaitProgress::Reached(outcome) => { - if invocation.presents_invoice() && !invoice_presented { - present_invoice( - view.invoice().ok_or_else(|| { + let invoice = (invocation.presents_invoice() && !invoice_presented) + .then(|| { + view.invoice().map(str::to_owned).ok_or_else(|| { Error::usage("The ready payment does not contain a BOLT11 invoice") - })?, - invocation, - api.terminal, - )?; + }) + }) + .transpose()?; + // An invoice that cannot be drawn fails before the record is written. + if let Some(invoice) = &invoice + && invocation.qr + { + qr::check(invoice)?; } timeline.reached(outcome); - return self.out.write( + self.out.write( Envelope::new(Some(response.status), response.body, Some(id), outcome), &api.secrets(), + )?; + let Some(invoice) = invoice else { + return Ok(()); + }; + let network = self.wallet_network(&view).await; + return present_invoice( + &invoice, + &view, + network.as_deref(), + invocation, + api.terminal, ); } WaitProgress::Pending => {} @@ -1437,10 +1458,9 @@ impl Call<'_> { )) .with_detail(ErrorDetail::pending(id))); } - api.terminal - .waiting( + timeline + .pause( format!("Waiting for the payment to be {target}..."), - timeline.started, tokio::time::sleep(pause), ) .await; @@ -1460,8 +1480,38 @@ impl Call<'_> { } } +impl Call<'_> { + /// The payment's wallet network, which colors a QR code on a terminal. Mutinynet and + /// signet invoices share a prefix, so the wallet says which it is. Best effort: without + /// it, the invoice's prefix stands in. + async fn wallet_network(&self, view: &PaymentView) -> Option { + if !self.invocation.qr || !self.api.terminal.decorates() { + return None; + } + let path = format!( + "/organizations/{}/wallets/{}", + self.scope.require_org().ok()?, + view.wallet_id? + ); + // A slow wallets endpoint must not hold back the code or outlast the wait. + let limit = + balance::READ_LIMIT.min(self.deadline.saturating_duration_since(Instant::now())); + let wallet = tokio::time::timeout(limit, self.api.read(&path)) + .await + .ok()? + .ok()?; + wallet.body.get("network")?.as_str().map(str::to_owned) + } +} + /// Copy and render a ready BOLT11 invoice as requested; both go to stderr. -fn present_invoice(invoice: &str, invocation: &ApiInvocation, terminal: Terminal) -> Result<()> { +fn present_invoice( + invoice: &str, + view: &PaymentView, + network: Option<&str>, + invocation: &ApiInvocation, + terminal: Terminal, +) -> Result<()> { if invocation.copy { match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(invoice)) { Ok(()) => terminal.notice("Invoice copied to the clipboard."), @@ -1471,23 +1521,12 @@ fn present_invoice(invoice: &str, invocation: &ApiInvocation, terminal: Terminal } } if invocation.qr { - // BOLT11 is case-insensitive. Uppercase enables QR alphanumeric mode, while - // low error correction and a two-module margin keep terminal output compact. - let code = qrcode::QrCode::with_error_correction_level( - invoice.to_ascii_uppercase(), - qrcode::EcLevel::L, - ) - .map_err(|_| Error::transport("Could not encode the invoice as a QR code"))?; - let image = code - .render::() - .quiet_zone(false) - .build(); - let image = image - .lines() - .map(|line| format!(" {line} ")) - .collect::>() - .join("\n"); - terminal.important(format!("\nScan to pay:\n\n{image}\n")); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| { + i64::try_from(since.as_secs()).unwrap_or(i64::MAX) + }); + qr::show(invoice, view, network, terminal, now)?; } Ok(()) } diff --git a/src/cli.rs b/src/cli.rs index eb0fa9e..4ac719d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -401,7 +401,7 @@ struct WaitFlags { #[derive(Debug, Args)] struct InvoiceFlags { - /// Render a ready BOLT11 invoice as a compact terminal QR code; implies --wait ready + /// Render a ready BOLT11 invoice as a QR code to scan; implies --wait ready #[arg(long, help_heading = "Waiting and presentation")] qr: bool, /// Copy a ready BOLT11 invoice to the clipboard; implies --wait ready diff --git a/src/lib.rs b/src/lib.rs index 21ca8e3..ea1d6be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ mod payment; mod pick; mod price; mod progress; +mod qr; pub mod registry; pub mod secret; mod startup; diff --git a/src/output.rs b/src/output.rs index 62e0201..e5fbbb7 100644 --- a/src/output.rs +++ b/src/output.rs @@ -5,7 +5,7 @@ mod human; -pub use human::{epoch_seconds, readable_amount}; +pub use human::{epoch_seconds, readable_amount, relative_time}; use crate::{ Error, Result, diff --git a/src/output/human.rs b/src/output/human.rs index af325e7..b69f89a 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -256,6 +256,11 @@ pub fn epoch_seconds(text: &str) -> Option { timestamp(text).map(|(_, seconds)| seconds) } +/// `then` against `now`, such as `in 59 minutes` or `3 days ago`. +pub fn relative_time(then: i64, now: i64) -> String { + relative(then, now) +} + /// 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/payment.rs b/src/payment.rs index 417d229..f32438e 100644 --- a/src/payment.rs +++ b/src/payment.rs @@ -3,7 +3,10 @@ //! Wire encodings here are stable: they appear in request bodies, in the `--wait` and //! `--unit` flags, and in the payment projections the CLI polls. -use crate::{Error, Result, output::Outcome}; +use crate::{ + Error, Result, + output::{Outcome, epoch_seconds}, +}; use clap::ValueEnum; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -232,6 +235,15 @@ pub struct PaymentData { pub payment_request: Option, #[serde(default)] pub address: Option, + // Display-only fields stay raw JSON: a shape this view does not expect must never fail + // the whole view, which drives waits. + #[serde(default)] + pub amount: Option, + #[serde(default)] + pub memo: Option, + /// The invoice's lifetime in seconds, counted from the payment's `created_at`. + #[serde(default)] + pub expiration: Option, } /// Read view over a payment projection; unknown fields and shapes stay in the raw body. @@ -240,6 +252,11 @@ pub struct PaymentView { #[serde(default)] pub id: Option, #[serde(default)] + pub wallet_id: Option, + /// RFC 3339 UTC; raw for the same reason as `PaymentData`'s display fields. + #[serde(default)] + pub created_at: Option, + #[serde(default)] pub status: Option, #[serde(default)] pub direction: Option, @@ -266,6 +283,22 @@ impl PaymentView { Self::deserialize(body).unwrap_or_default() } + /// When the invoice expires, in seconds since the epoch: its creation plus its lifetime. + pub fn expires_at(&self) -> Option { + let created = self.created_at.as_ref()?.as_str().and_then(epoch_seconds)?; + let lifetime = i64::try_from(self.data.expiration.as_ref()?.as_u64()?).ok()?; + created.checked_add(lifetime) + } + + /// The memo, when the payment has a non-empty one. + pub fn memo(&self) -> Option<&str> { + self.data + .memo + .as_ref() + .and_then(Value::as_str) + .filter(|memo| !memo.is_empty()) + } + /// The BOLT11 invoice, once generated. pub fn invoice(&self) -> Option<&str> { self.data @@ -310,6 +343,31 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn an_expiration_in_seconds_keeps_the_view_and_dates_the_expiry() { + // The contract's `expiration` is an integer number of seconds. + let view = PaymentView::from_body(&json!({ + "status": "completed", "created_at": "2026-10-21T00:00:00Z", + "data": {"expiration": 3600, "memo": "order 42"} + })); + assert_eq!( + view.progress(WaitTarget::Completed), + WaitProgress::Reached(Outcome::Completed) + ); + assert_eq!(view.expires_at(), Some(1_792_540_800 + 3600)); + assert_eq!(view.memo(), Some("order 42")); + // Unexpected display shapes leave the view intact and simply show nothing. + let odd = PaymentView::from_body(&json!({ + "status": "completed", "created_at": 7, + "data": {"expiration": "soon", "memo": {"x": 1}} + })); + assert_eq!( + odd.progress(WaitTarget::Completed), + WaitProgress::Reached(Outcome::Completed) + ); + assert_eq!((odd.expires_at(), odd.memo()), (None, None)); + } + #[test] fn exact_money() { assert_eq!( diff --git a/src/qr.rs b/src/qr.rs new file mode 100644 index 0000000..0d2ab8b --- /dev/null +++ b/src/qr.rs @@ -0,0 +1,298 @@ +//! A BOLT11 invoice as a terminal QR code. On a terminal the code is drawn black on bright +//! white with the spec's four-module quiet zone, so a phone scans it on a dark theme as well as a +//! light one, under a caption led by the network in its website color. Redirected stderr +//! keeps the compact code it always had. + +use crate::{ + Error, Result, + output::{readable_amount, relative_time}, + payment::PaymentView, + style::{Depth, Glyphs, Line, Role}, + terminal::Terminal, +}; +use clap::builder::styling::{Ansi256Color, AnsiColor, Color, RgbColor, Style}; +use qrcode::{EcLevel, QrCode, render::unicode::Dense1x2}; + +/// Light modules around a framed code: the four the QR spec asks for. Even, because a +/// character cell holds two modules from top to bottom. +const QUIET_MODULES: usize = 4; + +/// Show `invoice` for scanning, with what `view` says about the payment. `network` is the +/// wallet's network when it was read; the invoice's prefix stands in otherwise. +pub fn show( + invoice: &str, + view: &PaymentView, + network: Option<&str>, + terminal: Terminal, + now: i64, +) -> Result<()> { + let code = encode(invoice)?; + if !terminal.decorates() { + let image = render(&code, false) + .iter() + .map(|line| format!(" {line} ")) + .collect::>() + .join("\n"); + terminal.important(format!("\nScan to pay:\n\n{image}\n")); + return Ok(()); + } + let network = network.or_else(|| invoice_network(invoice)); + let depth = Depth::detect(|name| std::env::var(name).ok()); + terminal.important_line(Line::default()); + terminal.important_line(caption(view, network, terminal.glyphs(), depth, now)); + for line in image(&code, terminal.colors()) { + terminal.important_line(line); + } + Ok(()) +} + +/// Whether `invoice` can be drawn, for a caller to know before it writes anything else. +pub fn check(invoice: &str) -> Result<()> { + encode(invoice).map(|_| ()) +} + +/// The code as lines: framed black on bright white with color, so it scans on any theme. +/// Without color it cannot be, so it is the compact form, which follows the theme and may +/// read inverted on a dark one. +fn image(code: &QrCode, colored: bool) -> Vec { + if colored { + render(code, true) + .iter() + .map(|line| Line::default().styled(Role::QrCode, line)) + .collect() + } else { + render(code, false) + .iter() + .map(|line| Line::default().text(&format!(" {line} "))) + .collect() + } +} + +/// BOLT11 is case-insensitive. Uppercase enables QR alphanumeric mode, and low error +/// correction keeps the code small enough for a terminal. +fn encode(invoice: &str) -> Result { + QrCode::with_error_correction_level(invoice.to_ascii_uppercase(), EcLevel::L) + .map_err(|_| Error::transport("Could not encode the invoice as a QR code")) +} + +/// Two modules per character cell. `framed` adds a quiet zone of `QUIET_MODULES`: half that +/// many blank rows above and below, and that many blank columns each side. The compact form +/// leaves it to the margin around the code. +fn render(code: &QrCode, framed: bool) -> Vec { + let lines = code + .render::() + .quiet_zone(false) + .build() + .lines() + .map(str::to_owned) + .collect::>(); + if !framed { + return lines; + } + let side = " ".repeat(QUIET_MODULES); + let blank = " ".repeat(code.width() + 2 * QUIET_MODULES); + let margin = || std::iter::repeat_n(blank.clone(), QUIET_MODULES / 2); + margin() + .chain(lines.into_iter().map(|line| format!("{side}{line}{side}"))) + .chain(margin()) + .collect() +} + +/// The network a BOLT11 prefix names. Mutinynet invoices use signet's prefix. +fn invoice_network(invoice: &str) -> Option<&'static str> { + let invoice = invoice.to_ascii_lowercase(); + [ + ("lnbcrt", "regtest"), + ("lntbs", "signet"), + ("lntb", "testnet"), + ("lnbc", "mainnet"), + ] + .into_iter() + .find(|(prefix, _)| invoice.starts_with(prefix)) + .map(|(_, network)| network) +} + +/// A network's color from the website's network icons, at the terminal's depth. +fn network_style(network: &str, depth: Depth) -> Option