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 @@ -22,6 +22,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- `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.
- A payment that settles on a terminal closes with `⚡ Payment ID completed` instead of a check mark, printed after the payment's record so it stays on screen.
- `voltage payments list --watch` follows payments until Ctrl-C: a live table that marks new and changed payments on a terminal, or one `snapshot`, `new`, or `updated` event per payment when piped or with `--json`. `--interval` sets the refresh (default 5 seconds).

## 0.2.0 - 2026-10-22

Expand Down
11 changes: 11 additions & 0 deletions docs/guides/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ An accepted submission returns immediately with `outcome: accepted`. In human ou
voltage payments get PAYMENT_ID --profile prod --wait completed --timeout 120
```

## Watch payments

`payments list --watch` keeps reading the list every `--interval` seconds (default 5) until you press Ctrl-C, with the same filters as `payments list`:

```sh
voltage payments list --profile prod --wallet WALLET_ID --watch
voltage payments list --profile prod --watch --interval 2 --json
```

On a terminal it is a live table on the alternate screen, newest first, with `●` beside each payment that is new or changed since the last refresh; Ctrl-C puts your screen back and prints `Stopped watching payments.` (exit code 130). Piped or with `--json`, it writes one event envelope per payment instead: `snapshot` for each payment in the first read, then `new` or `updated` as they appear, with the payment in `data` and its ID in `id`. Each refresh reads the first page of the most recently updated payments (`sort_key=updated_at`, `sort_order=DESC`), so `--watch` cannot be combined with `--all`, `--cursor`, `--sort-key`, or `--sort-order`; more changes than one page holds (`--limit`, the API's default otherwise) between two refreshes are not all reported. A refresh that fails in transport, with a server error (5xx), or with a rate limit (429) is retried after the server's `Retry-After`, or after a pause that grows up to a minute, and the live view says why. A rejected credential (401) is resolved again once, so a saved login exchanges a fresh organization token. Any other error ends the watch. `--timeout` limits each refresh's request, not the watch, and `--quiet` also hides `Stopped watching payments.` On a terminal, rows beyond its height are left out and counted.

## 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. 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.
Expand Down
159 changes: 154 additions & 5 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ use crate::{
dry_run,
error::{CredentialSources, ErrorDetail, ErrorKind, ResponseLimit},
input::{PaginationMode, Request},
output::{Envelope, Outcome, Output},
output::{Envelope, LiveScreen, 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,
terminal::Terminal,
style::{Depth, Line, Role},
terminal::{self, Terminal},
timeline::Timeline,
watch,
};
use futures_util::StreamExt;
use serde::{Deserialize, Deserializer, Serialize};
Expand All @@ -35,7 +36,10 @@ use std::{
borrow::Cow,
collections::BTreeSet,
io::Write,
sync::{Mutex, OnceLock, PoisonError},
sync::{
Mutex, OnceLock, PoisonError,
atomic::{AtomicBool, Ordering},
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::time::Instant;
Expand Down Expand Up @@ -290,6 +294,8 @@ impl Api {
ErrorKind::for_http_status(status)
};
Error {
response_status: Some(status),
retry_after,
hint: self.authorization.rejection_hint(status),
// Only the sources are attached: an HTTP detail would make an HTML 404
// read as a missing resource.
Expand Down Expand Up @@ -322,6 +328,7 @@ impl Api {
if let Some(hint) = hint.or_else(|| rejection.hint().map(Cow::Borrowed)) {
error = error.with_hint(hint);
}
error.retry_after = retry_after;
return Err(error
.with_detail(ErrorDetail::Http {
http_status: status,
Expand Down Expand Up @@ -635,6 +642,7 @@ pub async fn execute(
let call = Call {
api,
global,
resolver,
invocation,
scope,
deadline: Instant::now() + global.timeout,
Expand All @@ -643,7 +651,12 @@ pub async fn execute(
};
match run {
Run::EventStream => call.api.stream(&call.request, call.out).await,
Run::Read(read) => call.read(read).await,
Run::Read(read) => {
if matches!(read.then, ReadThen::Watch { .. }) {
resolver.submission.watching();
}
call.read(read).await
}
Run::Mutation(mutation) => {
call.mutate(mutation, resolver.settings, resolver.submission)
.await
Expand Down Expand Up @@ -804,6 +817,8 @@ pub struct Read {
pub enum ReadThen {
/// Write the response, then follow further pages when `follow` (`--all`) is set.
Pages { follow: bool },
/// `payments list --watch`: read again every `interval_seconds` until interrupted.
Watch { interval_seconds: u64 },
/// A checkout session read answers 202 until its projection exists.
SessionProjection,
/// Poll the payment until `until`. A submitted payment can be missing from the read
Expand All @@ -825,6 +840,9 @@ impl Read {
&& invocation.wait.is_some(),
},
None if operation.id == OperationId::GetSession => ReadThen::SessionProjection,
None if let Some(interval) = invocation.watch => ReadThen::Watch {
interval_seconds: interval.as_secs(),
},
None => ReadThen::Pages {
follow: invocation.all,
},
Expand Down Expand Up @@ -868,6 +886,8 @@ impl Mutation {
struct Call<'a> {
api: Api,
global: &'a GlobalFlags,
/// How the credential was chosen, to choose it again when a watch outlives it.
resolver: Resolver<'a>,
invocation: &'a ApiInvocation,
scope: &'a Scope,
deadline: Instant,
Expand All @@ -878,6 +898,9 @@ struct Call<'a> {
impl Call<'_> {
async fn read(mut self, read: Read) -> Result<()> {
let operation = self.invocation.operation;
if matches!(read.then, ReadThen::Watch { .. }) {
watch::follow_updates(&mut self.request.query);
}
let sent = self
.api
.send(
Expand Down Expand Up @@ -911,6 +934,10 @@ impl Call<'_> {
let response = self.await_session_projection(response).await?;
self.write_pages(response, Outcome::Retrieved, false).await
}
ReadThen::Watch { interval_seconds } => {
self.watch(response, Duration::from_secs(interval_seconds))
.await
}
ReadThen::Pages { follow } => {
if operation.id == OperationId::GetWallet && self.out.on_terminal() {
self.lead_with_balance(&response.body).await?;
Expand All @@ -920,6 +947,74 @@ impl Call<'_> {
}
}

/// `payments list --watch`: read the list again every `interval` until interrupted, most
/// recently updated first. An interactive stdout gets a live table on the alternate
/// screen that marks payments new or changed since the last read; other output gets one
/// event envelope per payment, `snapshot` for the first read, then `new` or `updated`.
/// A refresh the server could not serve (a 5xx, a 429, or a transport failure) is retried
/// after its `Retry-After`, or after a growing pause; a rejected credential (401) is
/// resolved again once, since a saved login's organization token is short-lived; any
/// other failure ends the watch.
async fn watch(&mut self, mut response: Response, interval: Duration) -> Result<()> {
// Each read would otherwise draw a status line over the table.
self.api.terminal = self.api.terminal.quieted();
let live = self.out.on_terminal();
let _screen = live.then(LiveScreen::enter);
let mut seen = watch::Seen::default();
let mut failure = None;
let mut backoff = Backoff::new(interval, watch::BACKOFF_CEILING);
let mut pause = response.retry_after.unwrap_or(interval);
let mut reauthorized = false;
loop {
let changes = seen.update(&response.body);
if live {
let marked: Vec<String> = changes
.iter()
.filter(|(_, _, change)| *change != watch::Change::Snapshot)
.map(|(id, _, _)| id.clone())
.collect();
let header = watch_header(interval, failure.as_ref(), self.api.terminal);
self.out.live_frame(&header, &response.body, &marked)?;
} else {
for (id, payment, change) in changes {
let event = Envelope::event(payment, change.as_str().into(), Some(id));
self.out.write(event, &self.api.secrets())?;
}
}
tokio::time::sleep(pause).await;
let operation = self.invocation.operation;
let sent = self
.api
.send(
operation.method,
&self.request.path,
&self.request.query,
None,
)
.await;
match sent {
Ok(next) => {
pause = next.retry_after.unwrap_or(interval);
response = next;
failure = None;
reauthorized = false;
backoff = Backoff::new(interval, watch::BACKOFF_CEILING);
}
Err(error) if error.response_status() == Some(401) && !reauthorized => {
self.api.authorization =
Authorization::resolve(self.invocation, self.resolver, self.scope).await?;
reauthorized = true;
pause = Duration::ZERO;
}
Err(error) if watch::retries(&error) => {
pause = error.retry_after.unwrap_or_else(|| backoff.pause());
failure = Some(error);
}
Err(error) => return Err(error),
Comment thread
thebrandonlucas marked this conversation as resolved.
}
}
}

/// 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<()> {
Expand Down Expand Up @@ -1233,6 +1328,8 @@ pub struct SubmissionState {
/// The saved login whose profile offer is under way. Kept apart from `session`: the
/// offer's own refresh or token exchange sets and settles a session change of its own.
profile_offer: Mutex<Option<String>>,
/// `payments list --watch` is running, so Ctrl-C is how it ends rather than a cut-off.
watching: AtomicBool,
}

/// A login change that the server and the saved credentials can disagree about.
Expand Down Expand Up @@ -1273,6 +1370,16 @@ impl SubmissionState {
*self.session.lock().unwrap_or_else(PoisonError::into_inner) = None;
}

/// Marks a watch, which only reads and ends with Ctrl-C.
fn watching(&self) {
self.watching.store(true, Ordering::Relaxed);
}

/// A watch was running, so Ctrl-C is how it ended rather than a failure to report.
pub fn is_watching(&self) -> bool {
self.watching.load(Ordering::Relaxed)
}

/// Marks the profile offer after a login, which is saved by then, as under way or done.
pub(crate) fn offering_profile(&self, account: Option<&str>) {
*self
Expand Down Expand Up @@ -1506,6 +1613,25 @@ impl Call<'_> {
}
}

/// The live watch's first line: what it is, how often it refreshes, how to stop, and why
/// the last refresh failed, if it did.
fn watch_header(interval: Duration, failure: Option<&Error>, terminal: Terminal) -> Line {
let separator = terminal.glyphs().separator;
let mut line = Line::default()
.styled(Role::Emphasis, "Watching payments")
.text(&format!(
" {separator} every {} {separator} Ctrl-C to stop",
terminal::elapsed(interval)
));
if let Some(error) = failure {
line = line
.text(&format!(" {separator} "))
.paint(Role::Caution, "last refresh failed:")
.text(&format!(" {}", error.message));
}
line
}

/// Copy and render a ready BOLT11 invoice as requested; both go to stderr.
fn present_invoice(
invoice: &str,
Expand Down Expand Up @@ -1696,6 +1822,29 @@ fn next_page(body: &Value, request: &Request) -> Result<Option<(String, String)>
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn the_watch_header_says_how_to_stop_and_why_a_refresh_failed() {
let terminal = Terminal::new(false, true);
let separator = terminal.glyphs().separator;
let shown = |line: Line| anstream::adapter::strip_str(line.as_str()).to_string();
assert_eq!(
shown(watch_header(Duration::from_secs(5), None, terminal)),
format!("Watching payments {separator} every 5s {separator} Ctrl-C to stop")
);
let failure = Error::transport("Connection reset");
assert_eq!(
shown(watch_header(
Duration::from_secs(5),
Some(&failure),
terminal
)),
format!(
"Watching payments {separator} every 5s {separator} Ctrl-C to stop \
{separator} last refresh failed: Connection reset"
)
);
}
use crate::{
cli::{Command, Invocation},
config::Config,
Expand Down
Loading
Loading