Skip to content

feat(cli): frame the invoice QR code for scanning on any theme - #109

Merged
thebrandonlucas merged 1 commit into
feat/cli-look-09-balancefrom
feat/cli-look-10-qr
Oct 23, 2026
Merged

thebrandonlucas merged 1 commit into
feat/cli-look-09-balancefrom
feat/cli-look-10-qr

Conversation

@thebrandonlucas

@thebrandonlucas thebrandonlucas commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Stack position: 10 of 13 in the look-and-feel stack. Base: feat/cli-look-09-balance (#108).

Problem

--qr drew the invoice in the terminal's own colors with no quiet zone, and with --qr alone the full payment record printed after the code and pushed it off screen. On a dark theme, the dark modules render light, so the code is inverted, and some wallets will not scan an inverted code or one without a quiet zone. The caption, Scan to pay:, did not say what you were paying or until when.

Behavior

On a terminal:

● mutinynet · Scan to pay · 1,000 sats · order 42 · expires in 59 minutes   ← purple dot

██████████████████████████████████          ← black on bright white, every row,
██ ▄▄▄▄▄ █▀▄ ▀▄▀█▄ ▄█▀▀█ ▄▄▄▄▄ ██            with a 4-module quiet zone
  …
⠹ Waiting for the payment to be completed... 12s · expires in 58:47
  • The code is black on bright white whatever the theme, so it scans on light and dark terminals. Both colors are from the 16 basic colors.
  • Network badge. The caption starts with the network, like ● mutinynet, with the dot in the website's network-icon color: mainnet #FF7333, mutinynet #AD44FF, signet #A31AF7, testnet #13CE66. It falls back to 256 colors, then basic colors (yellow, magenta, green), and to * in ASCII. The QR code itself stays plain black on white; colored frames, finder patterns, and logos were tried and dropped.
  • Network source: the payment's wallet, read once with --qr on a terminal, because mutinynet and signet invoices share the lntbs prefix. The prefix is the fallback; an unknown network (such as regtest) gets no badge.
  • The QR spec's four-module quiet zone (two blank rows above and below, four blank columns each side), drawn in bright white. There's no indent.
  • Order: with --qr alone (a wait that ends at ready), the payment record prints first and the caption and code come last, so the code stays on screen. With --wait completed, the code comes first, because you need it while the CLI waits.
  • The caption shows whichever of the amount (friendly units), memo (scrubbed), and expiry the payment carries.
  • With --wait completed, the status line counts down to the invoice's expiry, using the countdown from feat(cli): link the login URL, bold the code, and count down to expiry #96.
  • Redirected stderr keeps the exact compact code and Scan to pay: text it always had.

Dropped from the plan: redrawing the code in place to ✓ Paid. The timeline prints status lines below the code, and erasing upward would wipe them, or break when the code has scrolled off screen. The closing ✓ Payment … completed line from #91 already marks the payment.

Design notes

  • qr::show holds the rendering that used to be in present_invoice. render(code, framed) is the only difference between the terminal and redirected forms; it pads the unframed code with a quiet zone of QUIET_MODULES (4, the spec's), as blank half-block rows and columns.
  • qr::network_style maps a network to its dot color at the terminal's depth. Call::wallet_network does the best-effort wallet read, and Line::with_style paints scrubbed text in a style chosen at run time.
  • In wait_for_payment, the early presentation applies only to --wait completed. On reaching the target, the result is written and then the invoice is presented.
  • Role::QrCode is black on bright white. It is the one role that sets a background, because the code must not follow the theme.
  • PaymentData gains amount, memo, and expiration, and PaymentView::expires_at parses the latter. Timeline::expires_at and Timeline::pause hold the countdown, so wait_for_payment calls timeline.pause(...) instead of terminal.waiting(...) with no new branch, and its CRAP score dropped. The expiry instant uses checked_add, so an absurd expiry cannot overflow.

Try it on staging

Setup: Try a pull request on staging (added by #97). This creates a receive invoice (--execute): nothing is paid, and it simply expires. Use a test-network wallet; find one with voltage wallets list --account me.

voltage payments receive --account me --wallet WALLET_ID --currency btc --kind bolt11 \
  --amount 1000 --unit sats --qr --execute

Expect: ● mutinynet with a purple dot, then the bold Scan to pay · 1,000 sats caption (plus expires … when the API sends an expiration). Below it, a plain black-on-white code with a white border. Scan it with a phone on both a dark and a light terminal theme; the wallet app should read a signet or mutinynet invoice.

voltage payments receive --account me --wallet WALLET_ID --currency btc --kind bolt11 \
  --amount 1000 --unit sats --qr --wait completed --execute

Expect: the same code, then a spinner line that adds · expires in m:ss when the invoice has an expiration. Ctrl-C to stop waiting; the invoice stays valid until it expires.

voltage payments receive --account me --wallet WALLET_ID --currency btc --kind bolt11 \
  --amount 1000 --unit sats --qr --execute 2>&1 | cat

Expect: the old compact code under Scan to pay:, with no colors.

Validation

  • cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (166 unit and 129 integration tests), and python3 scripts/check-coverage.py all pass.
  • Unit tests:
    • The framed code is decoded back into modules, half by half, and compared with the QR matrix. Every dark module is drawn in the foreground (black), with four quiet modules on every side; the compact form has none.
    • Networks come from the invoice prefix (mainnet, signet, testnet, regtest), every known network has a dot color (24-bit, 256-color, and basic checked), and regtest has none.
    • The caption gets the amount, a scrubbed memo, and a relative expiry, and a payment without them gets the bare caption.
  • New PTY test: a receive --qr --wait completed on a terminal shows the caption with amount, memo, and expiry, and every code line is black on bright white with a blank first row. The wait shows · expires in. The existing redirected test still sees Scan to pay:. A new PTY test, with all streams on the terminal, checks that for --qr alone the payment record comes before the caption and nothing follows the code, and that the wallet read names mutinynet in the badge. The framed test checks the signet fallback badge, its purple dot, and that the code has no frame.
  • Not done by me: scanning with a phone. Please check on a light and a dark theme.
  • CRAP: 0 of 760 measured functions at or above 20. The highest is 18.00 (src/cli.rs endpoint, untouched).

Docs

docs/guides/payments.md and CHANGELOG.md.

@thebrandonlucas
thebrandonlucas added this pull request to stack #92 October 22, 2026 22:57
@thebrandonlucas
thebrandonlucas force-pushed the feat/cli-look-10-qr branch 4 times, most recently from 89e87ed to bc5cc91 Compare October 23, 2026 02:15
@thebrandonlucas
thebrandonlucas marked this pull request as ready for review October 23, 2026 02:18

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on d674fb9 (base #108). Framing the QR on a light background is right for dark themes. Color-on polarity is correct (dark modules are drawn as the foreground, black on white), encode is unchanged with no truncation, the QR and caption go only to stderr, and --json, --output-file and piped stdout are unchanged.

P1: PaymentData.expiration: Option<String> (src/payment.rs:244) breaks every payment wait whose payment carries an expiration. In api/openapi.json, data.expiration is oneOf[null, Expiration], and Expiration is {"type":"integer","format":"u-int32"}: the invoice's lifetime in seconds, the same unit as --expiration. When the API sends "expiration": 3600, deserializing into Option<String> fails for the whole PaymentView. from_body then falls back to unwrap_or_default(), so status, invoice, direction and id all become None. progress() stays Pending, so payments receive --qr and payments get --wait … time out on a payment that is already ready or completed. check_invoice_payment and reconcile_payment lose the view too. The tests pass only because they use RFC 3339 strings. The field is a duration, so expires_at would also read it wrongly. Keep the display-only field out of the view that drives the wait, for example as Option<Value>, and take the expiry from the invoice's own timestamp and expiry, or from created_at plus the seconds. A unit test such as PaymentView::from_body(&json!({"status":"completed","data":{"expiration":3600}})).progress(WaitTarget::Completed), which should be Reached(Completed), would pin it.

P2: the best-effort wallet read has no short limit. It uses the full --timeout (60 seconds by default) and is not bounded by the wait's deadline, so a slow wallets endpoint holds back the QR, and the deadline can pass during the read. #108 now gives its extra reads a 5-second READ_LIMIT, and the same treatment fits here.

P2: the quiet zone and the color-off claim. The frame gives a 2-module quiet zone, but the QR spec asks for 4, and on a dark theme the terminal background starts right outside it. Going to 4 costs 4 columns. With NO_COLOR, --color never or TERM=dumb on a terminal, the framed form still prints with its colors stripped, so on a dark theme the code is inverted. Yet payments.md now says that the code "scans on a dark theme too". Branch on colors_stderr() or caveat the docs, and add a test.

Nonblocking: ASCII glyph mode still draws Unicode half-blocks. In the ready path, a QR encode error now comes after the record was written to stdout. The comment at the test's "No wallet read answers here" is inaccurate, because no read is made at all. The paths where the wallet read fails or has no network are untested.

Gate and CRAP on this head are running, and I will add the results.

Comment thread src/payment.rs Outdated
@tee8z

tee8z commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Gate results for d674fb9, which my change request said I would add. In nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (183 unit, 139 integration) and python3 scripts/check-coverage.py passed. CRAP: 796 measured functions, none at or above 20, highest table at 17.00. The suite passes because its fixtures send expiration as an RFC 3339 string. The contract's integer seconds is the case the P1 is about.

@thebrandonlucas

thebrandonlucas commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Review-body items, in 7033511:

  • P1, expiration: fixed (see the thread).
  • P2, wallet read limit: the read is bounded by balance::READ_LIMIT (5 s) and by the wait's deadline, whichever comes first.
  • P2, color off: with color off on a terminal, the code is now the compact form (no styles to strip), and payments.md says it follows the theme and may read inverted on a dark one. Unit test without_color_the_code_is_the_compact_form_and_carries_no_styles.
  • P2, quiet zone of 4: kept at 2 on purpose. blu asked for a smaller code after seeing the 4-module version; it was 8 columns wider and 2 rows taller. Phone wallets scan the 2-module zone reliably; it is drawn in bright white, so the code has a light border on any theme. blu can override this.
  • Encode error after the record: qr::check encodes the invoice before the record is written, so that error now comes first.
  • Inaccurate comment: fixed. The payment names no wallet, so no wallet is read.
  • Wallet read failing or without a network: new test a_failed_or_networkless_wallet_read_falls_back_to_the_invoice_prefix, with a 404, then a wallet without a network, both falling back to the signet prefix.
  • ASCII mode draws half-blocks: left as is. An ASCII-only code needs two characters per module, which doubles its width, and scanners need square modules; the half-block code is the smallest that scans.
  • The CRAP report has wait_for_payment at 18.10 (from 17.05) because of the early encode check; still under 20.

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.
@thebrandonlucas

Copy link
Copy Markdown
Contributor Author

Quiet zone: per blu, it now follows the spec at four modules (8280881): two blank rows above and below, and four blank columns each side, in bright white. The module-by-module unit test checks four light modules on every side. payments.md says the same.

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved 8280881 against feat/cli-look-09-balance (#108 73d4b3f). My findings are fixed:

  • P1, expiration: the display-only fields (amount, memo, expiration, and created_at) are Option<Value>, so no shape fails the view that drives the wait. expires_at is created_at plus expiration seconds, and created_at is a required date-time on ReceivePayment in the contract. The new unit test is the case from my review, {"status":"completed","data":{"expiration":3600}} giving Reached(Completed), and it also checks that odd shapes leave the view intact. The PTY test now sends an integer expiration.
  • Wallet read: bounded by READ_LIMIT or the time left before the wait's deadline, whichever is shorter.
  • Quiet zone: four modules, as the spec asks: two text rows above and below, which is four modules, and four columns each side. The unit test checks every side.
  • Color off: the compact form, with no styles to strip, and payments.md says it may read inverted on a dark theme.
  • Nonblocking notes: qr::check now fails before the record is written. The test comment is corrected. a_failed_or_networkless_wallet_read_falls_back_to_the_invoice_prefix covers a 404 and a wallet without a network. Keeping half-blocks in ASCII mode is reasonable.

Validation on this head, in nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (185 unit, 140 integration) and python3 scripts/check-coverage.py passed. CI is green. I did not run the CRAP report for this re-review.

@thebrandonlucas
thebrandonlucas merged commit 4b98c1a into master Oct 23, 2026
7 checks passed
@thebrandonlucas
thebrandonlucas deleted the feat/cli-look-10-qr branch October 23, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants