Merge pull request #350 from naturallaw777/arena/019faaea-sovran-systemsos

NWC: downloadable & printable LNURL QR for wallet connections
This commit is contained in:
Sovran Systems
2026-07-28 18:53:00 -05:00
committed by GitHub
3 changed files with 346 additions and 9 deletions
+239 -6
View File
@@ -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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{addr_html}</title>
<style>
body {{ font-family: system-ui, sans-serif; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:95vh; margin:0; color:#111; }}
.card {{ text-align:center; padding:28px 36px; border:2px solid #111; border-radius:18px; max-width:90vw; }}
h1 {{ font-size:20px; margin:0 0 18px; font-weight:700; }}
img {{ width:64mm; max-width:70vw; image-rendering:pixelated; }}
.addr {{ font-family:ui-monospace, monospace; font-size:15px; margin-top:18px; word-break:break-all; }}
.hint {{ font-size:12px; color:#555; margin-top:10px; }}
.actions {{ margin-top:22px; display:flex; gap:12px; }}
.actions button {{ font:inherit; padding:10px 22px; border-radius:10px; border:1px solid #111; background:#111; color:#fff; cursor:pointer; }}
.actions button.secondary {{ background:#fff; color:#111; }}
@media print {{ .actions {{ display:none; }} body {{ min-height:auto; }} }}
</style>
</head>
<body>
<div class="card">
<h1>Pay {name} with Bitcoin ⚡</h1>
<img src="{img_src}" alt="LNURL QR code for {addr_html}">
<div class="addr">{addr_html}</div>
<div class="hint">Scan with any Lightning wallet — this QR never expires.</div>
</div>
<div class="actions">
<button onclick="window.print()">🖨 Print</button>
<button class="secondary" onclick="window.close()">Close</button>
</div>
<script>
window.addEventListener("load", function () {{
var img = document.querySelector("img");
function go() {{ setTimeout(function () {{ window.print(); }}, 250); }}
if (img && !img.complete) {{ img.addEventListener("load", go); img.addEventListener("error", go); }}
else {{ go(); }}
}});
</script>
</body>
</html>"""
return HTMLResponse(content=html_doc, headers={"Cache-Control": "no-store"})
# ── Security endpoints ────────────────────────────────────────────
+26 -1
View File
@@ -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;
}
@@ -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 += '<p class="svc-detail-desc">Share the Lightning Address for <strong>' + escHtml(sw.name || "Wallet") + '</strong>. This QR never expires — anyone can scan it with any Lightning wallet to send sats, as many times as they like.</p>' +
'<div class="creds-qr-wrap">' +
'<img class="creds-qr-img" src="' + pngUrl + '" alt="LNURL QR code for ' + escHtml(swAddress) + '">' +
'<div class="creds-qr-hint">Scan with any Lightning wallet to pay ' + escHtml(swAddress) + '</div>' +
'</div>' +
(swAddress
? '<div class="creds-row"><div class="creds-label">Lightning Address</div><div class="creds-value-wrap"><div class="creds-value" id="' + shareAddrId + '">' + escHtml(swAddress) + '</div><button class="creds-copy-btn" data-target="' + shareAddrId + '">Copy</button></div></div>'
: "") +
'<div class="creds-row"><div class="creds-label">LNURL</div><div class="creds-value-wrap"><div class="creds-value nwc-lnurl-value" id="' + shareLnurlId + '">Loading…</div><button class="creds-copy-btn" data-target="' + shareLnurlId + '">Copy</button></div></div>' +
'<div class="nwc-share-actions">' +
'<button class="matrix-form-submit" id="nwc-share-dl-png-btn">⬇ Download PNG</button>' +
'<button class="matrix-form-back" id="nwc-share-dl-svg-btn">⬇ Download SVG</button>' +
'<button class="matrix-form-back" id="nwc-share-print-btn">🖨 Print…</button>' +
'<button class="matrix-form-back" id="nwc-share-back-btn">← Back</button>' +
'</div>' +
'<div class="creds-qr-hint nwc-share-formats-hint">PNG is ideal for websites and social posts. SVG stays sharp at any print size.</div>';
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 += '<div class="matrix-actions-row">' +
'<button class="matrix-action-btn" id="nwc-open-create-btn"' + (canCreate ? "" : " disabled") + '>' + (state.domain ? ' Create Wallet Connection' : 'Configure Domain First') + '</button>' +
@@ -219,6 +277,7 @@ function _nwcRenderWalletState() {
'<div class="creds-row"><div class="creds-label">Balance</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(String(wallet.balance_sats || 0)) + ' sats</div></div></div>' +
'<div class="creds-row"><div class="creds-label">Pending TX</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(String(wallet.pending_transactions || 0)) + '</div></div></div>' +
'<div class="nwc-wallet-actions">' +
'<button class="matrix-action-btn nwc-wallet-action-btn" data-action="share" data-wallet-id="' + escHtml(id) + '"' + ((state.busy || !wallet.lightning_address) ? " disabled" : "") + '>⚡ Share QR</button>' +
'<button class="matrix-form-back nwc-wallet-action-btn" data-action="test" data-wallet-alias="' + escHtml(wallet.alias || "") + '"' + (state.busy ? " disabled" : "") + '>Verify Address</button>' +
'<button class="matrix-form-back nwc-wallet-action-btn" data-action="drain" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Drain</button>' +
'<button class="btn btn-close-modal nwc-wallet-action-btn" data-action="delete" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Delete</button>' +
@@ -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: "",