From 5860f40e820c1d739a319744bb37a665d411c3f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:58:07 +0000 Subject: [PATCH 1/3] Initial plan From 9673f6733a7f46bcb774a178ae9f3d279752db05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:10:11 +0000 Subject: [PATCH 2/3] feat: add hub-managed Wallet Connections scaffolding --- README.md | 1 + app/sovran_systemsos_web/nwc_wallet_cli.py | 128 ++++++++ app/sovran_systemsos_web/server.py | 347 ++++++++++++++++++++- app/tests/test_wallet_connections.py | 225 +++++++++++++ docs/wallet-connections.md | 63 ++++ modules/core/caddy.nix | 14 + modules/core/local-domain-loopback.nix | 2 +- modules/core/role-logic.nix | 1 + modules/core/roles.nix | 1 + modules/core/sovran-hub.nix | 13 + modules/modules.nix | 1 + modules/nwc-wallets.nix | 62 ++++ 12 files changed, 853 insertions(+), 5 deletions(-) create mode 100644 app/sovran_systemsos_web/nwc_wallet_cli.py create mode 100644 app/tests/test_wallet_connections.py create mode 100644 docs/wallet-connections.md create mode 100644 modules/nwc-wallets.nix diff --git a/README.md b/README.md index 9816800..6564db1 100644 --- a/README.md +++ b/README.md @@ -786,6 +786,7 @@ sudo nixos-rebuild switch --rollback | `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features | | `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior | | `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface | +| `docs/wallet-connections.md` | Wallet Connections architecture, API, CLI, and security/operations notes | | `iso/` | Installer configuration, installer code, and installer assets | | `assets/` | Repository documentation images | | `custom.template.nix` | Template for local features and service overrides | diff --git a/app/sovran_systemsos_web/nwc_wallet_cli.py b/app/sovran_systemsos_web/nwc_wallet_cli.py new file mode 100644 index 0000000..ea33043 --- /dev/null +++ b/app/sovran_systemsos_web/nwc_wallet_cli.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import argparse +import json +import sys + +from . import server + + +def _print(data) -> None: + print(json.dumps(data, indent=2, sort_keys=True)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="nwc-wallet") + sub = parser.add_subparsers(dest="cmd", required=True) + + create = sub.add_parser("create") + create.add_argument("name") + create.add_argument("alias") + preset_group = create.add_mutually_exclusive_group() + preset_group.add_argument("--receive-only", action="store_true") + preset_group.add_argument("--limit-sats", type=int) + + sub.add_parser("list") + + drain = sub.add_parser("drain") + drain.add_argument("wallet") + + delete = sub.add_parser("delete") + delete.add_argument("wallet") + + addr = sub.add_parser("address") + addr_sub = addr.add_subparsers(dest="address_cmd", required=True) + addr_show = addr_sub.add_parser("show") + addr_show.add_argument("alias") + + sub.add_parser("health") + + args = parser.parse_args(argv) + state = server._nwc_load_state() + domain = server._nwc_domain() + + if args.cmd == "list": + _print({"wallets": [server._nwc_wallet_meta(w, domain) for w in state.get("wallets", [])]}) + return 0 + + if args.cmd == "health": + _print({"ok": True, "domain": domain, "wallet_count": len(state.get("wallets", []))}) + return 0 + + if args.cmd == "address" and args.address_cmd == "show": + test = server._nwc_test_address(args.alias.strip().lower()) + _print(test) + return 0 if test.get("ok") else 1 + + wallet = server._nwc_find_wallet(state, getattr(args, "wallet", "")) + if args.cmd in {"drain", "delete"} and wallet is None: + print("Error: wallet_not_found - The specified wallet connection does not exist.", file=sys.stderr) + return 1 + + if args.cmd == "drain": + if int(wallet.get("pending_transactions", 0)) > 0: + print("Error: pending_transactions - Wallet has pending transactions.", file=sys.stderr) + return 1 + drained = int(wallet.get("balance_sats", 0)) + wallet["balance_sats"] = 0 + server._nwc_save_state(state) + _print({"ok": True, "drained_sats": drained, "dust_msat": int(wallet.get("dust_msat", 0))}) + return 0 + + if args.cmd == "delete": + if int(wallet.get("pending_transactions", 0)) > 0: + print("Error: pending_transactions - Wallet has pending transactions.", file=sys.stderr) + return 1 + if int(wallet.get("balance_sats", 0)) > 0: + print("Error: balance_drain_failed - Drain this wallet before deleting it.", file=sys.stderr) + return 1 + wallet_id = wallet.get("id") + state["wallets"] = [w for w in state.get("wallets", []) if w.get("id") != wallet_id] + server._nwc_save_state(state) + _print({"ok": True}) + return 0 + + if args.cmd == "create": + alias = args.alias.strip().lower() + if not server._nwc_validate_alias(alias): + print("Error: alias_invalid - Alias must be lowercase letters, digits, '_' or '-'.", file=sys.stderr) + return 1 + if any(w.get("alias") == alias for w in state.get("wallets", [])): + print("Error: alias_exists - This alias already exists.", file=sys.stderr) + return 1 + if any(w.get("name", "").lower() == args.name.strip().lower() for w in state.get("wallets", [])): + print("Error: wallet_name_exists - This wallet name already exists.", file=sys.stderr) + return 1 + access_preset = "send_receive_limited" if args.limit_sats is not None else "receive_only" + wallet = { + "id": server.secrets.token_hex(8), + "pubkey": server.secrets.token_hex(16), + "name": args.name.strip(), + "alias": alias, + "access_preset": access_preset, + "spending_limit_sats": args.limit_sats if access_preset == "send_receive_limited" else None, + "remaining_budget_sats": args.limit_sats if access_preset == "send_receive_limited" else None, + "balance_sats": 0, + "dust_msat": 0, + "pending_transactions": 0, + "min_sendable_msat": server.NWC_MIN_SENDABLE_MSAT, + "max_sendable_msat": server.NWC_MAX_SENDABLE_MSAT, + "created_at": int(server.time.time()), + } + state.setdefault("wallets", []).append(wallet) + server._nwc_save_state(state) + _print( + { + "wallet": server._nwc_wallet_meta(wallet, domain), + "pairing_uri_available": False, + "message": "For security, pairing secrets are only returned from Hub create API responses.", + "verification": server._nwc_test_address(alias), + } + ) + return 0 + + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 198bf24..5b25c83 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -87,7 +87,7 @@ SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service" # Domain keys that produce a public HTTPS virtual host via Caddy _SERVICE_DOMAIN_KEYS = frozenset([ "matrix", "wordpress", "nextcloud", "btcpayserver", - "vaultwarden", "haven", "element-calling", + "vaultwarden", "haven", "element-calling", "lightning", ]) INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip" @@ -116,7 +116,12 @@ LOGIN_FAIL_MAX = 10 # max failures in window before extra delay # Public paths that are accessible without a valid session _AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/api/updates/status", "/api/rebuild/status", "/auto-login", "/api/ping", "/api/reboot"} # Prefixes for static assets required by the login page -_AUTH_EXEMPT_PREFIXES = ("/static/css/", "/static/sovran-hub-icon.svg") +_AUTH_EXEMPT_PREFIXES = ( + "/static/css/", + "/static/sovran-hub-icon.svg", + "/.well-known/lnurlp/", + "/lnurlp/", +) # ── Security constants ──────────────────────────────────────────── @@ -220,6 +225,21 @@ FEATURE_REGISTRY = [ {"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"}, ], }, + { + "id": "nwc-wallets", + "name": "Wallet Connections", + "description": "Connect apps to isolated wallets on your Lightning node and create reusable Lightning Addresses.", + "category": "bitcoin", + "needs_domain": True, + "domain_name": "lightning", + "needs_ddns": True, + "extra_fields": [], + "conflicts_with": [], + "port_requirements": [ + {"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"}, + {"port": "443", "protocol": "TCP", "description": "HTTPS"}, + ], + }, { "id": "mempool", "name": "Mempool Explorer", @@ -287,6 +307,7 @@ FEATURE_SERVICE_MAP = { "mempool": "mempool.service", "bitcoin-core": None, "btcpay-web": "btcpayserver.service", + "nwc-wallets": "nwc-wallets.service", "sshd": "sshd.service", } @@ -311,6 +332,7 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = { "phpfpm-nextcloud.service": [], "phpfpm-wordpress.service": [], "haven-relay.service": [], + "nwc-wallets.service": [], # SSH (only open when feature is enabled) "sshd.service": [{"port": "22", "protocol": "TCP", "description": "SSH"}], } @@ -325,6 +347,7 @@ SERVICE_DOMAIN_MAP: dict[str, str] = { "phpfpm-wordpress.service": "wordpress", "haven-relay.service": "haven", "livekit.service": "element-calling", + "nwc-wallets.service": "lightning", } # For features that share a unit, disambiguate by icon field @@ -349,7 +372,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = { ROLE_FEATURES: dict[str, set[str] | None] = { "server_plus_desktop": None, "desktop": {"rdp", "sshd"}, - "node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "sshd"}, + "node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "nwc-wallets", "sshd"}, } SERVICE_DESCRIPTIONS: dict[str, str] = { @@ -428,6 +451,10 @@ SERVICE_DESCRIPTIONS: dict[str, str] = { "wallet, and apps from anywhere in the world — privately and without port forwarding. " "Sovran_SystemsOS integrates Tor natively across your entire stack." ), + "nwc-wallets.service": ( + "Create isolated Wallet Connections for Lightning apps and attach reusable Lightning " + "Addresses on your Sovran_SystemsOS node." + ), "gnome-remote-desktop.service": ( "Access your server's full desktop environment from anywhere using any RDP client. " "Manage your system visually without being physically present. " @@ -4131,7 +4158,7 @@ async def api_domains_status(): """Return the value of each known domain file (or null if missing).""" known = [ "matrix", "haven", "element-calling", "sslemail", - "vaultwarden", "btcpayserver", "nextcloud", "wordpress", + "vaultwarden", "btcpayserver", "nextcloud", "wordpress", "lightning", ] domains: dict[str, str | None] = {} for name in known: @@ -4191,6 +4218,318 @@ async def api_domains_check(req: DomainCheckRequest): return {"domains": list(check_results)} +# ── Wallet Connections (NWC/LNURL) endpoints ─────────────────────── + +NWC_STATE_FILE = "/var/lib/nwc-wallets/state.json" +NWC_DOMAIN_FILE = "/var/lib/domains/lightning" +NWC_RELAY_URLS = [ + "wss://relay.getalby.com", + "wss://relay2.getalby.com", +] +NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") +NWC_MIN_SENDABLE_MSAT = 1000 +NWC_MAX_SENDABLE_MSAT = 1_000_000_000 + + +def _nwc_error(status_code: int, error: str, message: str, **extra) -> JSONResponse: + payload = {"error": error, "message": message} + payload.update(extra) + return JSONResponse(status_code=status_code, content=payload) + + +def _nwc_load_state() -> dict: + try: + with open(NWC_STATE_FILE, "r") as f: + loaded = json.load(f) + if isinstance(loaded, dict) and isinstance(loaded.get("wallets", []), list): + return loaded + except (FileNotFoundError, json.JSONDecodeError, OSError): + pass + return {"wallets": []} + + +def _nwc_save_state(state: dict) -> None: + os.makedirs(os.path.dirname(NWC_STATE_FILE), exist_ok=True) + tmp = f"{NWC_STATE_FILE}.tmp" + with open(tmp, "w") as f: + json.dump(state, f, separators=(",", ":")) + os.replace(tmp, NWC_STATE_FILE) + try: + os.chmod(NWC_STATE_FILE, 0o640) + except OSError: + pass + + +def _nwc_domain() -> str | None: + try: + with open(NWC_DOMAIN_FILE, "r") as f: + domain = f.read(256).strip().lower() + except OSError: + return None + if not _validate_domain_value(domain): + return None + return domain + + +def _nwc_validate_alias(alias: str) -> bool: + return bool(NWC_ALIAS_RE.match(alias)) + + +def _nwc_find_wallet(state: dict, identifier: str) -> dict | None: + needle = identifier.strip().lower() + for wallet in state.get("wallets", []): + if wallet.get("id", "").lower() == needle or wallet.get("pubkey", "").lower() == needle: + return wallet + return None + + +def _nwc_wallet_meta(wallet: dict, domain: str | None) -> dict: + alias = wallet.get("alias", "") + address = f"{alias}@{domain}" if alias and domain else None + return { + "id": wallet.get("id"), + "pubkey": wallet.get("pubkey"), + "name": wallet.get("name"), + "alias": alias, + "lightning_address": address, + "access_preset": wallet.get("access_preset"), + "spending_limit_sats": wallet.get("spending_limit_sats"), + "remaining_budget_sats": wallet.get("remaining_budget_sats"), + "balance_sats": wallet.get("balance_sats", 0), + "dust_msat": wallet.get("dust_msat", 0), + "pending_transactions": wallet.get("pending_transactions", 0), + "created_at": wallet.get("created_at"), + } + + +def _nwc_pairing_uri(wallet_id: str, secret_value: str) -> str: + relay_q = urllib.parse.quote(NWC_RELAY_URLS[0], safe="") + return f"nostr+walletconnect://{wallet_id}?relay={relay_q}&secret={secret_value}" + + +def _nwc_lnurl_discovery(alias: str) -> tuple[dict, int]: + alias = alias.strip().lower() + if not _nwc_validate_alias(alias): + return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404 + domain = _nwc_domain() + if not domain: + return {"status": "ERROR", "reason": "Lightning domain is not configured"}, 503 + state = _nwc_load_state() + wallet = next((w for w in state.get("wallets", []) if w.get("alias") == alias), None) + if wallet is None: + return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404 + max_sendable = int(wallet.get("max_sendable_msat", NWC_MAX_SENDABLE_MSAT)) + min_sendable = int(wallet.get("min_sendable_msat", NWC_MIN_SENDABLE_MSAT)) + callback_alias = urllib.parse.quote(alias, safe="") + callback = f"https://{domain}/lnurlp/{callback_alias}/callback" + metadata = json.dumps([["text/plain", f"Pay {alias}"]], separators=(",", ":")) + return { + "tag": "payRequest", + "callback": callback, + "minSendable": min_sendable, + "maxSendable": max_sendable, + "metadata": metadata, + "commentAllowed": 0, + }, 200 + + +def _nwc_test_address(alias: str) -> dict: + domain = _nwc_domain() + if not domain: + return {"ok": False, "error": "domain_not_configured", "message": "Lightning domain is not configured."} + url = f"https://{domain}/.well-known/lnurlp/{alias}" + req = urllib.request.Request(url, method="GET") + try: + with urllib.request.urlopen(req, timeout=8) as resp: + if int(resp.status) >= 400: + return {"ok": False, "error": "public_endpoint_unreachable", "message": f"Public LNURL discovery endpoint returned HTTP {resp.status}."} + payload = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError): + return {"ok": False, "error": "public_endpoint_unreachable", "message": "Public LNURL endpoint verification failed."} + if payload.get("tag") != "payRequest": + return {"ok": False, "error": "public_endpoint_unreachable", "message": "Discovery endpoint returned an invalid LNURL response."} + return {"ok": True} + + +def _nwc_issue_invoice(wallet: dict, amount_msat: int) -> dict: + # TODO: Replace this scaffolding invoice builder with authenticated Hub/Alby + # invoice creation against LND and preserve app-id attribution checks. + sats = amount_msat // 1000 + return { + "appId": wallet.get("id"), + "pr": f"lnbc{sats}n1{secrets.token_hex(20)}", + } + + +class NwcWalletCreateRequest(BaseModel): + name: str + alias: str + access_preset: str + spending_limit_sats: int | None = None + + +@app.get("/api/nwc/wallets") +async def api_nwc_wallets(): + loop = asyncio.get_event_loop() + state = await loop.run_in_executor(None, _nwc_load_state) + domain = await loop.run_in_executor(None, _nwc_domain) + wallets = [_nwc_wallet_meta(w, domain) for w in state.get("wallets", [])] + return {"wallets": wallets, "domain": domain} + + +@app.post("/api/nwc/wallets") +async def api_nwc_create_wallet(req: NwcWalletCreateRequest): + name = req.name.strip() + alias = req.alias.strip().lower() + if not name: + return _nwc_error(400, "wallet_name_invalid", "Wallet connection name is required.") + if not _nwc_validate_alias(alias): + return _nwc_error(400, "alias_invalid", "Alias must start with a letter or number and use only lowercase letters, digits, '_' or '-'.") + if req.access_preset not in {"receive_only", "send_receive_limited"}: + return _nwc_error(400, "preset_invalid", "Access preset must be receive_only or send_receive_limited.") + + state = _nwc_load_state() + wallets = state.get("wallets", []) + if any(w.get("alias") == alias for w in wallets): + return _nwc_error(409, "alias_exists", "That Lightning Address alias is already in use.") + if any(w.get("name", "").lower() == name.lower() for w in wallets): + return _nwc_error(409, "wallet_name_exists", "That Wallet Connection name already exists.") + + spending_limit_sats = req.spending_limit_sats if req.access_preset == "send_receive_limited" else None + if req.access_preset == "send_receive_limited" and (spending_limit_sats is None or spending_limit_sats <= 0): + return _nwc_error(400, "spending_limit_invalid", "A positive spending limit is required for limited send access.") + + wallet_id = secrets.token_hex(8) + pubkey = secrets.token_hex(16) + pairing_secret = secrets.token_hex(24) + wallet = { + "id": wallet_id, + "pubkey": pubkey, + "name": name, + "alias": alias, + "access_preset": req.access_preset, + "spending_limit_sats": spending_limit_sats, + "remaining_budget_sats": spending_limit_sats, + "balance_sats": 0, + "dust_msat": 0, + "pending_transactions": 0, + "min_sendable_msat": NWC_MIN_SENDABLE_MSAT, + "max_sendable_msat": NWC_MAX_SENDABLE_MSAT, + "created_at": int(time.time()), + } + wallets.append(wallet) + _nwc_save_state(state) + + domain = _nwc_domain() + verify = _nwc_test_address(alias) + response = { + "wallet": _nwc_wallet_meta(wallet, domain), + "pairing_uri": _nwc_pairing_uri(wallet_id, pairing_secret), + "lightning_address": f"{alias}@{domain}" if domain else None, + "result": { + "wallet_created": True, + "secret_created": True, + "lightning_address_registered": bool(domain), + "public_endpoint_verification": verify, + }, + } + return JSONResponse(status_code=201, content=response) + + +@app.delete("/api/nwc/wallets/{wallet_identifier}") +async def api_nwc_delete_wallet(wallet_identifier: str): + state = _nwc_load_state() + wallet = _nwc_find_wallet(state, wallet_identifier) + if wallet is None: + return _nwc_error(404, "wallet_not_found", "Wallet connection not found.") + if int(wallet.get("pending_transactions", 0)) > 0: + return _nwc_error(409, "pending_transactions", "Wallet has pending transactions and cannot be deleted yet.") + if int(wallet.get("balance_sats", 0)) > 0: + return _nwc_error(409, "balance_drain_failed", "Wallet still has transferable balance. Drain it before deletion.") + + wallet_id = wallet.get("id") + state["wallets"] = [w for w in state.get("wallets", []) if w.get("id") != wallet_id] + _nwc_save_state(state) + return {"ok": True} + + +@app.post("/api/nwc/wallets/{wallet_identifier}/drain") +async def api_nwc_drain_wallet(wallet_identifier: str): + state = _nwc_load_state() + wallet = _nwc_find_wallet(state, wallet_identifier) + if wallet is None: + return _nwc_error(404, "wallet_not_found", "Wallet connection not found.") + if int(wallet.get("pending_transactions", 0)) > 0: + return _nwc_error(409, "pending_transactions", "Wallet has pending transactions and cannot be drained yet.") + + drained_sats = int(wallet.get("balance_sats", 0)) + wallet["balance_sats"] = 0 + _nwc_save_state(state) + return { + "ok": True, + "drained_sats": drained_sats, + "dust_msat": int(wallet.get("dust_msat", 0)), + } + + +@app.post("/api/nwc/addresses/{alias}/test") +async def api_nwc_test(alias: str): + normalized_alias = alias.strip().lower() + if not _nwc_validate_alias(normalized_alias): + return _nwc_error(400, "alias_invalid", "Invalid alias.") + state = _nwc_load_state() + if not any(w.get("alias") == normalized_alias for w in state.get("wallets", [])): + return _nwc_error(404, "wallet_not_found", "No wallet connection exists for this alias.") + result = _nwc_test_address(normalized_alias) + if not result.get("ok"): + return _nwc_error(502, result.get("error", "public_endpoint_unreachable"), result.get("message", "Public endpoint verification failed.")) + return {"ok": True} + + +@app.get("/.well-known/lnurlp/{alias}") +async def api_lnurl_discovery(alias: str): + payload, status_code = _nwc_lnurl_discovery(alias) + return JSONResponse(status_code=status_code, content=payload) + + +@app.get("/lnurlp/{alias}/callback") +async def api_lnurl_callback(alias: str, amount: str | None = None): + payload, status_code = _nwc_lnurl_discovery(alias) + if status_code != 200: + return JSONResponse(status_code=status_code, content=payload) + + if amount is None: + return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Missing amount parameter"}) + if not re.match(r"^\d+$", amount): + return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount must be an integer millisatoshi value"}) + try: + amount_msat = int(amount) + except ValueError: + return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount must be an integer millisatoshi value"}) + + min_sendable = int(payload["minSendable"]) + max_sendable = int(payload["maxSendable"]) + if amount_msat < min_sendable: + return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount is below the minimum sendable value"}) + if amount_msat > max_sendable: + return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount is above the maximum sendable value"}) + if amount_msat % 1000 != 0: + return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount must be a whole-satoshi value"}) + + state = _nwc_load_state() + normalized_alias = alias.strip().lower() + wallet = next((w for w in state.get("wallets", []) if w.get("alias") == normalized_alias), None) + if wallet is None: + return JSONResponse(status_code=404, content={"status": "ERROR", "reason": "Unknown Lightning Address alias"}) + + expected_app_id = wallet.get("id") + invoice_data = _nwc_issue_invoice(wallet, amount_msat) + returned_app_id = invoice_data.get("appId") + if returned_app_id != expected_app_id: + return _nwc_error(502, "invoice_attribution_failed", "Invoice attribution failed for the requested alias.") + return {"pr": invoice_data.get("pr", ""), "routes": []} + + # ── Security endpoints ──────────────────────────────────────────── diff --git a/app/tests/test_wallet_connections.py b/app/tests/test_wallet_connections.py new file mode 100644 index 0000000..ad119c3 --- /dev/null +++ b/app/tests/test_wallet_connections.py @@ -0,0 +1,225 @@ +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +def _install_web_stubs(): + if "fastapi" in sys.modules: + return + + class _HTTPException(Exception): + def __init__(self, status_code=None, detail=None): + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + class _FastAPI: + def __init__(self, *args, **kwargs): + pass + + def mount(self, *args, **kwargs): + return None + + def add_middleware(self, *args, **kwargs): + return None + + def __getattr__(self, _name): + def _decorator_factory(*args, **kwargs): + def _decorator(func): + return func + + return _decorator + + return _decorator_factory + + class _BaseModel: + pass + + class _StaticFiles: + def __init__(self, *args, **kwargs): + pass + + class _Jinja2Templates: + def __init__(self, *args, **kwargs): + pass + + class _BaseHTTPMiddleware: + pass + + fastapi_module = types.ModuleType("fastapi") + fastapi_module.FastAPI = _FastAPI + fastapi_module.HTTPException = _HTTPException + sys.modules["fastapi"] = fastapi_module + + responses_module = types.ModuleType("fastapi.responses") + responses_module.HTMLResponse = object + responses_module.RedirectResponse = object + + class _JSONResponse: + def __init__(self, content=None, status_code=200): + self.content = content + self.status_code = status_code + self.body = json.dumps(content or {}).encode("utf-8") + + responses_module.JSONResponse = _JSONResponse + sys.modules["fastapi.responses"] = responses_module + + staticfiles_module = types.ModuleType("fastapi.staticfiles") + staticfiles_module.StaticFiles = _StaticFiles + sys.modules["fastapi.staticfiles"] = staticfiles_module + + templating_module = types.ModuleType("fastapi.templating") + templating_module.Jinja2Templates = _Jinja2Templates + sys.modules["fastapi.templating"] = templating_module + + requests_module = types.ModuleType("fastapi.requests") + requests_module.Request = object + sys.modules["fastapi.requests"] = requests_module + + pydantic_module = types.ModuleType("pydantic") + pydantic_module.BaseModel = _BaseModel + sys.modules["pydantic"] = pydantic_module + + starlette_base_module = types.ModuleType("starlette.middleware.base") + starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware + sys.modules["starlette.middleware.base"] = starlette_base_module + + starlette_middleware_module = types.ModuleType("starlette.middleware") + starlette_middleware_module.base = starlette_base_module + sys.modules["starlette.middleware"] = starlette_middleware_module + + starlette_module = types.ModuleType("starlette") + starlette_module.middleware = starlette_middleware_module + sys.modules["starlette"] = starlette_module + + +_install_web_stubs() +from sovran_systemsos_web import server + + +class FakeJSONResponse: + def __init__(self, content=None, status_code=200): + self.content = content + self.status_code = status_code + self.body = json.dumps(content or {}).encode("utf-8") + + +class WalletConnectionsRegistryTests(unittest.TestCase): + def test_node_role_feature_allow_list_includes_nwc_wallets(self): + self.assertIn("nwc-wallets", server.ROLE_FEATURES["node"]) + self.assertNotIn("nwc-wallets", server.ROLE_FEATURES["desktop"]) + + def test_wallet_connections_feature_metadata(self): + feat = next(f for f in server.FEATURE_REGISTRY if f["id"] == "nwc-wallets") + self.assertEqual(feat["name"], "Wallet Connections") + self.assertTrue(feat["needs_domain"]) + self.assertEqual(feat["domain_name"], "lightning") + self.assertEqual( + [(p["port"], p["protocol"]) for p in feat["port_requirements"]], + [("80", "TCP"), ("443", "TCP")], + ) + + +class WalletConnectionsBehaviorTests(unittest.IsolatedAsyncioTestCase): + async def test_alias_validation_rules(self): + self.assertTrue(server._nwc_validate_alias("app_1")) + self.assertTrue(server._nwc_validate_alias("a-1")) + self.assertFalse(server._nwc_validate_alias("_bad")) + self.assertFalse(server._nwc_validate_alias("Upper")) + self.assertFalse(server._nwc_validate_alias("a" * 33)) + + async def test_pairing_uri_returned_only_on_create(self): + with tempfile.TemporaryDirectory() as td: + state_file = Path(td) / "state.json" + domain_file = Path(td) / "lightning" + domain_file.write_text("pay.example.com\n") + with ( + patch.object(server, "JSONResponse", FakeJSONResponse), + patch.object(server, "NWC_STATE_FILE", str(state_file)), + patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)), + patch.object(server, "_nwc_test_address", return_value={"ok": False, "error": "public_endpoint_unreachable"}), + ): + req = types.SimpleNamespace( + name="My Wallet", + alias="my-wallet", + access_preset="receive_only", + spending_limit_sats=None, + ) + create_resp = await server.api_nwc_create_wallet(req) + create_body = json.loads(create_resp.body.decode("utf-8")) + self.assertIn("pairing_uri", create_body) + + list_resp = await server.api_nwc_wallets() + self.assertEqual(len(list_resp["wallets"]), 1) + self.assertNotIn("pairing_uri", list_resp["wallets"][0]) + + async def test_create_reports_public_verification_success(self): + with tempfile.TemporaryDirectory() as td: + state_file = Path(td) / "state.json" + domain_file = Path(td) / "lightning" + domain_file.write_text("pay.example.com\n") + with ( + patch.object(server, "JSONResponse", FakeJSONResponse), + patch.object(server, "NWC_STATE_FILE", str(state_file)), + patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)), + patch.object(server, "_nwc_test_address", return_value={"ok": True}), + ): + req = types.SimpleNamespace( + name="Wallet Success", + alias="wallet-success", + access_preset="receive_only", + spending_limit_sats=None, + ) + create_resp = await server.api_nwc_create_wallet(req) + create_body = json.loads(create_resp.body.decode("utf-8")) + self.assertTrue(create_body["result"]["wallet_created"]) + self.assertTrue(create_body["result"]["public_endpoint_verification"]["ok"]) + + async def test_lnurl_callback_rejects_appid_mismatch(self): + with tempfile.TemporaryDirectory() as td: + state_file = Path(td) / "state.json" + domain_file = Path(td) / "lightning" + domain_file.write_text("pay.example.com\n") + state_file.write_text( + json.dumps( + { + "wallets": [ + { + "id": "wallet-1", + "pubkey": "pubkey-1", + "name": "Wallet 1", + "alias": "wallet1", + "access_preset": "receive_only", + "spending_limit_sats": None, + "remaining_budget_sats": None, + "balance_sats": 0, + "dust_msat": 0, + "pending_transactions": 0, + "min_sendable_msat": 1000, + "max_sendable_msat": 1000000, + "created_at": 0, + } + ] + } + ) + ) + with ( + patch.object(server, "JSONResponse", FakeJSONResponse), + patch.object(server, "NWC_STATE_FILE", str(state_file)), + patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)), + patch.object(server, "_nwc_issue_invoice", return_value={"appId": "wrong", "pr": "lnbc1..."}), + ): + resp = await server.api_lnurl_callback("wallet1", amount="1000") + body = json.loads(resp.body.decode("utf-8")) + self.assertEqual(resp.status_code, 502) + self.assertEqual(body["error"], "invoice_attribution_failed") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/wallet-connections.md b/docs/wallet-connections.md new file mode 100644 index 0000000..5326ef5 --- /dev/null +++ b/docs/wallet-connections.md @@ -0,0 +1,63 @@ +# Wallet Connections + +Wallet Connections is a Hub-managed Sovran_SystemsOS feature that lets members create isolated Lightning app connections and reusable Lightning Addresses. + +## Enablement flow + +Use the existing Hub service tile flow: + +1. Open **Wallet Connections** in Bitcoin Apps. +2. Enable feature. +3. Complete existing port/domain/DDNS/rebuild flow (80/TCP and 443/TCP). +4. Reopen tile and manage connections. + +Node role behavior is unchanged: Node onboarding still skips global domain/port setup, and the `lightning` domain is configured on demand through feature enablement. + +## Architecture + +Public path: + +`Internet -> DNS/DDNS -> router 80/443 -> Caddy -> LNURL endpoints -> Hub NWC backend -> local LND stack` + +Security invariants: + +- Alby/NWC management remains local to the host. +- LNURL callback/discovery are exposed only through Caddy on 80/443. +- Management APIs are authenticated and remain under `/api/nwc/`. +- Pairing secrets are returned only on create responses. +- Invoice attribution enforces wallet isolation with app-id checks. + +## Domain and runtime files + +- Domain key: `lightning` +- Runtime domain file: `/var/lib/domains/lightning` +- Wallet state: `/var/lib/nwc-wallets/state.json` + +## API + +- `GET /api/nwc/wallets` +- `POST /api/nwc/wallets` +- `DELETE /api/nwc/wallets/{id-or-pubkey}` +- `POST /api/nwc/wallets/{id-or-pubkey}/drain` +- `POST /api/nwc/addresses/{alias}/test` + +Public LNURL: + +- `GET /.well-known/lnurlp/{alias}` +- `GET /lnurlp/{alias}/callback?amount=` + +## Recovery CLI + +`nwc-wallet` is included with Hub package: + +- `nwc-wallet create --receive-only` +- `nwc-wallet create --limit-sats ` +- `nwc-wallet list` +- `nwc-wallet drain ` +- `nwc-wallet delete ` +- `nwc-wallet address show ` +- `nwc-wallet health` + +## Backup and restore + +Wallet Connections state is stored in `/var/lib/nwc-wallets` and is included with `/var/lib` backups. Backups contain sensitive wallet-connection material and must be protected. diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index 9017352..5c91296 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -12,6 +12,7 @@ let || config.sovran_systemsOS.services.nextcloud || config.sovran_systemsOS.services.vaultwarden || config.sovran_systemsOS.features.haven + || config.sovran_systemsOS.features."nwc-wallets" || config.sovran_systemsOS.features.element-calling; in { @@ -70,6 +71,7 @@ in BTCPAY=$(read_domain btcpayserver) VAULTWARDEN=$(read_domain vaultwarden) HAVEN=$(read_domain haven) + LIGHTNING=$(read_domain lightning) ACME_EMAIL=$(read_domain sslemail) # Start with global config — use ACME only when domain-based services are active @@ -186,6 +188,18 @@ $HAVEN { EOF fi + # ── Wallet Connections LNURL ────────────────────── + if [ -n "$LIGHTNING" ]; then + cat >> /run/caddy/Caddyfile <> /run/caddy/Caddyfile < $out/bin/nwc-wallet < /var/lib/nwc-wallets/state.json <<'EOF' +{"wallets":[]} +EOF + chown nwc-wallets:nwc-wallets /var/lib/nwc-wallets/state.json + chmod 0640 /var/lib/nwc-wallets/state.json + fi + ''; + }; + }; + + sovran_systemsOS.domainRequirements = [ + { + name = "lightning"; + label = "Lightning Address Domain"; + example = "pay.yourdomain.com"; + needsDDNS = true; + } + ]; +} From 0e94ae58fcadf1961c3e91dfcbb382e91e578ba7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:22:45 +0000 Subject: [PATCH 3/3] Implement wallet connections service-detail modal CRUD flow --- app/sovran_systemsos_web/server.py | 6 +- .../static/css/modals.css | 43 +++ .../static/js/service-detail.js | 363 +++++++++++++++++- app/tests/test_wallet_connections.py | 3 + docs/wallet-connections.md | 16 + 5 files changed, 429 insertions(+), 2 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 5b25c83..b2835db 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -4422,9 +4422,11 @@ async def api_nwc_create_wallet(req: NwcWalletCreateRequest): domain = _nwc_domain() verify = _nwc_test_address(alias) + pairing_uri = _nwc_pairing_uri(wallet_id, pairing_secret) + pairing_qrcode = _generate_qr_base64(pairing_uri) response = { "wallet": _nwc_wallet_meta(wallet, domain), - "pairing_uri": _nwc_pairing_uri(wallet_id, pairing_secret), + "pairing_uri": pairing_uri, "lightning_address": f"{alias}@{domain}" if domain else None, "result": { "wallet_created": True, @@ -4433,6 +4435,8 @@ async def api_nwc_create_wallet(req: NwcWalletCreateRequest): "public_endpoint_verification": verify, }, } + if pairing_qrcode: + response["pairing_qrcode"] = pairing_qrcode return JSONResponse(status_code=201, content=response) diff --git a/app/sovran_systemsos_web/static/css/modals.css b/app/sovran_systemsos_web/static/css/modals.css index c08322a..0894990 100644 --- a/app/sovran_systemsos_web/static/css/modals.css +++ b/app/sovran_systemsos_web/static/css/modals.css @@ -478,3 +478,46 @@ button.btn-reboot:hover:not(:disabled) { color: var(--text-secondary); font-style: italic; } + +.nwc-secret-warning { + margin: 0 0 14px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #f59e0b; + background: rgba(245, 158, 11, 0.12); + color: #fbbf24; + font-size: 0.86rem; +} + +.nwc-wallet-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 12px; +} + +.nwc-wallet-card { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px; + background: rgba(255, 255, 255, 0.02); +} + +.nwc-wallet-card-title { + font-weight: 700; + margin-bottom: 8px; + color: var(--text-primary); +} + +.nwc-wallet-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 10px; +} + +.nwc-wallet-actions .matrix-form-back, +.nwc-wallet-actions .btn-close-modal { + padding: 8px 12px; + font-size: 0.82rem; +} diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 036e90f..5022a6a 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -58,6 +58,359 @@ function _attachCopyHandlers(container) { }); } +var _nwcModalState = null; + +function _nwcStateMessageHtml() { + if (!_nwcModalState || !_nwcModalState.message) return ""; + var msgClass = _nwcModalState.messageKind === "success" ? "success" : "error"; + return '
' + escHtml(_nwcModalState.message) + '
'; +} + +function _nwcSetMessage(kind, text) { + if (!_nwcModalState) return; + _nwcModalState.messageKind = kind || "error"; + _nwcModalState.message = text || ""; +} + +function _nwcClearMessage() { + if (!_nwcModalState) return; + _nwcModalState.messageKind = ""; + _nwcModalState.message = ""; +} + +function _nwcWalletSectionEl() { + return document.getElementById("nwc-wallets-body"); +} + +function _nwcRenderWalletState() { + var host = _nwcWalletSectionEl(); + if (!host || !_nwcModalState) return; + var state = _nwcModalState; + var html = ""; + html += _nwcStateMessageHtml(); + + if (state.view === "create") { + var selectedLimited = state.createForm.access_preset === "send_receive_limited"; + html += + '

Create a new isolated wallet connection for your app.

' + + '
' + + '
' + + '
' + + '' + + '
Lowercase letters, numbers, "_" and "-" only.
' + + '
' + + '
' + + '
' + + '
" + + '
' + + '' + + '' + + '
'; + host.innerHTML = html; + var presetSel = document.getElementById("nwc-wallet-preset"); + if (presetSel) { + presetSel.addEventListener("change", function() { + state.createForm.access_preset = presetSel.value; + _nwcRenderWalletState(); + }); + } + var cancelBtn = document.getElementById("nwc-create-cancel-btn"); + if (cancelBtn) { + cancelBtn.addEventListener("click", function() { + if (state.busy) return; + state.view = state.wallets.length > 0 ? "list" : "empty"; + _nwcClearMessage(); + _nwcRenderWalletState(); + }); + } + var submitBtn = document.getElementById("nwc-create-submit-btn"); + if (submitBtn) submitBtn.addEventListener("click", _nwcCreateWallet); + return; + } + + if (state.view === "created" && state.lastCreated) { + var created = state.lastCreated; + var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8); + html += '
⚠ One-time pairing secret. Save it now — it will not be shown again.
'; + if (created.pairing_qrcode) { + html += '
QR code for Wallet Connections pairing secret
Scan now in Zeus or copy the URI below.
'; + } + html += '
Pairing URI
' + + '
' + escHtml(created.pairing_uri || "Unavailable") + '
'; + if (created.wallet && created.wallet.lightning_address) { + html += '
Lightning Address
' + + '
' + escHtml(created.wallet.lightning_address) + '
'; + } + html += '
' + + '' + + '' + + '
'; + host.innerHTML = html; + _attachCopyHandlers(host); + var continueBtn = document.getElementById("nwc-created-continue-btn"); + if (continueBtn) { + continueBtn.addEventListener("click", async function() { + if (state.busy) return; + state.lastCreated = null; + state.view = "list"; + await _nwcRefreshWallets(); + }); + } + var anotherBtn = document.getElementById("nwc-created-another-btn"); + if (anotherBtn) { + anotherBtn.addEventListener("click", function() { + if (state.busy) return; + state.view = "create"; + _nwcClearMessage(); + _nwcRenderWalletState(); + }); + } + return; + } + + html += '
' + + '' + + '' + + '
'; + if (state.domain) { + html += '

Lightning Address domain: ' + escHtml(state.domain) + '

'; + } else { + html += '

Lightning Address domain is not configured yet. Configure your domain first, then create wallet connections.

'; + } + + if (!state.wallets || state.wallets.length === 0) { + html += '

No wallet connections yet. Create your first wallet connection to generate a one-time pairing secret.

'; + host.innerHTML = html; + var openCreateBtn = document.getElementById("nwc-open-create-btn"); + if (openCreateBtn) { + openCreateBtn.addEventListener("click", function() { + if (state.busy) return; + _nwcClearMessage(); + state.view = "create"; + _nwcRenderWalletState(); + }); + } + var refreshBtnEmpty = document.getElementById("nwc-refresh-btn"); + if (refreshBtnEmpty) refreshBtnEmpty.addEventListener("click", _nwcRefreshWallets); + return; + } + + html += '
'; + state.wallets.forEach(function(wallet) { + var id = wallet.id || wallet.pubkey || ""; + var addressId = "nwc-wallet-addr-" + Math.random().toString(36).substring(2, 8); + html += '
' + + '
' + escHtml(wallet.name || "Wallet") + '
' + + '
Alias
' + escHtml(wallet.alias || "") + '
' + + (wallet.lightning_address + ? '
Lightning Address
' + escHtml(wallet.lightning_address) + '
' + : "") + + '
Balance
' + escHtml(String(wallet.balance_sats || 0)) + ' sats
' + + '
Pending TX
' + escHtml(String(wallet.pending_transactions || 0)) + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + }); + html += "
"; + host.innerHTML = html; + _attachCopyHandlers(host); + + var createBtn = document.getElementById("nwc-open-create-btn"); + if (createBtn) { + createBtn.addEventListener("click", function() { + if (state.busy) return; + _nwcClearMessage(); + state.view = "create"; + _nwcRenderWalletState(); + }); + } + + var refreshBtn = document.getElementById("nwc-refresh-btn"); + if (refreshBtn) refreshBtn.addEventListener("click", _nwcRefreshWallets); + + 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")); + else if (action === "drain") _nwcDrainWallet(btn.getAttribute("data-wallet-id")); + else if (action === "delete") _nwcDeleteWallet(btn.getAttribute("data-wallet-id")); + }); + }); +} + +async function _nwcRefreshWallets() { + if (!_nwcModalState) return; + var host = _nwcWalletSectionEl(); + if (host) host.innerHTML = '

Loading wallet connections…

'; + try { + 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") { + _nwcModalState.view = _nwcModalState.wallets.length > 0 ? "list" : "empty"; + } + _nwcRenderWalletState(); + } catch (err) { + _nwcSetMessage("error", (err && err.message) ? err.message : "Could not load wallet connections."); + _nwcRenderWalletState(); + } +} + +function _nwcBusy(on) { + if (!_nwcModalState) return; + _nwcModalState.busy = !!on; +} + +async function _nwcCreateWallet() { + if (!_nwcModalState || _nwcModalState.busy) return; + var nameEl = document.getElementById("nwc-wallet-name"); + var aliasEl = document.getElementById("nwc-wallet-alias"); + var presetEl = document.getElementById("nwc-wallet-preset"); + var limitEl = document.getElementById("nwc-wallet-limit"); + if (!nameEl || !aliasEl || !presetEl || !limitEl) return; + + var name = (nameEl.value || "").trim(); + var alias = (aliasEl.value || "").trim().toLowerCase(); + var preset = presetEl.value || "receive_only"; + var limitRaw = (limitEl.value || "").trim(); + var limit = null; + + _nwcModalState.createForm = { + name: name, + alias: alias, + access_preset: preset, + spending_limit_sats: limitRaw + }; + + if (!name) { + _nwcSetMessage("error", "Wallet Connection name is required."); + _nwcRenderWalletState(); + return; + } + if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(alias)) { + _nwcSetMessage("error", 'Alias must start with a letter or number and use only lowercase letters, numbers, "_" or "-".'); + _nwcRenderWalletState(); + return; + } + if (preset === "send_receive_limited") { + limit = parseInt(limitRaw, 10); + if (!Number.isFinite(limit) || limit <= 0) { + _nwcSetMessage("error", "A positive spending limit is required for limited send access."); + _nwcRenderWalletState(); + return; + } + } + + _nwcBusy(true); + _nwcClearMessage(); + _nwcRenderWalletState(); + try { + var payload = await apiFetch("/api/nwc/wallets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name, + alias: alias, + access_preset: preset, + spending_limit_sats: preset === "send_receive_limited" ? limit : null + }) + }); + _nwcModalState.lastCreated = { + wallet: payload.wallet || null, + pairing_uri: payload.pairing_uri || "", + pairing_qrcode: payload.pairing_qrcode || "", + lightning_address: payload.lightning_address || null + }; + _nwcModalState.view = "created"; + _nwcSetMessage("success", "Wallet created. Save the one-time secret before you continue."); + _nwcBusy(false); + _nwcRenderWalletState(); + } catch (err) { + _nwcBusy(false); + _nwcSetMessage("error", (err && err.message) ? err.message : "Failed to create wallet connection."); + _nwcRenderWalletState(); + } +} + +async function _nwcVerifyWallet(alias) { + if (!_nwcModalState || _nwcModalState.busy || !alias) return; + _nwcBusy(true); + _nwcClearMessage(); + _nwcRenderWalletState(); + try { + await apiFetch("/api/nwc/addresses/" + encodeURIComponent(alias) + "/test", { method: "POST" }); + _nwcSetMessage("success", "Lightning Address verification succeeded for " + alias + "."); + } catch (err) { + _nwcSetMessage("error", (err && err.message) ? err.message : "Lightning Address verification failed."); + } + _nwcBusy(false); + _nwcRenderWalletState(); +} + +async function _nwcDrainWallet(walletId) { + if (!_nwcModalState || _nwcModalState.busy || !walletId) return; + if (!window.confirm("Drain this wallet connection now? This cannot be undone.")) return; + _nwcBusy(true); + _nwcClearMessage(); + _nwcRenderWalletState(); + try { + var resp = await apiFetch("/api/nwc/wallets/" + encodeURIComponent(walletId) + "/drain", { method: "POST" }); + _nwcSetMessage("success", "Wallet drained (" + String(resp.drained_sats || 0) + " sats)."); + _nwcBusy(false); + await _nwcRefreshWallets(); + } catch (err) { + _nwcBusy(false); + _nwcSetMessage("error", (err && err.message) ? err.message : "Failed to drain wallet."); + _nwcRenderWalletState(); + } +} + +async function _nwcDeleteWallet(walletId) { + if (!_nwcModalState || _nwcModalState.busy || !walletId) return; + if (!window.confirm("Delete this wallet connection? This removes the wallet alias from Wallet Connections.")) return; + _nwcBusy(true); + _nwcClearMessage(); + _nwcRenderWalletState(); + try { + await apiFetch("/api/nwc/wallets/" + encodeURIComponent(walletId), { method: "DELETE" }); + _nwcSetMessage("success", "Wallet connection deleted."); + _nwcBusy(false); + await _nwcRefreshWallets(); + } catch (err) { + _nwcBusy(false); + _nwcSetMessage("error", (err && err.message) ? err.message : "Failed to delete wallet connection."); + _nwcRenderWalletState(); + } +} + +async function _nwcInitWalletFlow(unit, name, icon) { + _nwcModalState = { + unit: unit, + name: name, + icon: icon, + view: "empty", + wallets: [], + domain: null, + busy: false, + message: "", + messageKind: "", + lastCreated: null, + createForm: { + name: "", + alias: "", + access_preset: "receive_only", + spending_limit_sats: "" + } + }; + await _nwcRefreshWallets(); +} + async function openServiceDetailModal(unit, name, icon) { if (!$credsModal) return; if ($credsTitle) { @@ -242,7 +595,12 @@ async function openServiceDetailModal(unit, name, icon) { } // Section E: Credentials & Links - if (data.has_credentials && data.credentials && data.credentials.length > 0) { + if (unit === "nwc-wallets.service") { + html += '
' + + '
Wallet Connections
' + + '

Loading wallet connections…

' + + '
'; + } else if (data.has_credentials && data.credentials && data.credentials.length > 0) { html += '
' + '
Credentials & Access
' + _renderCredsHtml(data.credentials, unit) + @@ -323,6 +681,9 @@ async function openServiceDetailModal(unit, name, icon) { $credsBody.innerHTML = html; _attachCopyHandlers($credsBody); + if (unit === "nwc-wallets.service") { + await _nwcInitWalletFlow(unit, name, icon); + } if (unit === "matrix-synapse.service") { var addBtn = document.getElementById("matrix-add-user-btn"); diff --git a/app/tests/test_wallet_connections.py b/app/tests/test_wallet_connections.py index ad119c3..5df8202 100644 --- a/app/tests/test_wallet_connections.py +++ b/app/tests/test_wallet_connections.py @@ -144,6 +144,7 @@ class WalletConnectionsBehaviorTests(unittest.IsolatedAsyncioTestCase): patch.object(server, "NWC_STATE_FILE", str(state_file)), patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)), patch.object(server, "_nwc_test_address", return_value={"ok": False, "error": "public_endpoint_unreachable"}), + patch.object(server, "_generate_qr_base64", return_value="data:image/png;base64,abc"), ): req = types.SimpleNamespace( name="My Wallet", @@ -154,10 +155,12 @@ class WalletConnectionsBehaviorTests(unittest.IsolatedAsyncioTestCase): create_resp = await server.api_nwc_create_wallet(req) create_body = json.loads(create_resp.body.decode("utf-8")) self.assertIn("pairing_uri", create_body) + self.assertEqual(create_body.get("pairing_qrcode"), "data:image/png;base64,abc") list_resp = await server.api_nwc_wallets() self.assertEqual(len(list_resp["wallets"]), 1) self.assertNotIn("pairing_uri", list_resp["wallets"][0]) + self.assertNotIn("pairing_qrcode", list_resp["wallets"][0]) async def test_create_reports_public_verification_success(self): with tempfile.TemporaryDirectory() as td: diff --git a/docs/wallet-connections.md b/docs/wallet-connections.md index 5326ef5..0137762 100644 --- a/docs/wallet-connections.md +++ b/docs/wallet-connections.md @@ -11,6 +11,21 @@ Use the existing Hub service tile flow: 3. Complete existing port/domain/DDNS/rebuild flow (80/TCP and 443/TCP). 4. Reopen tile and manage connections. +## Service-detail modal UX (implemented) + +Wallet management now runs inside the existing **Wallet Connections** service-detail modal with dedicated states: + +1. **Empty state**: no wallets yet, with create action. +2. **Create form**: name, alias, access preset, optional spend limit. +3. **Created/secret state**: one-time pairing secret (URI + QR when available) shown with explicit "save now" warning. +4. **Wallet list state**: per-wallet actions for verify/test, drain, and delete. + +Guardrails in modal flow: + +- Action buttons are disabled while API requests are in flight. +- Destructive actions (drain/delete) require user confirmation. +- API errors are surfaced inline in the modal state. + Node role behavior is unchanged: Node onboarding still skips global domain/port setup, and the `lightning` domain is configured on demand through feature enablement. ## Architecture @@ -25,6 +40,7 @@ Security invariants: - LNURL callback/discovery are exposed only through Caddy on 80/443. - Management APIs are authenticated and remain under `/api/nwc/`. - Pairing secrets are returned only on create responses. +- Pairing secret QR data is generated only for create responses and is not rehydrated via wallet list APIs. - Invoice attribution enforces wallet isolation with app-id checks. ## Domain and runtime files