From 5e3432aa891fce3edc7d00fd218c1217a03efe31 Mon Sep 17 00:00:00 2001 From: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:47:37 +0000 Subject: [PATCH] nwc: add downloadable/printable LNURL QR for wallet connections Each NWC wallet connection gets its own shareable LNURL QR so the owner can receive payments from anyone without creating invoices (the core LNURL value proposition: a static QR that never expires). Hub UI (service detail modal): - New 'Share QR' button on every wallet connection card - Dedicated share view: large QR, Lightning Address + copy, raw bech32 LNURL + copy - Download PNG (websites/social), download SVG (vector, print-sharp at any size), and a Print button that opens a print-ready payment card which auto-invokes the browser print dialog Backend (FastAPI, session-authenticated): - GET /api/nwc/wallets/{id}/lnurl -> alias, lightning_address, LNURL - GET /api/nwc/wallets/{id}/lnurl-qr.png (?download=1, ?scale=) - GET /api/nwc/wallets/{id}/lnurl-qr.svg (?download=1) - GET /api/nwc/wallets/{id}/lnurl-qr/print (print-ready HTML card) - Stdlib bech32 encoder (BIP-173, verified against the official LUD-01 golden vector); LNURL is uppercased for QR alphanumeric-mode density per LUD-01 guidance; QR rendered with the existing qrencode package - Resolves wallets by app id or pubkey; 404 unknown wallet, 503 when no Lightning domain is configured; all routes return 401 unauthenticated No new system dependencies. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 245 +++++++++++++++++- .../static/css/modals.css | 27 +- .../static/js/service-detail.js | 83 +++++- 3 files changed, 346 insertions(+), 9 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 7d7028c..a8552b7 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -26,10 +26,11 @@ import urllib.request from threading import Lock from fastapi import FastAPI, HTTPException -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.requests import Request +from html import escape as _html_escape from pydantic import BaseModel from starlette.middleware.base import BaseHTTPMiddleware @@ -1341,22 +1342,101 @@ def _evaluate_domain_checklist( # ── QR code helper ──────────────────────────────────────────────── -def _generate_qr_base64(data: str) -> str | None: - """Generate a QR code PNG and return it as a base64-encoded data URI. +def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes | None: + """Generate a QR code PNG and return the raw bytes. Uses qrencode CLI (available on the system via credentials.nix).""" try: result = subprocess.run( - ["qrencode", "-o", "-", "-t", "PNG", "-s", "6", "-m", "2", "-l", "H", data], + ["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", "H", data], capture_output=True, timeout=10, ) if result.returncode == 0 and result.stdout: - b64 = base64.b64encode(result.stdout).decode("ascii") - return f"data:image/png;base64,{b64}" + return result.stdout except Exception: pass return None +def _generate_qr_svg(data: str, scale: int = 10, margin: int = 4) -> str | None: + """Generate a QR code SVG document (resolution-independent, ideal if the + user wants to embed the QR in a website or print it at any size).""" + try: + result = subprocess.run( + ["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", "H", data], + capture_output=True, timeout=10, + ) + if result.returncode == 0 and result.stdout: + return result.stdout.decode("utf-8", errors="replace") + except Exception: + pass + return None + + +def _generate_qr_base64(data: str) -> str | None: + """Generate a QR code PNG and return it as a base64-encoded data URI.""" + png = _generate_qr_png_bytes(data, scale=6, margin=2) + if png: + b64 = base64.b64encode(png).decode("ascii") + return f"data:image/png;base64,{b64}" + return None + + +# ── Bech32 encoding (BIP-173) — used for LNURL strings (LUD-01) ── + +_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +_BECH32_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3) + + +def _bech32_polymod(values: list[int]) -> int: + chk = 1 + for value in values: + top = chk >> 25 + chk = ((chk & 0x1FFFFFF) << 5) ^ value + for i in range(5): + if (top >> i) & 1: + chk ^= _BECH32_GENERATOR[i] + return chk + + +def _bech32_hrp_expand(hrp: str) -> list[int]: + return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + + +def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]: + values = _bech32_hrp_expand(hrp) + data + polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1 + return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)] + + +def _bech32_convertbits(data: bytes, frombits: int, tobits: int) -> list[int]: + acc = 0 + bits = 0 + ret: list[int] = [] + maxv = (1 << tobits) - 1 + for value in data: + acc = (acc << frombits) | value + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if bits: + ret.append((acc << (tobits - bits)) & maxv) + return ret + + +def _bech32_encode(hrp: str, payload: bytes) -> str: + """Encode payload bytes as a bech32 string with the given HRP (BIP-173).""" + data = _bech32_convertbits(payload, 8, 5) + combined = data + _bech32_create_checksum(hrp, data) + return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined) + + +def _nwc_lnurl_bech32(alias: str, domain: str) -> str: + """Return the LUD-01 bech32 LNURL for a wallet connection alias.""" + url = f"https://{domain}/.well-known/lnurlp/{alias}" + return _bech32_encode("lnurl", url.encode("utf-8")) + + # ── Update helpers (file-based, no systemctl) ──────────────────── def _read_update_status() -> str: @@ -4550,6 +4630,159 @@ async def api_nwc_test(alias: str): return {"ok": True} +# ── LNURL QR sharing (download / print) ─────────────────────────── + + +def _nwc_lnurl_context(wallet_identifier: str) -> tuple[dict | None, str | None, JSONResponse | None]: + """Resolve a wallet connection + domain for the LNURL QR endpoints. + + Returns ``(wallet, domain, error_response)``. On success the error is + ``None``; on failure the wallet is ``None`` and the error is a ready-made + ``JSONResponse``. Blocking — call via ``loop.run_in_executor``. + """ + domain = _nwc_domain() + if not domain: + return None, None, _nwc_error(503, "domain_not_configured", "Lightning Address domain is not configured.") + needle = wallet_identifier.strip().lower() + try: + wallets = _nwc_mgr.get_manager().list_wallets(domain) + except _nwc_mgr.AlbyHubError as exc: + return None, domain, _nwc_error(503, exc.code, exc.args[0]) + for wallet in wallets: + wid = str(wallet.get("id", "")).lower() + wpk = str(wallet.get("pubkey", "")).lower() + if needle and needle in (wid, wpk): + if not wallet.get("alias"): + return None, domain, _nwc_error(404, "wallet_not_found", "Wallet connection has no Lightning Address alias.") + return wallet, domain, None + return None, domain, _nwc_error(404, "wallet_not_found", "Wallet connection not found.") + + +def _nwc_lnurl_error_png() -> JSONResponse: + return _nwc_error(500, "qr_unavailable", "QR code generation failed on this system.") + + +@app.get("/api/nwc/wallets/{wallet_identifier}/lnurl") +async def api_nwc_wallet_lnurl(wallet_identifier: str): + """Return the bech32 LNURL + Lightning Address for a wallet connection.""" + loop = asyncio.get_event_loop() + wallet, domain, err = await loop.run_in_executor(None, _nwc_lnurl_context, wallet_identifier) + if err: + return err + return { + "alias": wallet["alias"], + "lightning_address": wallet.get("lightning_address"), + "lnurl": _nwc_lnurl_bech32(wallet["alias"], domain), + } + + +@app.get("/api/nwc/wallets/{wallet_identifier}/lnurl-qr.png") +async def api_nwc_wallet_lnurl_qr_png(wallet_identifier: str, download: bool = False, scale: int = 10): + """Serve the LNURL QR code as a PNG (uppercase LNURL for scannability). + + ``?download=1`` switches Content-Disposition to attachment so the browser + saves it with a meaningful filename. ``scale`` (qrencode pixel size) may be + raised for high-resolution display, e.g. the print page. + """ + loop = asyncio.get_event_loop() + wallet, domain, err = await loop.run_in_executor(None, _nwc_lnurl_context, wallet_identifier) + if err: + return err + scale = max(1, min(scale, 40)) + lnurl_qr = _nwc_lnurl_bech32(wallet["alias"], domain).upper() + png = await loop.run_in_executor(None, lambda: _generate_qr_png_bytes(lnurl_qr, scale=scale, margin=4)) + if png is None: + return _nwc_lnurl_error_png() + filename = f"lightning-{wallet['alias']}-{domain}.png" + disposition = "attachment" if download else "inline" + return Response( + content=png, + media_type="image/png", + headers={ + "Content-Disposition": f'{disposition}; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.get("/api/nwc/wallets/{wallet_identifier}/lnurl-qr.svg") +async def api_nwc_wallet_lnurl_qr_svg(wallet_identifier: str, download: bool = False): + """Serve the LNURL QR code as an SVG — resolution-independent, so it stays + perfectly sharp when printed at any size or embedded on a website.""" + loop = asyncio.get_event_loop() + wallet, domain, err = await loop.run_in_executor(None, _nwc_lnurl_context, wallet_identifier) + if err: + return err + lnurl_qr = _nwc_lnurl_bech32(wallet["alias"], domain).upper() + svg = await loop.run_in_executor(None, lambda: _generate_qr_svg(lnurl_qr, margin=4)) + if svg is None: + return _nwc_error(500, "qr_unavailable", "QR code generation failed on this system.") + filename = f"lightning-{wallet['alias']}-{domain}.svg" + disposition = "attachment" if download else "inline" + return Response( + content=svg.encode("utf-8"), + media_type="image/svg+xml", + headers={ + "Content-Disposition": f'{disposition}; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.get("/api/nwc/wallets/{wallet_identifier}/lnurl-qr/print") +async def api_nwc_wallet_lnurl_qr_print(wallet_identifier: str): + """Print-ready LNURL payment card — opens the browser print dialog.""" + loop = asyncio.get_event_loop() + wallet, domain, err = await loop.run_in_executor(None, _nwc_lnurl_context, wallet_identifier) + if err: + return err + address = wallet.get("lightning_address") or f"{wallet['alias']}@{domain}" + name = _html_escape(str(wallet.get("name") or "Wallet")) + addr_html = _html_escape(address) + img_src = f"/api/nwc/wallets/{urllib.parse.quote(wallet_identifier, safe='')}/lnurl-qr.png?scale=24" + html_doc = f""" + + + + +{addr_html} + + + +
+

Pay {name} with Bitcoin ⚡

+ LNURL QR code for {addr_html} +
{addr_html}
+
Scan with any Lightning wallet — this QR never expires.
+
+
+ + +
+ + +""" + return HTMLResponse(content=html_doc, headers={"Cache-Control": "no-store"}) + + # ── Security endpoints ──────────────────────────────────────────── diff --git a/app/sovran_systemsos_web/static/css/modals.css b/app/sovran_systemsos_web/static/css/modals.css index 0894990..17b967d 100644 --- a/app/sovran_systemsos_web/static/css/modals.css +++ b/app/sovran_systemsos_web/static/css/modals.css @@ -517,7 +517,32 @@ button.btn-reboot:hover:not(:disabled) { } .nwc-wallet-actions .matrix-form-back, -.nwc-wallet-actions .btn-close-modal { +.nwc-wallet-actions .btn-close-modal, +.nwc-wallet-actions .matrix-action-btn { padding: 8px 12px; font-size: 0.82rem; } + +/* ── NWC LNURL share / download view ───────────────────────────── */ + +.nwc-share-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 14px; +} + +.nwc-share-actions .matrix-form-back, +.nwc-share-actions .matrix-form-submit { + padding: 8px 12px; + font-size: 0.82rem; +} + +.nwc-lnurl-value { + font-size: 0.72rem; + word-break: break-all; +} + +.nwc-share-formats-hint { + text-align: center; +} diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index ad7ea37..85a7a9e 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -178,6 +178,64 @@ function _nwcRenderWalletState() { return; } + if (state.view === "share" && state.shareWallet) { + var sw = state.shareWallet; + var swId = encodeURIComponent(String(sw.id || sw.pubkey || "")); + var swAddress = sw.lightning_address || ((sw.alias && state.domain) ? sw.alias + "@" + state.domain : ""); + var pngUrl = "/api/nwc/wallets/" + swId + "/lnurl-qr.png"; + var shareAddrId = "nwc-share-addr-" + Math.random().toString(36).substring(2, 8); + var shareLnurlId = "nwc-share-lnurl-" + Math.random().toString(36).substring(2, 8); + html += '

Share the Lightning Address for ' + escHtml(sw.name || "Wallet") + '. This QR never expires — anyone can scan it with any Lightning wallet to send sats, as many times as they like.

' + + '
' + + 'LNURL QR code for ' + escHtml(swAddress) + '' + + '
Scan with any Lightning wallet to pay ' + escHtml(swAddress) + '
' + + '
' + + (swAddress + ? '
Lightning Address
' + escHtml(swAddress) + '
' + : "") + + '
LNURL
Loading…
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
PNG is ideal for websites and social posts. SVG stays sharp at any print size.
'; + host.innerHTML = html; + _attachCopyHandlers(host); + apiFetch("/api/nwc/wallets/" + swId + "/lnurl").then(function(payload) { + var el = document.getElementById(shareLnurlId); + if (el) el.textContent = payload.lnurl || "Unavailable"; + }).catch(function() { + var el = document.getElementById(shareLnurlId); + if (el) el.textContent = "Unavailable"; + }); + function _nwcShareDownload(url) { + var a = document.createElement("a"); + a.href = url; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + } + var dlPngBtn = document.getElementById("nwc-share-dl-png-btn"); + if (dlPngBtn) dlPngBtn.addEventListener("click", function() { _nwcShareDownload(pngUrl + "?download=1"); }); + var dlSvgBtn = document.getElementById("nwc-share-dl-svg-btn"); + if (dlSvgBtn) dlSvgBtn.addEventListener("click", function() { _nwcShareDownload("/api/nwc/wallets/" + swId + "/lnurl-qr.svg?download=1"); }); + var printBtn = document.getElementById("nwc-share-print-btn"); + if (printBtn) printBtn.addEventListener("click", function() { window.open("/api/nwc/wallets/" + swId + "/lnurl-qr/print", "_blank", "noopener"); }); + var shareBackBtn = document.getElementById("nwc-share-back-btn"); + if (shareBackBtn) { + shareBackBtn.addEventListener("click", function() { + state.view = "list"; + state.shareWallet = null; + _nwcClearMessage(); + _nwcRenderWalletState(); + }); + } + return; + } + var canCreate = !!state.domain && !state.busy; html += '
' + '' + @@ -219,6 +277,7 @@ function _nwcRenderWalletState() { '
Balance
' + escHtml(String(wallet.balance_sats || 0)) + ' sats
' + '
Pending TX
' + escHtml(String(wallet.pending_transactions || 0)) + '
' + '
' + + '' + '' + '' + '' + @@ -245,13 +304,32 @@ function _nwcRenderWalletState() { host.querySelectorAll(".nwc-wallet-action-btn").forEach(function(btn) { btn.addEventListener("click", function() { var action = btn.getAttribute("data-action"); - if (action === "test") _nwcVerifyWallet(btn.getAttribute("data-wallet-alias")); + if (action === "share") _nwcOpenShare(btn.getAttribute("data-wallet-id")); + else if (action === "test") _nwcVerifyWallet(btn.getAttribute("data-wallet-alias")); else if (action === "drain") _nwcDrainWallet(btn.getAttribute("data-wallet-id")); else if (action === "delete") _nwcDeleteWallet(btn.getAttribute("data-wallet-id")); }); }); } +function _nwcOpenShare(walletId) { + if (!_nwcModalState || _nwcModalState.busy || !walletId) return; + var wallet = null; + for (var i = 0; i < _nwcModalState.wallets.length; i++) { + var w = _nwcModalState.wallets[i]; + if (String(w.id || w.pubkey || "") === String(walletId)) { wallet = w; break; } + } + if (!wallet) { + _nwcSetMessage("error", "Wallet connection not found."); + _nwcRenderWalletState(); + return; + } + _nwcClearMessage(); + _nwcModalState.shareWallet = wallet; + _nwcModalState.view = "share"; + _nwcRenderWalletState(); +} + async function _nwcRefreshWallets() { if (!_nwcModalState) return; var host = _nwcWalletSectionEl(); @@ -260,7 +338,7 @@ async function _nwcRefreshWallets() { var payload = await apiFetch("/api/nwc/wallets"); _nwcModalState.wallets = Array.isArray(payload.wallets) ? payload.wallets : []; _nwcModalState.domain = payload.domain || null; - if (_nwcModalState.view !== "create" && _nwcModalState.view !== "created") { + if (_nwcModalState.view !== "create" && _nwcModalState.view !== "created" && _nwcModalState.view !== "share") { _nwcModalState.view = _nwcModalState.wallets.length > 0 ? "list" : "empty"; } _nwcRenderWalletState(); @@ -409,6 +487,7 @@ async function _nwcInitWalletFlow(unit, name, icon) { message: "", messageKind: "", lastCreated: null, + shareWallet: null, createForm: { name: "", alias: "",