Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
99 changes: 69 additions & 30 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 => {}
Expand All @@ -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;
Expand All @@ -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<String> {
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."),
Expand All @@ -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::<qrcode::render::unicode::Dense1x2>()
.quiet_zone(false)
.build();
let image = image
.lines()
.map(|line| format!(" {line} "))
.collect::<Vec<_>>()
.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(())
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod payment;
mod pick;
mod price;
mod progress;
mod qr;
pub mod registry;
pub mod secret;
mod startup;
Expand Down
2 changes: 1 addition & 1 deletion src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ pub fn epoch_seconds(text: &str) -> Option<i64> {
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<Role> {
match outcome {
Expand Down
60 changes: 59 additions & 1 deletion src/payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -232,6 +235,15 @@ pub struct PaymentData {
pub payment_request: Option<String>,
#[serde(default)]
pub address: Option<String>,
// 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<Value>,
#[serde(default)]
pub memo: Option<Value>,
/// The invoice's lifetime in seconds, counted from the payment's `created_at`.
#[serde(default)]
pub expiration: Option<Value>,
}

/// Read view over a payment projection; unknown fields and shapes stay in the raw body.
Expand All @@ -240,6 +252,11 @@ pub struct PaymentView {
#[serde(default)]
pub id: Option<Uuid>,
#[serde(default)]
pub wallet_id: Option<Uuid>,
/// RFC 3339 UTC; raw for the same reason as `PaymentData`'s display fields.
#[serde(default)]
pub created_at: Option<Value>,
#[serde(default)]
pub status: Option<StatusText>,
#[serde(default)]
pub direction: Option<PaymentDirection>,
Expand All @@ -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<i64> {
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
Expand Down Expand Up @@ -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!(
Expand Down
Loading
Loading