From ccff377607d5b7041794ac0dba32b5756b6de28a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:10:13 +0000 Subject: [PATCH] Replace Wallet Connections scaffolding with real Alby Hub/LND implementation - Add nwc_hub_manager.py: AlbyHubManager with real Alby Hub API (setup, auth, CRUD, drain, delete, invoice) - Add nwc_lnurl_service.py: dedicated loopback LNURL service on port 8181 - server.py: remove JSON scaffolding (state.json, fake invoice generator, fake NWC URI, LNURL routes); replace with real manager calls; update service maps to albyhub.service; remove LNURL auth-exempt paths - nwc_wallet_cli.py: rewrite to use real AlbyHubManager instead of JSON state - modules/nwc-wallets.nix: replace with albyhub user/service, nwc-lnurl service, LND macaroon, unlock-password generation - modules/core/caddy.nix: proxy LNURL routes to port 8181 (dedicated service) instead of 8937 (Hub) - modules/core/sovran-hub.nix: service tile points to albyhub.service - docs/wallet-connections.md: document real architecture, Alby Hub pin/patches, backup sensitivity - test_wallet_connections.py: replace scaffolding tests with 54 real manager tests using mocked Alby Hub --- app/sovran_systemsos_web/nwc_hub_manager.py | 744 ++++++++++++ app/sovran_systemsos_web/nwc_lnurl_service.py | 220 ++++ app/sovran_systemsos_web/nwc_wallet_cli.py | 97 +- app/sovran_systemsos_web/server.py | 301 ++--- app/tests/test_wallet_connections.py | 1082 ++++++++++++++--- docs/wallet-connections.md | 115 +- modules/core/caddy.nix | 8 +- modules/core/sovran-hub.nix | 2 +- modules/nwc-wallets.nix | 226 +++- 9 files changed, 2290 insertions(+), 505 deletions(-) create mode 100644 app/sovran_systemsos_web/nwc_hub_manager.py create mode 100644 app/sovran_systemsos_web/nwc_lnurl_service.py diff --git a/app/sovran_systemsos_web/nwc_hub_manager.py b/app/sovran_systemsos_web/nwc_hub_manager.py new file mode 100644 index 0000000..7f20e47 --- /dev/null +++ b/app/sovran_systemsos_web/nwc_hub_manager.py @@ -0,0 +1,744 @@ +""" +Alby Hub manager — shared backend for Wallet Connections API and recovery CLI. + +Interfaces with the local Alby Hub instance at http://127.0.0.1:8080. +All sensitive values (passwords, bearer tokens, pairing URIs, macaroon +contents, Nostr private keys) are redacted from any exception messages +or log output. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +logger = logging.getLogger(__name__) + +# ── Constants ────────────────────────────────────────────────────── + +DEFAULT_API_BASE = "http://127.0.0.1:8080" +DEFAULT_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password" +DEFAULT_MACAROON_FILE = "/run/lnd/albyhub.macaroon" +DEFAULT_LND_ADDRESS = "localhost" +DEFAULT_LND_CERT_FILE = "/var/lib/lnd/tls.cert" +DEFAULT_LND_SOCKET = "/run/lnd/lnd.socket" + +LNURL_DESCRIPTION_DEFAULT = "Pay via Lightning" +NWC_MIN_SENDABLE_MSAT = 1000 +NWC_MAX_SENDABLE_MSAT = 1_000_000_000 + +# Metadata key used to mark managed isolated wallets +_MANAGED_APP_STORE_ID = "uncle-jim" +_MANAGED_META_KEY = "app_store_app_id" + +RECEIVE_ONLY_SCOPES = [ + "get_info", + "get_balance", + "make_invoice", + "lookup_invoice", + "list_transactions", + "notifications", +] + +LIMITED_SEND_SCOPES = RECEIVE_ONLY_SCOPES + ["pay_invoice"] + +# ── Exceptions ───────────────────────────────────────────────────── + + +class AlbyHubError(Exception): + """Base error from the Alby Hub manager. + + The message string is safe to surface to the user — it never + contains raw secret material. + """ + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + def __str__(self) -> str: + return f"[{self.code}] {self.args[0]}" + + +class AlbyHubHttpError(AlbyHubError): + def __init__(self, status_code: int, message: str) -> None: + super().__init__(f"http_{status_code}", message) + self.status_code = status_code + + +# ── Manager class ────────────────────────────────────────────────── + + +class AlbyHubManager: + """Thread-safe manager for Alby Hub API operations.""" + + def __init__( + self, + api_base: str = DEFAULT_API_BASE, + unlock_password_file: str = DEFAULT_UNLOCK_PASSWORD_FILE, + macaroon_file: str = DEFAULT_MACAROON_FILE, + lnd_address: str = DEFAULT_LND_ADDRESS, + lnd_cert_file: str = DEFAULT_LND_CERT_FILE, + ) -> None: + self.api_base = api_base.rstrip("/") + self.unlock_password_file = unlock_password_file + self.macaroon_file = macaroon_file + self.lnd_address = lnd_address + self.lnd_cert_file = lnd_cert_file + self._lock = threading.Lock() + self._token: str | None = None + + # ── Low-level HTTP ───────────────────────────────────────────── + + def _request( + self, + method: str, + path: str, + body: dict | None = None, + token: str | None = None, + timeout: int = 30, + ) -> dict: + """Make a raw HTTP request to the local Alby Hub API. + + Returns the parsed JSON response body. + Raises AlbyHubHttpError on non-2xx responses. + Secrets in response bodies are never included in raised exceptions. + """ + url = f"{self.api_base}{path}" + data = json.dumps(body).encode("utf-8") if body is not None else None + headers: dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if token: + headers["Authorization"] = f"******" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read() + if not raw: + return {} + return json.loads(raw) + except urllib.error.HTTPError as exc: + code = exc.code + # Read and discard the body — we do NOT include it in the exception + try: + exc.read() + except Exception: + pass + raise AlbyHubHttpError(code, f"Hub API {method} {path} returned HTTP {code}") from None + except (urllib.error.URLError, TimeoutError, OSError): + raise AlbyHubError( + "hub_unreachable", + f"Hub API {method} {path} is unreachable", + ) from None + + def _authenticated_request( + self, + method: str, + path: str, + body: dict | None = None, + timeout: int = 30, + ) -> dict: + """Make an authenticated request; retry once with a fresh token on 401/403.""" + token = self.ensure_ready() + try: + return self._request(method, path, body=body, token=token, timeout=timeout) + except AlbyHubHttpError as exc: + if exc.status_code in (401, 403): + with self._lock: + self._token = None + token = self.ensure_ready() + return self._request(method, path, body=body, token=token, timeout=timeout) + raise + + def _paginate(self, path_template: str, page_size: int = 100) -> list[dict]: + """Paginate a list API completely, collecting all items. + + ``path_template`` must contain ``{limit}`` and ``{offset}`` placeholders. + """ + token = self.ensure_ready() + offset = 0 + results: list[dict] = [] + while True: + path = path_template.format(limit=page_size, offset=offset) + page = self._request("GET", path, token=token) + # Alby Hub returns apps at the top level or under "apps"/"transactions" + if isinstance(page, list): + items = page + elif isinstance(page, dict): + items = page.get("apps") or page.get("transactions") or [] + else: + items = [] + if not isinstance(items, list): + break + results.extend(items) + if len(items) < page_size: + break + offset += page_size + return results + + # ── Startup / Auth ───────────────────────────────────────────── + + def _read_unlock_password(self) -> str: + try: + with open(self.unlock_password_file, "r") as fh: + return fh.read().strip() + except OSError as exc: + raise AlbyHubError( + "unlock_password_unavailable", + "Cannot read Alby Hub unlock password", + ) from exc + + def _wait_for_file(self, path: str, timeout: int = 120) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if os.path.exists(path): + return + time.sleep(2) + raise AlbyHubError( + "dependency_unavailable", + f"Timed out waiting for required file", + ) + + def _wait_for_hub_api(self, timeout: int = 120) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + self._request("GET", "/api/info", timeout=5) + return + except AlbyHubError: + pass + time.sleep(3) + raise AlbyHubError("hub_unavailable", "Timed out waiting for Alby Hub API") + + def _hub_setup(self, password: str) -> None: + """Perform /api/setup idempotently.""" + try: + info = self._request("GET", "/api/info", timeout=10) + if info.get("setupCompleted"): + return + except AlbyHubError: + pass + + try: + with open(self.macaroon_file, "rb") as fh: + macaroon_hex = fh.read().hex() + except OSError: + raise AlbyHubError( + "macaroon_unavailable", + "Cannot read Alby Hub LND macaroon", + ) + + setup_body = { + "unlockPassword": password, + "lndAddress": self.lnd_address, + "lndCertFile": self.lnd_cert_file, + "lndMacaroon": macaroon_hex, + "backendType": "LND", + } + try: + self._request("POST", "/api/setup", body=setup_body, timeout=30) + except AlbyHubHttpError as exc: + if exc.status_code == 409: + return # already setup + raise + + def _hub_unlock(self, password: str) -> None: + try: + self._request( + "POST", + "/api/unlock", + body={"unlockPassword": password}, + timeout=30, + ) + except AlbyHubHttpError as exc: + if exc.status_code == 409: + return # already unlocked + raise + + def _obtain_token(self, password: str) -> str: + resp = self._request( + "POST", + "/api/auth", + body={"password": password}, + timeout=30, + ) + token = ( + resp.get("token") + or resp.get("accessToken") + or resp.get("access_token") + ) + if not token or not isinstance(token, str): + raise AlbyHubError("auth_failed", "Alby Hub auth response missing token") + return token + + def _wait_for_node_ready(self, token: str, timeout: int = 120) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + status = self._request( + "GET", "/api/node/status", token=token, timeout=10 + ) + if status.get("isReady") or status.get("running") or status.get("online"): + return + except AlbyHubError: + pass + time.sleep(3) + raise AlbyHubError("node_not_ready", "Timed out waiting for Alby Hub node to be ready") + + def ensure_ready(self) -> str: + """Ensure Alby Hub is set up, unlocked, and authenticated. + + Returns a valid bearer token. Caches it and uses a lock to + prevent concurrent setup races. + """ + with self._lock: + if self._token: + return self._token + + password = self._read_unlock_password() + self._wait_for_file(self.macaroon_file, timeout=120) + self._wait_for_hub_api(timeout=120) + self._hub_setup(password) + self._hub_unlock(password) + token = self._obtain_token(password) + self._wait_for_node_ready(token, timeout=120) + self._token = token + return token + + # ── App isolation helpers ────────────────────────────────────── + + @staticmethod + def _parse_metadata(raw: Any) -> dict: + if isinstance(raw, dict): + return raw + if isinstance(raw, str): + try: + result = json.loads(raw) + if isinstance(result, dict): + return result + except Exception: + pass + return {} + + def _is_managed_app(self, app: dict) -> bool: + meta = self._parse_metadata(app.get("metadata")) + return meta.get(_MANAGED_META_KEY) == _MANAGED_APP_STORE_ID + + def _app_to_wallet_meta(self, app: dict, domain: str | None) -> dict: + meta = self._parse_metadata(app.get("metadata")) + alias = meta.get("lnurl_alias", "") + address = f"{alias}@{domain}" if alias and domain else None + + scopes = app.get("scopes") or [] + access_preset = ( + "send_receive_limited" if "pay_invoice" in scopes else "receive_only" + ) + + balance_sats = 0 + budget = app.get("budget") or {} + used_msat = int(budget.get("usedBudget", 0) or 0) + balance_sats = used_msat // 1000 + + remaining_sats: int | None = None + remaining_raw = budget.get("remainingBudget") + if remaining_raw is not None: + remaining_sats = int(remaining_raw) // 1000 + + spending_limit_sats: int | None = None + max_amount = app.get("maxAmountSat") or 0 + if max_amount: + spending_limit_sats = int(max_amount) + + # Count pending transactions from the budget or transactions list + pending_txs = len( + [t for t in (app.get("pendingTransactions") or []) if t] + ) + + return { + "id": str(app.get("id", "")), + "pubkey": app.get("nostrPubkey") or app.get("pubkey") or "", + "name": app.get("name", ""), + "alias": alias, + "lightning_address": address, + "access_preset": access_preset, + "spending_limit_sats": spending_limit_sats, + "remaining_budget_sats": remaining_sats, + "balance_sats": balance_sats, + "dust_msat": 0, + "pending_transactions": pending_txs, + "created_at": app.get("createdAt") or app.get("created_at"), + "min_sendable_msat": int( + meta.get("lnurl_min_sendable_msat", NWC_MIN_SENDABLE_MSAT) + ), + "max_sendable_msat": int( + meta.get("lnurl_max_sendable_msat", NWC_MAX_SENDABLE_MSAT) + ), + } + + def _all_managed_apps(self) -> list[dict]: + apps = self._paginate("/api/apps?limit={limit}&offset={offset}") + return [a for a in apps if a.get("isolated") and self._is_managed_app(a)] + + def _find_managed_app(self, identifier: str) -> dict | None: + needle = identifier.strip().lower() + for app in self._all_managed_apps(): + if str(app.get("id", "")).lower() == needle: + return app + pubkey = ( + app.get("nostrPubkey") or app.get("pubkey") or "" + ).lower() + if pubkey == needle: + return app + return None + + # ── Public API ───────────────────────────────────────────────── + + def list_wallets(self, domain: str | None = None) -> list[dict]: + """Return all managed isolated app wallets (no secrets).""" + return [ + self._app_to_wallet_meta(a, domain) + for a in self._all_managed_apps() + ] + + def create_wallet( + self, + name: str, + alias: str, + access_preset: str, + spending_limit_sats: int | None, + domain: str | None = None, + ) -> dict: + """Create a new isolated Alby Hub app (wallet connection). + + Returns a dict containing: + ``wallet`` — safe metadata (no secrets) + ``pairing_uri`` — real Alby Hub pairingUri (returned ONCE) + ``result`` — creation status report + """ + # Validate uniqueness + managed = self._all_managed_apps() + for a in managed: + meta = self._parse_metadata(a.get("metadata")) + if meta.get("lnurl_alias", "").lower() == alias.lower(): + raise AlbyHubError( + "alias_exists", "That Lightning Address alias is already in use." + ) + if (a.get("name") or "").lower() == name.lower(): + raise AlbyHubError( + "wallet_name_exists", + "That Wallet Connection name already exists.", + ) + + scopes = ( + LIMITED_SEND_SCOPES + if access_preset == "send_receive_limited" + else RECEIVE_ONLY_SCOPES + ) + max_amount = ( + spending_limit_sats + if access_preset == "send_receive_limited" and spending_limit_sats + else 0 + ) + + create_body: dict = { + "name": name, + "scopes": scopes, + "isolated": True, + "budgetRenewal": "never", + "maxAmountSat": max_amount, + "metadata": { + _MANAGED_META_KEY: _MANAGED_APP_STORE_ID, + "lnurl_alias": alias, + "lnurl_description": LNURL_DESCRIPTION_DEFAULT, + "lnurl_min_sendable_msat": NWC_MIN_SENDABLE_MSAT, + "lnurl_max_sendable_msat": NWC_MAX_SENDABLE_MSAT, + }, + } + + resp = self._authenticated_request("POST", "/api/apps", body=create_body) + pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or "" + app_id = resp.get("id") + + # Fetch full app details for accurate metadata + app_detail: dict | None = None + if app_id is not None: + try: + app_detail = self._authenticated_request( + "GET", f"/api/apps/{app_id}" + ) + except AlbyHubError: + pass + + if app_detail is None: + # Fallback: search recent apps for the one we just created + updated = self._all_managed_apps() + for a in updated: + if str(a.get("id", "")) == str(app_id): + app_detail = a + break + + wallet_meta = self._app_to_wallet_meta(app_detail or resp, domain) + + # Initial internal transfer for limited wallets + funding_result: dict = {"attempted": False, "success": False} + if ( + access_preset == "send_receive_limited" + and spending_limit_sats + and app_id is not None + ): + funding_result["attempted"] = True + try: + self._authenticated_request( + "POST", + "/api/transfers", + body={ + "toAppId": int(app_id), + "amountMsat": spending_limit_sats * 1000, + }, + ) + funding_result["success"] = True + except AlbyHubError as exc: + funding_result["error"] = exc.code + funding_result["message"] = ( + "The wallet was created and the NWC connection secret is shown " + "above, but initial funding failed. Save the NWC secret now. " + "Do not create another wallet." + ) + + return { + "wallet": wallet_meta, + "pairing_uri": pairing_uri, # returned once on create only + "result": { + "wallet_created": True, + "secret_created": bool(pairing_uri), + "lightning_address_registered": bool(alias and domain), + "funding": funding_result, + }, + } + + def _get_app_balance_msat(self, app: dict) -> int: + budget = app.get("budget") or {} + return int(budget.get("usedBudget", 0) or 0) + + def _get_app_pending_txs(self, app_id: int) -> list[dict]: + txs = self._paginate( + f"/api/apps/{app_id}/transactions?limit={{limit}}&offset={{offset}}" + ) + return [t for t in txs if t.get("state", "").lower() in ("pending",)] + + def drain_wallet(self, identifier: str) -> dict: + """Drain all whole-satoshi funds from an isolated app to the primary wallet. + + Returns ``{"ok": True, "drained_sats": N, "dust_msat": M}``. + Raises AlbyHubError on rejection or failure. + """ + app = self._find_managed_app(identifier) + if app is None: + raise AlbyHubError("wallet_not_found", "Wallet connection not found.") + + app_id = int(app["id"]) + balance_msat = self._get_app_balance_msat(app) + + if balance_msat < 0: + raise AlbyHubError("negative_balance", "Wallet has a negative balance.") + + pending = self._get_app_pending_txs(app_id) + if pending: + raise AlbyHubError( + "pending_transactions", + "Wallet has pending transactions and cannot be drained.", + ) + + whole_sats = balance_msat // 1000 + dust_msat = balance_msat % 1000 + + if whole_sats == 0: + return {"ok": True, "drained_sats": 0, "dust_msat": dust_msat} + + # Save original permissions + original_scopes = list(app.get("scopes") or []) + original_max = app.get("maxAmountSat") or 0 + original_renewal = app.get("budgetRenewal") or "never" + + # Temporarily grant pay_invoice scope with sufficient budget + patch_body = { + "scopes": sorted(set(original_scopes) | {"pay_invoice"}), + "maxAmountSat": whole_sats, + "budgetRenewal": "never", + } + self._authenticated_request("PATCH", f"/api/apps/{app_id}", body=patch_body) + + drain_error: AlbyHubError | None = None + drained_sats = 0 + try: + self._authenticated_request( + "POST", + "/api/transfers", + body={"fromAppId": app_id, "amountMsat": whole_sats * 1000}, + ) + drained_sats = whole_sats + except AlbyHubError as exc: + drain_error = exc + finally: + # Restore original permissions whether drain succeeded or not + restore_body = { + "scopes": original_scopes, + "maxAmountSat": original_max, + "budgetRenewal": original_renewal, + } + try: + self._authenticated_request( + "PATCH", f"/api/apps/{app_id}", body=restore_body + ) + except AlbyHubError: + pass # best-effort restore; don't mask the original error + + if drain_error is not None: + raise drain_error + + # Verify remaining balance equals expected dust + refreshed = self._authenticated_request("GET", f"/api/apps/{app_id}") + remaining_msat = self._get_app_balance_msat(refreshed) + + return { + "ok": True, + "drained_sats": drained_sats, + "dust_msat": dust_msat, + "remaining_msat": remaining_msat, + } + + def delete_wallet(self, identifier: str) -> dict: + """Safely drain and delete an isolated app. + + Returns ``{"ok": True, "drained_sats": N}``. + """ + app = self._find_managed_app(identifier) + if app is None: + raise AlbyHubError("wallet_not_found", "Wallet connection not found.") + + app_id = int(app["id"]) + + pending = self._get_app_pending_txs(app_id) + if pending: + raise AlbyHubError( + "pending_transactions", + "Wallet has pending transactions and cannot be deleted.", + ) + + drain_result = self.drain_wallet(identifier) + + # Verify no transferable balance remains + refreshed = self._authenticated_request("GET", f"/api/apps/{app_id}") + remaining_msat = self._get_app_balance_msat(refreshed) + if remaining_msat >= 1000: + raise AlbyHubError( + "drain_incomplete", + f"Drain verification failed: funds still remain.", + ) + + # Delete by nostr pubkey + pubkey = app.get("nostrPubkey") or app.get("pubkey") or "" + if not pubkey: + raise AlbyHubError( + "app_pubkey_missing", + "Cannot delete app: nostr pubkey not available.", + ) + self._authenticated_request( + "DELETE", + f"/api/apps/{urllib.parse.quote(pubkey, safe='')}", + ) + + return {"ok": True, "drained_sats": drain_result.get("drained_sats", 0)} + + def issue_invoice( + self, app_id: int, amount_msat: int, description: str = "" + ) -> str: + """Create an LND invoice attributed to a specific isolated app. + + Returns a valid BOLT11 invoice string. + Raises AlbyHubError if the Hub returns an invalid or misattributed invoice. + """ + resp = self._authenticated_request( + "POST", + "/api/invoices", + body={ + "amountMsat": amount_msat, + "description": description or LNURL_DESCRIPTION_DEFAULT, + "appId": app_id, + }, + ) + invoice: str = ( + resp.get("paymentRequest") + or resp.get("pr") + or resp.get("invoice") + or "" + ) + returned_app_id = resp.get("appId") + + if not invoice: + raise AlbyHubError("invoice_creation_failed", "Hub returned empty invoice.") + + # Require a valid BOLT11 prefix (mainnet, testnet, signet, regtest) + if not re.match(r"^ln[a-z]{2,6}[0-9]", invoice, re.IGNORECASE): + raise AlbyHubError( + "invalid_invoice", "Hub returned a non-BOLT11 invoice string." + ) + + if returned_app_id is not None and int(returned_app_id) != app_id: + raise AlbyHubError( + "invoice_attribution_failed", + "Invoice attribution mismatch: returned appId does not match.", + ) + + return invoice + + def find_app_by_alias(self, alias: str) -> dict | None: + """Find a managed isolated app by its ``lnurl_alias`` metadata field.""" + alias_lower = alias.strip().lower() + for a in self._all_managed_apps(): + meta = self._parse_metadata(a.get("metadata")) + if meta.get("lnurl_alias", "").lower() == alias_lower: + return a + return None + + def health(self) -> dict: + """Return a basic health summary.""" + try: + token = self.ensure_ready() + status = self._request( + "GET", "/api/node/status", token=token, timeout=10 + ) + return { + "ok": True, + "hub_ready": bool( + status.get("isReady") or status.get("running") + ), + } + except AlbyHubError as exc: + return {"ok": False, "error": exc.code, "message": str(exc)} + + +# ── Module-level singleton ────────────────────────────────────────── + +_manager: AlbyHubManager | None = None +_manager_lock = threading.Lock() + + +def get_manager() -> AlbyHubManager: + """Return the module-level singleton AlbyHubManager.""" + global _manager + if _manager is None: + with _manager_lock: + if _manager is None: + _manager = AlbyHubManager() + return _manager diff --git a/app/sovran_systemsos_web/nwc_lnurl_service.py b/app/sovran_systemsos_web/nwc_lnurl_service.py new file mode 100644 index 0000000..1507381 --- /dev/null +++ b/app/sovran_systemsos_web/nwc_lnurl_service.py @@ -0,0 +1,220 @@ +""" +Dedicated LNURL service for Wallet Connections. + +Runs as ``nwc-lnurl.service`` on 127.0.0.1:8181 (loopback only). +Caddy proxies the public Lightning Address domain's LNURL routes to this port. + +Routes: + GET /.well-known/lnurlp/{alias} + GET /lnurlp/{alias}/callback?amount= + +All error responses are safe for public consumption — raw Alby Hub bodies +and internal credentials are never returned to callers. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import urllib.parse +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import TYPE_CHECKING + +from . import nwc_hub_manager as _mgr_mod + +if TYPE_CHECKING: + from .nwc_hub_manager import AlbyHubManager + +logger = logging.getLogger(__name__) + +# ── Configuration ───────────────────────────────────────────────── + +LNURL_BIND_HOST = "127.0.0.1" +LNURL_PORT = 8181 +DOMAIN_FILE = "/var/lib/domains/lightning" + +NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") + +# ── Helpers ─────────────────────────────────────────────────────── + + +def _read_domain() -> str | None: + try: + with open(DOMAIN_FILE, "r") as fh: + raw = fh.read(256).strip().lower() + # Basic validation: must look like a hostname + if re.match(r"^[a-z0-9][a-z0-9.\-]{1,253}$", raw): + return raw + except OSError: + pass + return None + + +def _lnurl_discovery(alias: str, manager: "AlbyHubManager") -> tuple[dict, int]: + alias = alias.strip().lower() + if not NWC_ALIAS_RE.match(alias): + return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404 + + domain = _read_domain() + if not domain: + return {"status": "ERROR", "reason": "Lightning domain is not configured"}, 503 + + try: + app = manager.find_app_by_alias(alias) + except _mgr_mod.AlbyHubError: + return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503 + + if app is None: + return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404 + + meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata")) + min_sendable = int( + meta.get("lnurl_min_sendable_msat", _mgr_mod.NWC_MIN_SENDABLE_MSAT) + ) + max_sendable = int( + meta.get("lnurl_max_sendable_msat", _mgr_mod.NWC_MAX_SENDABLE_MSAT) + ) + + callback_alias = urllib.parse.quote(alias, safe="") + callback = f"https://{domain}/lnurlp/{callback_alias}/callback" + description = meta.get("lnurl_description") or f"Pay {alias}" + metadata = json.dumps([["text/plain", description]], separators=(",", ":")) + + return { + "tag": "payRequest", + "callback": callback, + "minSendable": min_sendable, + "maxSendable": max_sendable, + "metadata": metadata, + "commentAllowed": 0, + }, 200 + + +def _lnurl_callback( + alias: str, amount_str: str | None, manager: "AlbyHubManager" +) -> tuple[dict, int]: + payload, status_code = _lnurl_discovery(alias, manager) + if status_code != 200: + return payload, status_code + + if amount_str is None: + return {"status": "ERROR", "reason": "Missing amount parameter"}, 400 + if not re.match(r"^\d+$", amount_str): + return { + "status": "ERROR", + "reason": "Amount must be an integer millisatoshi value", + }, 400 + + amount_msat = int(amount_str) + min_sendable = int(payload["minSendable"]) + max_sendable = int(payload["maxSendable"]) + + if amount_msat < min_sendable: + return { + "status": "ERROR", + "reason": "Amount is below the minimum sendable value", + }, 400 + if amount_msat > max_sendable: + return { + "status": "ERROR", + "reason": "Amount is above the maximum sendable value", + }, 400 + if amount_msat % 1000 != 0: + return { + "status": "ERROR", + "reason": "Amount must be a whole-satoshi value", + }, 400 + + try: + app = manager.find_app_by_alias(alias) + except _mgr_mod.AlbyHubError: + return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503 + + if app is None: + return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404 + + meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata")) + description = meta.get("lnurl_description") or f"Pay {alias}" + + try: + app_id = int(app["id"]) + invoice = manager.issue_invoice(app_id, amount_msat, description) + except _mgr_mod.AlbyHubError: + return {"status": "ERROR", "reason": "Invoice creation failed"}, 502 + + return {"pr": invoice, "routes": []}, 200 + + +# ── HTTP server ─────────────────────────────────────────────────── + + +def _make_handler(manager: "AlbyHubManager") -> type: + """Return a handler class bound to the given manager.""" + + class LnurlHandler(BaseHTTPRequestHandler): + _manager = manager + + def log_message(self, fmt: str, *args: object) -> None: + logger.debug(f"LNURL {self.address_string()} {fmt % args}") + + def _send_json(self, status: int, body: dict) -> None: + raw = json.dumps(body, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + qs = urllib.parse.parse_qs(parsed.query) + + # /.well-known/lnurlp/{alias} + m = re.fullmatch( + r"/.well-known/lnurlp/([^/]+)", path + ) + if m: + alias = urllib.parse.unquote(m.group(1)) + payload, code = _lnurl_discovery(alias, self._manager) + self._send_json(code, payload) + return + + # /lnurlp/{alias}/callback + m = re.fullmatch(r"/lnurlp/([^/]+)/callback", path) + if m: + alias = urllib.parse.unquote(m.group(1)) + amount_list = qs.get("amount") + amount_str = amount_list[0] if amount_list else None + payload, code = _lnurl_callback(alias, amount_str, self._manager) + self._send_json(code, payload) + return + + self._send_json(404, {"status": "ERROR", "reason": "Not found"}) + + return LnurlHandler + + +def run( + host: str = LNURL_BIND_HOST, + port: int = LNURL_PORT, + manager: "AlbyHubManager | None" = None, +) -> None: + """Start the blocking LNURL HTTP server.""" + if manager is None: + manager = _mgr_mod.get_manager() + handler_class = _make_handler(manager) + server = HTTPServer((host, port), handler_class) + logger.info("nwc-lnurl service listening on %s:%d", host, port) + server.serve_forever() + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + run() + + +if __name__ == "__main__": + main() diff --git a/app/sovran_systemsos_web/nwc_wallet_cli.py b/app/sovran_systemsos_web/nwc_wallet_cli.py index ea33043..8e60762 100644 --- a/app/sovran_systemsos_web/nwc_wallet_cli.py +++ b/app/sovran_systemsos_web/nwc_wallet_cli.py @@ -4,7 +4,8 @@ import argparse import json import sys -from . import server +from . import nwc_hub_manager as _mgr_mod +from .server import _nwc_domain, _nwc_validate_alias, _nwc_test_address def _print(data) -> None: @@ -38,85 +39,71 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser("health") args = parser.parse_args(argv) - state = server._nwc_load_state() - domain = server._nwc_domain() + manager = _mgr_mod.get_manager() + domain = _nwc_domain() if args.cmd == "list": - _print({"wallets": [server._nwc_wallet_meta(w, domain) for w in state.get("wallets", [])]}) + try: + wallets = manager.list_wallets(domain) + except _mgr_mod.AlbyHubError as exc: + print(f"Error: {exc.code} - {exc}", file=sys.stderr) + return 1 + _print({"wallets": wallets}) return 0 if args.cmd == "health": - _print({"ok": True, "domain": domain, "wallet_count": len(state.get("wallets", []))}) - return 0 + result = manager.health() + _print(result) + return 0 if result.get("ok") else 1 if args.cmd == "address" and args.address_cmd == "show": - test = server._nwc_test_address(args.alias.strip().lower()) + alias = args.alias.strip().lower() + test = _nwc_test_address(alias) _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) + try: + result = manager.drain_wallet(args.wallet) + except _mgr_mod.AlbyHubError as exc: + print(f"Error: {exc.code} - {exc}", 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))}) + _print(result) 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) + try: + result = manager.delete_wallet(args.wallet) + except _mgr_mod.AlbyHubError as exc: + print(f"Error: {exc.code} - {exc}", 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}) + _print(result) return 0 if args.cmd == "create": alias = args.alias.strip().lower() - if not server._nwc_validate_alias(alias): + if not _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) + try: + result = manager.create_wallet( + args.name.strip(), + alias, + access_preset, + args.limit_sats if access_preset == "send_receive_limited" else None, + domain, + ) + except _mgr_mod.AlbyHubError as exc: + print(f"Error: {exc.code} - {exc}", file=sys.stderr) + return 1 + # Print the pairing URI once — this is the only time it is shown _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), + "wallet": result["wallet"], + "pairing_uri": result.get("pairing_uri", ""), + "message": "Keep the NWC connection secret private. It cannot be displayed again.", + "result": result.get("result", {}), } ) return 0 diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index b2835db..76764ec 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -35,6 +35,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from .config import load_config from . import systemctl as sysctl +from . import nwc_hub_manager as _nwc_mgr logger = logging.getLogger(__name__) @@ -119,8 +120,6 @@ _AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/api/updates/status", "/api/rebui _AUTH_EXEMPT_PREFIXES = ( "/static/css/", "/static/sovran-hub-icon.svg", - "/.well-known/lnurlp/", - "/lnurlp/", ) # ── Security constants ──────────────────────────────────────────── @@ -307,7 +306,7 @@ FEATURE_SERVICE_MAP = { "mempool": "mempool.service", "bitcoin-core": None, "btcpay-web": "btcpayserver.service", - "nwc-wallets": "nwc-wallets.service", + "nwc-wallets": "albyhub.service", "sshd": "sshd.service", } @@ -332,7 +331,8 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = { "phpfpm-nextcloud.service": [], "phpfpm-wordpress.service": [], "haven-relay.service": [], - "nwc-wallets.service": [], + "albyhub.service": [], + "nwc-lnurl.service": [], # SSH (only open when feature is enabled) "sshd.service": [{"port": "22", "protocol": "TCP", "description": "SSH"}], } @@ -347,7 +347,7 @@ SERVICE_DOMAIN_MAP: dict[str, str] = { "phpfpm-wordpress.service": "wordpress", "haven-relay.service": "haven", "livekit.service": "element-calling", - "nwc-wallets.service": "lightning", + "albyhub.service": "lightning", } # For features that share a unit, disambiguate by icon field @@ -451,7 +451,7 @@ 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": ( + "albyhub.service": ( "Create isolated Wallet Connections for Lightning apps and attach reusable Lightning " "Addresses on your Sovran_SystemsOS node." ), @@ -4218,17 +4218,12 @@ async def api_domains_check(req: DomainCheckRequest): return {"domains": list(check_results)} -# ── Wallet Connections (NWC/LNURL) endpoints ─────────────────────── +# ── Wallet Connections (NWC) 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 +NWC_MIN_SENDABLE_MSAT = _nwc_mgr.NWC_MIN_SENDABLE_MSAT +NWC_MAX_SENDABLE_MSAT = _nwc_mgr.NWC_MAX_SENDABLE_MSAT def _nwc_error(status_code: int, error: str, message: str, **extra) -> JSONResponse: @@ -4237,29 +4232,6 @@ def _nwc_error(status_code: int, error: str, message: str, **extra) -> JSONRespo 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: @@ -4275,64 +4247,6 @@ 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: @@ -4351,16 +4265,6 @@ def _nwc_test_address(alias: str) -> dict: 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 @@ -4371,9 +4275,13 @@ class NwcWalletCreateRequest(BaseModel): @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", [])] + try: + wallets = await loop.run_in_executor( + None, _nwc_mgr.get_manager().list_wallets, domain + ) + except _nwc_mgr.AlbyHubError as exc: + return _nwc_error(503, exc.code, str(exc)) return {"wallets": wallets, "domain": domain} @@ -4388,50 +4296,40 @@ async def api_nwc_create_wallet(req: NwcWalletCreateRequest): 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) - pairing_uri = _nwc_pairing_uri(wallet_id, pairing_secret) - pairing_qrcode = _generate_qr_base64(pairing_uri) - response = { - "wallet": _nwc_wallet_meta(wallet, domain), + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + None, + lambda: _nwc_mgr.get_manager().create_wallet( + name, alias, req.access_preset, spending_limit_sats, domain + ), + ) + except _nwc_mgr.AlbyHubError as exc: + code_map = { + "alias_exists": 409, + "wallet_name_exists": 409, + } + status = code_map.get(exc.code, 502) + return _nwc_error(status, exc.code, str(exc)) + + pairing_uri: str = result.get("pairing_uri", "") + pairing_qrcode: str | None = None + if pairing_uri: + pairing_qrcode = _generate_qr_base64(pairing_uri) + + verify = await loop.run_in_executor(None, _nwc_test_address, alias) + + response: dict = { + "wallet": result["wallet"], "pairing_uri": pairing_uri, "lightning_address": f"{alias}@{domain}" if domain else None, "result": { - "wallet_created": True, - "secret_created": True, - "lightning_address_registered": bool(domain), + **result.get("result", {}), "public_endpoint_verification": verify, }, } @@ -4442,38 +4340,42 @@ async def api_nwc_create_wallet(req: NwcWalletCreateRequest): @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} + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + None, + _nwc_mgr.get_manager().delete_wallet, + wallet_identifier, + ) + except _nwc_mgr.AlbyHubError as exc: + code_map = { + "wallet_not_found": 404, + "pending_transactions": 409, + "drain_incomplete": 409, + } + status = code_map.get(exc.code, 502) + return _nwc_error(status, exc.code, str(exc)) + return result @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)), - } + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + None, + _nwc_mgr.get_manager().drain_wallet, + wallet_identifier, + ) + except _nwc_mgr.AlbyHubError as exc: + code_map = { + "wallet_not_found": 404, + "pending_transactions": 409, + "negative_balance": 409, + } + status = code_map.get(exc.code, 502) + return _nwc_error(status, exc.code, str(exc)) + return result @app.post("/api/nwc/addresses/{alias}/test") @@ -4481,58 +4383,23 @@ 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", [])): + loop = asyncio.get_event_loop() + try: + app = await loop.run_in_executor( + None, + _nwc_mgr.get_manager().find_app_by_alias, + normalized_alias, + ) + except _nwc_mgr.AlbyHubError as exc: + return _nwc_error(503, exc.code, str(exc)) + if app is None: return _nwc_error(404, "wallet_not_found", "No wallet connection exists for this alias.") - result = _nwc_test_address(normalized_alias) + result = await loop.run_in_executor(None, _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 index 5df8202..6d2c009 100644 --- a/app/tests/test_wallet_connections.py +++ b/app/tests/test_wallet_connections.py @@ -1,14 +1,44 @@ +""" +Wallet Connections tests — validates the real AlbyHubManager and LNURL service +using mocked Alby Hub HTTP responses. + +Tests cover: +- Feature registry and role visibility +- Manager idempotent setup/start/unlock +- Token refresh after 401/403 +- App and transaction pagination +- Real create request body and scopes +- Real pairingUri returned once, absent from list +- Duplicate alias/name rejection +- Initial transfer success and partial-failure semantics +- Real list mapping (no secrets) +- Pending transaction blocking +- Drain permission update, transfer, final verification, restoration +- Delete using app pubkey after drain +- LNURL discovery from real-style app metadata +- Callback /api/invoices request includes numeric appId +- AppId mismatch rejection +- Invalid/fake BOLT11 rejection +- Public verification failure does not duplicate or roll back creation +- Caddy proxies to dedicated LNURL port 8181, not 8937 +- Internal ports are not publicly opened +""" + import json +import re import sys import tempfile import types import unittest +from io import BytesIO from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, call, patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +# ── Minimal stubs so server.py can be imported without full FastAPI ── + def _install_web_stubs(): if "fastapi" in sys.modules: return @@ -20,46 +50,16 @@ def _install_web_stubs(): 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 __init__(self, *a, **kw): pass + def mount(self, *a, **kw): return None + def add_middleware(self, *a, **kw): return None def __getattr__(self, _name): - def _decorator_factory(*args, **kwargs): - def _decorator(func): - return func + def _deco_factory(*a, **kw): + def _deco(func): return func + return _deco + return _deco_factory - 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 _BaseModel: pass class _JSONResponse: def __init__(self, content=None, status_code=200): @@ -67,161 +67,903 @@ def _install_web_stubs(): self.status_code = status_code self.body = json.dumps(content or {}).encode("utf-8") - responses_module.JSONResponse = _JSONResponse - sys.modules["fastapi.responses"] = responses_module + fastapi_mod = types.ModuleType("fastapi") + fastapi_mod.FastAPI = _FastAPI + fastapi_mod.HTTPException = _HTTPException + sys.modules["fastapi"] = fastapi_mod - staticfiles_module = types.ModuleType("fastapi.staticfiles") - staticfiles_module.StaticFiles = _StaticFiles - sys.modules["fastapi.staticfiles"] = staticfiles_module + resp_mod = types.ModuleType("fastapi.responses") + resp_mod.HTMLResponse = object + resp_mod.RedirectResponse = object + resp_mod.JSONResponse = _JSONResponse + sys.modules["fastapi.responses"] = resp_mod - templating_module = types.ModuleType("fastapi.templating") - templating_module.Jinja2Templates = _Jinja2Templates - sys.modules["fastapi.templating"] = templating_module + sys.modules["fastapi.staticfiles"] = types.ModuleType("fastapi.staticfiles") - requests_module = types.ModuleType("fastapi.requests") - requests_module.Request = object - sys.modules["fastapi.requests"] = requests_module + class _StaticFiles: + def __init__(self, *args, **kwargs): + pass - pydantic_module = types.ModuleType("pydantic") - pydantic_module.BaseModel = _BaseModel - sys.modules["pydantic"] = pydantic_module + sys.modules["fastapi.staticfiles"].StaticFiles = _StaticFiles - starlette_base_module = types.ModuleType("starlette.middleware.base") - starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware - sys.modules["starlette.middleware.base"] = starlette_base_module + class _Jinja2Templates: + def __init__(self, *args, **kwargs): + pass - starlette_middleware_module = types.ModuleType("starlette.middleware") - starlette_middleware_module.base = starlette_base_module - sys.modules["starlette.middleware"] = starlette_middleware_module + tmpl_mod = types.ModuleType("fastapi.templating") + tmpl_mod.Jinja2Templates = _Jinja2Templates + sys.modules["fastapi.templating"] = tmpl_mod - starlette_module = types.ModuleType("starlette") - starlette_module.middleware = starlette_middleware_module - sys.modules["starlette"] = starlette_module + req_mod = types.ModuleType("fastapi.requests") + req_mod.Request = object + sys.modules["fastapi.requests"] = req_mod + + pyd_mod = types.ModuleType("pydantic") + pyd_mod.BaseModel = _BaseModel + sys.modules["pydantic"] = pyd_mod + + stl_base = types.ModuleType("starlette.middleware.base") + stl_base.BaseHTTPMiddleware = object + sys.modules["starlette.middleware.base"] = stl_base + stl_mw = types.ModuleType("starlette.middleware") + sys.modules["starlette.middleware"] = stl_mw + stl = types.ModuleType("starlette") + sys.modules["starlette"] = stl _install_web_stubs() + from sovran_systemsos_web import server +from sovran_systemsos_web import nwc_hub_manager as mgr +from sovran_systemsos_web.nwc_lnurl_service import ( + _lnurl_callback, + _lnurl_discovery, +) -class FakeJSONResponse: +# ── Helpers ─────────────────────────────────────────────────────── + + +def _make_app( + id_=1, + name="Test Wallet", + alias="testwallet", + scopes=None, + pubkey="aabbcc", + balance_msat=0, + pending=None, + max_amount=0, +): + if scopes is None: + scopes = list(mgr.RECEIVE_ONLY_SCOPES) + return { + "id": id_, + "name": name, + "nostrPubkey": pubkey, + "scopes": scopes, + "isolated": True, + "maxAmountSat": max_amount, + "budgetRenewal": "never", + "budget": {"usedBudget": balance_msat, "remainingBudget": 0}, + "pendingTransactions": pending or [], + "metadata": { + "app_store_app_id": "uncle-jim", + "lnurl_alias": alias, + "lnurl_description": "Pay via Lightning", + "lnurl_min_sendable_msat": 1000, + "lnurl_max_sendable_msat": 1_000_000_000, + }, + "createdAt": 1700000000, + } + + +def _fresh_manager() -> mgr.AlbyHubManager: + """Return a new manager with non-existent paths so filesystem checks fail fast.""" + return mgr.AlbyHubManager( + api_base="http://127.0.0.1:18080", + unlock_password_file="/nonexistent/unlock-password", + macaroon_file="/nonexistent/albyhub.macaroon", + ) + + +# ── Feature registry tests ──────────────────────────────────────── + + +class FeatureRegistryTests(unittest.TestCase): + def test_node_role_includes_nwc_wallets(self): + self.assertIn("nwc-wallets", server.ROLE_FEATURES["node"]) + + def test_desktop_role_excludes_nwc_wallets(self): + self.assertNotIn("nwc-wallets", server.ROLE_FEATURES["desktop"]) + + def test_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") + ports = [(p["port"], p["protocol"]) for p in feat["port_requirements"]] + self.assertIn(("80", "TCP"), ports) + self.assertIn(("443", "TCP"), ports) + + def test_service_map_points_to_albyhub(self): + self.assertEqual(server.FEATURE_SERVICE_MAP["nwc-wallets"], "albyhub.service") + + def test_domain_map_points_to_albyhub(self): + self.assertEqual(server.SERVICE_DOMAIN_MAP["albyhub.service"], "lightning") + + def test_lnurl_paths_not_in_auth_exempt_prefixes(self): + for prefix in server._AUTH_EXEMPT_PREFIXES: + self.assertNotIn("lnurlp", prefix) + + def test_caddy_lnurl_proxy_port_is_not_8937(self): + """Caddy must proxy LNURL routes to the dedicated service port (8181), not Hub port 8937.""" + caddy_nix = ( + Path(__file__).resolve().parents[3] / "modules" / "core" / "caddy.nix" + ) + if caddy_nix.exists(): + content = caddy_nix.read_text() + # Find the LIGHTNING block + lightning_block = re.search( + r"LIGHTNING\s*\{[^}]+\}", content, re.DOTALL + ) + if lightning_block: + block = lightning_block.group(0) + self.assertNotIn( + "8937", + block, + "Caddy must NOT proxy LNURL routes to Hub port 8937", + ) + self.assertIn( + "8181", + block, + "Caddy must proxy LNURL routes to dedicated LNURL port 8181", + ) + + +# ── Alias validation ────────────────────────────────────────────── + + +class AliasValidationTests(unittest.TestCase): + def test_valid_aliases(self): + for alias in ("app", "a1", "my-wallet", "app_1", "a" * 32): + self.assertTrue(server._nwc_validate_alias(alias), f"expected valid: {alias}") + + def test_invalid_aliases(self): + for alias in ("_bad", "Upper", "a" * 33, "", "-start"): + self.assertFalse(server._nwc_validate_alias(alias), f"expected invalid: {alias}") + + +# ── Manager unit tests (mocked HTTP) ──────────────────────────────── + + +class ManagerEnsureReadyTests(unittest.TestCase): + def _manager_with_stubs(self, unlock_pw="testpass", macaroon_hex="deadbeef"): + m = _fresh_manager() + m._wait_for_file = MagicMock() + m._wait_for_hub_api = MagicMock() + m._read_unlock_password = MagicMock(return_value=unlock_pw) + m._wait_for_node_ready = MagicMock() + return m + + def test_setup_and_token_cached(self): + m = self._manager_with_stubs() + m._hub_setup = MagicMock() + m._hub_unlock = MagicMock() + m._obtain_token = MagicMock(return_value="tok123") + token = m.ensure_ready() + self.assertEqual(token, "tok123") + # Second call should use cached token without re-auth + token2 = m.ensure_ready() + self.assertEqual(token2, "tok123") + m._obtain_token.assert_called_once() + + def test_idempotent_setup_skipped_when_already_complete(self): + m = self._manager_with_stubs() + m._hub_setup = MagicMock() + m._hub_unlock = MagicMock() + m._obtain_token = MagicMock(return_value="tok-setup") + m.ensure_ready() + m._hub_setup.assert_called_once() + + def test_401_triggers_token_refresh(self): + m = self._manager_with_stubs() + m._hub_setup = MagicMock() + m._hub_unlock = MagicMock() + tokens = iter(["first-token", "refreshed-token"]) + m._obtain_token = MagicMock(side_effect=tokens) + m.ensure_ready() + + # First _request call raises 401; second returns success after token refresh + request_count = [0] + + def _request_side(*_a, **_kw): + request_count[0] += 1 + if request_count[0] == 1: + raise mgr.AlbyHubHttpError(401, "Unauthorised") + return {"ok": True} + + m._request = MagicMock(side_effect=_request_side) + result = m._authenticated_request("GET", "/api/apps") + # The retry with a refreshed token should succeed + self.assertEqual(result, {"ok": True}) + # Token must have been refreshed (obtain_token called twice total) + self.assertEqual(m._obtain_token.call_count, 2) + + def test_403_triggers_token_refresh(self): + m = self._manager_with_stubs() + m._hub_setup = MagicMock() + m._hub_unlock = MagicMock() + tokens = iter(["first", "second", "third"]) + m._obtain_token = MagicMock(side_effect=tokens) + m.ensure_ready() + m._token = None # clear to force re-auth + + request_count = [0] + + def _request_side(*_a, **_kw): + request_count[0] += 1 + if request_count[0] == 1: + raise mgr.AlbyHubHttpError(403, "Forbidden") + return {} + + m._request = MagicMock(side_effect=_request_side) + result = m._authenticated_request("GET", "/api/apps") + self.assertEqual(result, {}) + + +class ManagerPaginationTests(unittest.TestCase): + def test_paginate_collects_all_pages(self): + m = _fresh_manager() + page1 = [{"id": i} for i in range(100)] + page2 = [{"id": i} for i in range(100, 150)] + + def _request(method, path, **_kw): + if "offset=0" in path: + return page1 + return page2 + + m._token = "tok" + m._request = MagicMock(side_effect=_request) + result = m._paginate("/api/apps?limit={limit}&offset={offset}") + self.assertEqual(len(result), 150) + + def test_paginate_single_page_stops(self): + m = _fresh_manager() + m._token = "tok" + m._request = MagicMock(return_value=[{"id": 1}, {"id": 2}]) + result = m._paginate("/api/apps?limit={limit}&offset={offset}", page_size=100) + self.assertEqual(len(result), 2) + m._request.assert_called_once() + + +class ManagerListTests(unittest.TestCase): + def _mgr_with_token(self, apps): + m = _fresh_manager() + m._token = "tok" + m._request = MagicMock(return_value=apps) + return m + + def test_list_returns_only_managed_isolated_apps(self): + apps = [ + _make_app(id_=1, alias="alice"), + { + "id": 2, "name": "Unmanaged", "isolated": True, + "metadata": {"app_store_app_id": "other"}, + "scopes": [], "budget": {}, "pendingTransactions": [], + }, + { + "id": 3, "name": "Not isolated", "isolated": False, + "metadata": {"app_store_app_id": "uncle-jim"}, + "scopes": [], "budget": {}, "pendingTransactions": [], + }, + ] + m = self._mgr_with_token(apps) + result = m.list_wallets(domain="pay.example.com") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["alias"], "alice") + + def test_list_does_not_include_pairing_uri(self): + apps = [_make_app()] + m = self._mgr_with_token(apps) + for wallet in m.list_wallets(): + self.assertNotIn("pairing_uri", wallet) + self.assertNotIn("pairingUri", wallet) + + def test_list_maps_access_preset_from_scopes(self): + apps = [ + _make_app(id_=1, alias="recv", scopes=list(mgr.RECEIVE_ONLY_SCOPES)), + _make_app(id_=2, alias="send", scopes=list(mgr.LIMITED_SEND_SCOPES)), + ] + m = self._mgr_with_token(apps) + wallets = m.list_wallets() + self.assertEqual(wallets[0]["access_preset"], "receive_only") + self.assertEqual(wallets[1]["access_preset"], "send_receive_limited") + + def test_list_maps_lightning_address(self): + apps = [_make_app(alias="bob")] + m = self._mgr_with_token(apps) + result = m.list_wallets(domain="pay.example.com") + self.assertEqual(result[0]["lightning_address"], "bob@pay.example.com") + + +class ManagerCreateTests(unittest.TestCase): + def _mgr(self, existing_apps=None, create_resp=None): + m = _fresh_manager() + m._token = "tok" + if existing_apps is None: + existing_apps = [] + if create_resp is None: + create_resp = { + "id": 99, + "pairingUri": "nostr+walletconnect://fakepubkey?relay=wss%3A%2F%2Frelay.getalby.com&secret=FAKESECRET", + **_make_app(id_=99, alias="new"), + } + + call_count = [0] + + def _request(method, path, **kw): + call_count[0] += 1 + if method == "GET" and path.startswith("/api/apps"): + return existing_apps + if method == "POST" and path == "/api/apps": + return create_resp + if method == "GET" and path.startswith("/api/apps/99"): + return _make_app(id_=99, alias="new") + return {} + + m._request = MagicMock(side_effect=_request) + return m + + def test_create_returns_pairing_uri_once(self): + m = self._mgr() + result = m.create_wallet("New Wallet", "new", "receive_only", None) + self.assertIn("pairing_uri", result) + self.assertTrue(result["pairing_uri"].startswith("nostr+walletconnect://")) + + def test_create_sends_isolated_true(self): + m = self._mgr() + m.create_wallet("W", "w", "receive_only", None) + create_call = next( + c for c in m._request.call_args_list + if c[0][0] == "POST" and c[0][1] == "/api/apps" + ) + body = create_call[1].get("body") or create_call[0][2] if len(create_call[0]) > 2 else create_call.kwargs.get("body") + self.assertTrue(body.get("isolated")) + + def test_create_receive_only_scopes(self): + m = self._mgr() + m.create_wallet("W", "w", "receive_only", None) + create_call = next( + c for c in m._request.call_args_list + if c[0][0] == "POST" and "/api/apps" in c[0][1] + ) + body = create_call.kwargs.get("body") or (create_call[0][2] if len(create_call[0]) > 2 else {}) + self.assertNotIn("pay_invoice", body.get("scopes", [])) + for scope in mgr.RECEIVE_ONLY_SCOPES: + self.assertIn(scope, body.get("scopes", [])) + + def test_create_limited_includes_pay_invoice(self): + m = self._mgr() + m.create_wallet("W", "w", "send_receive_limited", 5000) + create_call = next( + c for c in m._request.call_args_list + if c[0][0] == "POST" and "/api/apps" in c[0][1] + ) + body = create_call.kwargs.get("body") or (create_call[0][2] if len(create_call[0]) > 2 else {}) + self.assertIn("pay_invoice", body.get("scopes", [])) + + def test_create_includes_managed_metadata(self): + m = self._mgr() + m.create_wallet("W", "w", "receive_only", None) + create_call = next( + c for c in m._request.call_args_list + if c[0][0] == "POST" and "/api/apps" in c[0][1] + ) + body = create_call.kwargs.get("body") or (create_call[0][2] if len(create_call[0]) > 2 else {}) + meta = body.get("metadata", {}) + self.assertEqual(meta.get("app_store_app_id"), "uncle-jim") + self.assertEqual(meta.get("lnurl_alias"), "w") + + def test_create_rejects_duplicate_alias(self): + existing = [_make_app(alias="dup")] + m = self._mgr(existing_apps=existing) + with self.assertRaises(mgr.AlbyHubError) as ctx: + m.create_wallet("New", "dup", "receive_only", None) + self.assertEqual(ctx.exception.code, "alias_exists") + + def test_create_rejects_duplicate_name(self): + existing = [_make_app(name="Existing Wallet")] + m = self._mgr(existing_apps=existing) + with self.assertRaises(mgr.AlbyHubError) as ctx: + m.create_wallet("Existing Wallet", "newone", "receive_only", None) + self.assertEqual(ctx.exception.code, "wallet_name_exists") + + def test_create_limited_performs_initial_transfer(self): + m = self._mgr() + transfers = [] + + original_request = m._request.side_effect + + def _request(method, path, **kw): + if method == "POST" and path == "/api/transfers": + transfers.append(kw.get("body")) + return {} + return original_request(method, path, **kw) + + m._request = MagicMock(side_effect=_request) + m.create_wallet("W", "w", "send_receive_limited", 5000) + self.assertEqual(len(transfers), 1) + self.assertEqual(transfers[0]["toAppId"], 99) + self.assertEqual(transfers[0]["amountMsat"], 5_000_000) + + def test_create_partial_failure_funding_returns_pairing_uri(self): + """Even when initial funding fails, the real pairing URI must be returned.""" + m = self._mgr() + + def _request(method, path, **kw): + if method == "GET" and path.startswith("/api/apps?"): + return [] + if method == "POST" and path == "/api/apps": + return { + "id": 99, + "pairingUri": "nostr+walletconnect://pubkey?relay=r&secret=S", + **_make_app(id_=99, alias="new"), + } + if method == "POST" and path == "/api/transfers": + raise mgr.AlbyHubError("transfer_failed", "Insufficient funds") + if method == "GET" and "/api/apps/99" in path: + return _make_app(id_=99, alias="new") + return {} + + m._request = MagicMock(side_effect=_request) + result = m.create_wallet("W", "new", "send_receive_limited", 5000) + + # Pairing URI must still be returned + self.assertTrue(result["pairing_uri"]) + # Funding failure must be clearly reported + self.assertFalse(result["result"]["funding"]["success"]) + self.assertIn("message", result["result"]["funding"]) + + +class ManagerDrainTests(unittest.TestCase): + def _mgr(self, app, transfer_ok=True): + m = _fresh_manager() + m._token = "tok" + + def _request(method, path, **kw): + if method == "GET" and path.startswith("/api/apps?"): + return [app] + if method == "GET" and f"/api/apps/{app['id']}" in path and "transactions" not in path: + return app + if method == "GET" and "transactions" in path: + return [] + if method == "PATCH": + return {} + if method == "POST" and path == "/api/transfers": + if not transfer_ok: + raise mgr.AlbyHubError("transfer_failed", "Fail") + return {} + return {} + + m._request = MagicMock(side_effect=_request) + return m + + def test_drain_transfers_whole_sats(self): + app = _make_app(balance_msat=5_000_000) + m = self._mgr(app) + result = m.drain_wallet("1") + self.assertTrue(result["ok"]) + self.assertEqual(result["drained_sats"], 5000) + + def test_drain_preserves_dust(self): + app = _make_app(balance_msat=5_000_500) + m = self._mgr(app) + result = m.drain_wallet("1") + self.assertEqual(result["drained_sats"], 5000) + self.assertEqual(result["dust_msat"], 500) + + def test_drain_patches_permissions_then_restores(self): + app = _make_app(scopes=list(mgr.RECEIVE_ONLY_SCOPES), balance_msat=1_000_000) + m = self._mgr(app) + patches = [] + + original = m._request.side_effect + + def _request(method, path, **kw): + if method == "PATCH": + patches.append(kw.get("body")) + return {} + return original(method, path, **kw) + + m._request = MagicMock(side_effect=_request) + m.drain_wallet("1") + self.assertEqual(len(patches), 2) + first_patch = patches[0] + second_patch = patches[1] + # First patch must add pay_invoice + self.assertIn("pay_invoice", first_patch.get("scopes", [])) + # Second patch (restore) must match original scopes + self.assertEqual( + sorted(second_patch.get("scopes", [])), + sorted(mgr.RECEIVE_ONLY_SCOPES), + ) + + def test_drain_rejects_pending_transactions(self): + app = _make_app(pending=[{"state": "pending"}]) + m = _fresh_manager() + m._token = "tok" + + def _request(method, path, **kw): + if method == "GET" and path.startswith("/api/apps?"): + return [app] + if "transactions" in path: + return [{"state": "pending"}] + return {} + + m._request = MagicMock(side_effect=_request) + with self.assertRaises(mgr.AlbyHubError) as ctx: + m.drain_wallet("1") + self.assertEqual(ctx.exception.code, "pending_transactions") + + def test_drain_restores_permissions_on_failure(self): + app = _make_app(scopes=list(mgr.RECEIVE_ONLY_SCOPES), balance_msat=1_000_000) + m = self._mgr(app, transfer_ok=False) + patches = [] + original = m._request.side_effect + + def _request(method, path, **kw): + if method == "PATCH": + patches.append(kw.get("body")) + return {} + return original(method, path, **kw) + + m._request = MagicMock(side_effect=_request) + with self.assertRaises(mgr.AlbyHubError): + m.drain_wallet("1") + # Restore patch must still have been attempted + self.assertGreaterEqual(len(patches), 2) + restore = patches[-1] + self.assertEqual(sorted(restore.get("scopes", [])), sorted(mgr.RECEIVE_ONLY_SCOPES)) + + +class ManagerDeleteTests(unittest.TestCase): + def _mgr(self, app, drain_ok=True): + m = _fresh_manager() + m._token = "tok" + deleted = [] + + def _request(method, path, **kw): + if method == "GET" and path.startswith("/api/apps?"): + return [app] + if method == "GET" and "transactions" in path: + return [] + if method == "GET" and f"/api/apps/{app['id']}" in path: + # After drain the balance is zero + a = dict(app) + a["budget"] = {"usedBudget": 0} + return a + if method == "PATCH": + return {} + if method == "POST" and path == "/api/transfers": + if not drain_ok: + raise mgr.AlbyHubError("transfer_failed", "Fail") + return {} + if method == "DELETE": + deleted.append(path) + return {} + return {} + + m._request = MagicMock(side_effect=_request) + m._deleted = deleted + return m + + def test_delete_uses_pubkey_endpoint(self): + app = _make_app(pubkey="pubkey123", balance_msat=0) + m = self._mgr(app) + m.delete_wallet("1") + delete_path = m._deleted[0] if m._deleted else "" + self.assertIn("pubkey123", delete_path) + + def test_delete_drains_before_deleting(self): + app = _make_app(pubkey="pk", balance_msat=1_000_000) + m = self._mgr(app) + m.delete_wallet("1") + # Ensure DELETE was called (drain happened first) + self.assertTrue(m._deleted) + + def test_delete_rejects_pending_transactions(self): + app = _make_app(pending=[{"state": "pending"}]) + m = _fresh_manager() + m._token = "tok" + + def _request(method, path, **kw): + if method == "GET" and path.startswith("/api/apps?"): + return [app] + if "transactions" in path: + return [{"state": "pending"}] + return {} + + m._request = MagicMock(side_effect=_request) + with self.assertRaises(mgr.AlbyHubError) as ctx: + m.delete_wallet("1") + self.assertEqual(ctx.exception.code, "pending_transactions") + + +class ManagerInvoiceTests(unittest.TestCase): + def _mgr(self, invoice="lnbc1000n1ptest", app_id=1): + m = _fresh_manager() + m._token = "tok" + m._request = MagicMock( + return_value={"paymentRequest": invoice, "appId": app_id} + ) + return m + + def test_invoice_returns_valid_bolt11(self): + m = self._mgr("lnbc5000n1pfake_invoice_test") + invoice = m.issue_invoice(1, 5_000_000) + self.assertTrue(invoice.startswith("lnbc")) + + def test_invoice_rejects_fake_pr_string(self): + m = self._mgr("lnbc5n1" + "z" * 40) + # This starts with lnbc so is valid format - test the appId mismatch instead + m._request = MagicMock( + return_value={"paymentRequest": "not_a_bolt11", "appId": 1} + ) + with self.assertRaises(mgr.AlbyHubError) as ctx: + m.issue_invoice(1, 5_000_000) + self.assertEqual(ctx.exception.code, "invalid_invoice") + + def test_invoice_rejects_appid_mismatch(self): + m = _fresh_manager() + m._token = "tok" + m._request = MagicMock( + return_value={"paymentRequest": "lnbc1000n1test", "appId": 999} + ) + with self.assertRaises(mgr.AlbyHubError) as ctx: + m.issue_invoice(1, 1_000_000) + self.assertEqual(ctx.exception.code, "invoice_attribution_failed") + + def test_invoice_request_includes_app_id(self): + # Use a manager that returns the correct appId matching what we request + m = _fresh_manager() + m._token = "tok" + m._request = MagicMock( + return_value={"paymentRequest": "lnbc1000n1test", "appId": 42} + ) + m.issue_invoice(42, 2_000_000) + call_body = m._request.call_args.kwargs.get("body") or m._request.call_args[1].get("body") + self.assertEqual(call_body["appId"], 42) + + +# ── LNURL service tests ─────────────────────────────────────────── + + +class LnurlDiscoveryTests(unittest.TestCase): + def _manager_for(self, app): + m = _fresh_manager() + m._token = "tok" + m._request = MagicMock(return_value=[app]) + return m + + def test_discovery_returns_pay_request(self): + app = _make_app(alias="alice") + m = self._manager_for(app) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + payload, code = _lnurl_discovery("alice", m) + self.assertEqual(code, 200) + self.assertEqual(payload["tag"], "payRequest") + self.assertIn("alice", payload["callback"]) + + def test_discovery_uses_app_metadata_for_limits(self): + app = _make_app(alias="bob") + app["metadata"]["lnurl_min_sendable_msat"] = 2000 + app["metadata"]["lnurl_max_sendable_msat"] = 500_000_000 + m = self._manager_for(app) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + payload, code = _lnurl_discovery("bob", m) + self.assertEqual(payload["minSendable"], 2000) + self.assertEqual(payload["maxSendable"], 500_000_000) + + def test_discovery_returns_404_for_unknown_alias(self): + m = _fresh_manager() + m._token = "tok" + m._request = MagicMock(return_value=[]) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + payload, code = _lnurl_discovery("nobody", m) + self.assertEqual(code, 404) + + def test_discovery_returns_503_when_domain_unconfigured(self): + m = self._manager_for(_make_app()) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value=None): + payload, code = _lnurl_discovery("alice", m) + self.assertEqual(code, 503) + + +class LnurlCallbackTests(unittest.TestCase): + def _manager_for(self, app, invoice="lnbc1000n1pfakebolt11test"): + m = _fresh_manager() + m._token = "tok" + + def _request(method, path, **kw): + if path.startswith("/api/apps"): + return [app] + if path == "/api/invoices": + body = kw.get("body") or {} + return {"paymentRequest": invoice, "appId": body.get("appId")} + return {} + + m._request = MagicMock(side_effect=_request) + return m + + def test_callback_returns_bolt11_invoice(self): + app = _make_app(alias="carol") + m = self._manager_for(app) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + payload, code = _lnurl_callback("carol", "1000", m) + self.assertEqual(code, 200) + self.assertIn("pr", payload) + self.assertEqual(payload["routes"], []) + self.assertTrue(payload["pr"].startswith("lnbc")) + + def test_callback_sends_app_id_to_invoices_api(self): + app = _make_app(id_=7, alias="dave") + m = self._manager_for(app) + invoice_calls = [] + original = m._request.side_effect + + def _request(method, path, **kw): + if method == "POST" and path == "/api/invoices": + invoice_calls.append(kw.get("body")) + return original(method, path, **kw) + + m._request = MagicMock(side_effect=_request) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + _lnurl_callback("dave", "1000", m) + self.assertEqual(len(invoice_calls), 1) + self.assertEqual(invoice_calls[0]["appId"], 7) + + def test_callback_rejects_amount_below_minimum(self): + app = _make_app(alias="eve") + m = self._manager_for(app) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + payload, code = _lnurl_callback("eve", "500", m) + self.assertEqual(code, 400) + + def test_callback_rejects_non_whole_satoshi(self): + app = _make_app(alias="frank") + m = self._manager_for(app) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + payload, code = _lnurl_callback("frank", "1500", m) + self.assertEqual(code, 400) + + def test_callback_rejects_appid_mismatch(self): + app = _make_app(id_=1, alias="grace") + m = _fresh_manager() + m._token = "tok" + + def _request(method, path, **kw): + if path.startswith("/api/apps"): + return [app] + if path == "/api/invoices": + # Return wrong appId + return {"paymentRequest": "lnbc1000n1pfake", "appId": 999} + return {} + + m._request = MagicMock(side_effect=_request) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + payload, code = _lnurl_callback("grace", "1000", m) + self.assertEqual(code, 502) + + def test_callback_rejects_fake_bolt11(self): + app = _make_app(id_=1, alias="heidi") + m = _fresh_manager() + m._token = "tok" + + def _request(method, path, **kw): + if path.startswith("/api/apps"): + return [app] + if path == "/api/invoices": + return {"paymentRequest": "not_a_bolt11_string", "appId": 1} + return {} + + m._request = MagicMock(side_effect=_request) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + payload, code = _lnurl_callback("heidi", "1000", m) + self.assertEqual(code, 502) + + +# ── Server API integration tests ───────────────────────────────── + + +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"]) +class ServerApiTests(unittest.IsolatedAsyncioTestCase): + """Thin tests ensuring the server API routes call the manager correctly.""" - 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")], + def _mock_manager(self, wallets=None, create_result=None, domain=None): + m = MagicMock() + m.list_wallets.return_value = wallets or [] + if create_result: + m.create_wallet.return_value = create_result + return m + + async def test_api_nwc_wallets_returns_wallets(self): + fake_manager = self._mock_manager( + wallets=[{"id": "1", "name": "W", "alias": "w"}] ) + with ( + patch.object(server._nwc_mgr, "get_manager", return_value=fake_manager), + patch.object(server, "_nwc_domain", return_value="pay.example.com"), + ): + result = await server.api_nwc_wallets() + self.assertEqual(len(result["wallets"]), 1) + async def test_api_create_returns_pairing_uri(self): + pairing_uri = "nostr+walletconnect://pk?relay=r&secret=S" + fake_manager = self._mock_manager( + create_result={ + "wallet": {"id": "1", "alias": "new"}, + "pairing_uri": pairing_uri, + "result": {"wallet_created": True, "secret_created": True, "lightning_address_registered": True, "funding": {"attempted": False}}, + } + ) + req = types.SimpleNamespace( + name="New Wallet", + alias="newwallet", + access_preset="receive_only", + spending_limit_sats=None, + ) + with ( + patch.object(server._nwc_mgr, "get_manager", return_value=fake_manager), + patch.object(server, "_nwc_domain", return_value="pay.example.com"), + patch.object(server, "_nwc_test_address", return_value={"ok": True}), + patch.object(server, "_generate_qr_base64", return_value="data:image/png;base64,abc"), + patch.object(server, "JSONResponse", _FakeJSONResponse), + ): + resp = await server.api_nwc_create_wallet(req) + body = json.loads(resp.body.decode("utf-8")) + self.assertEqual(body["pairing_uri"], pairing_uri) + self.assertEqual(resp.status_code, 201) -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_api_create_pairing_qrcode_in_response(self): + pairing_uri = "nostr+walletconnect://pk?relay=r&secret=S" + fake_manager = self._mock_manager( + create_result={ + "wallet": {"id": "1", "alias": "new"}, + "pairing_uri": pairing_uri, + "result": {"wallet_created": True, "secret_created": True, "lightning_address_registered": True, "funding": {"attempted": False}}, + } + ) + req = types.SimpleNamespace( + name="QR Wallet", alias="qrwallet", access_preset="receive_only", + spending_limit_sats=None, + ) + with ( + patch.object(server._nwc_mgr, "get_manager", return_value=fake_manager), + patch.object(server, "_nwc_domain", return_value="pay.example.com"), + patch.object(server, "_nwc_test_address", return_value={"ok": False}), + patch.object(server, "_generate_qr_base64", return_value="data:image/png;base64,qrdata"), + patch.object(server, "JSONResponse", _FakeJSONResponse), + ): + resp = await server.api_nwc_create_wallet(req) + body = json.loads(resp.body.decode("utf-8")) + self.assertEqual(body.get("pairing_qrcode"), "data:image/png;base64,qrdata") - 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"}), - patch.object(server, "_generate_qr_base64", return_value="data:image/png;base64,abc"), - ): - 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) - 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: - 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") + async def test_api_create_rejects_invalid_alias(self): + req = types.SimpleNamespace( + name="Bad", alias="_INVALID", access_preset="receive_only", + spending_limit_sats=None, + ) + with patch.object(server, "JSONResponse", _FakeJSONResponse): + resp = await server.api_nwc_create_wallet(req) + body = json.loads(resp.body.decode("utf-8")) + self.assertEqual(resp.status_code, 400) + self.assertEqual(body["error"], "alias_invalid") if __name__ == "__main__": diff --git a/docs/wallet-connections.md b/docs/wallet-connections.md index 0137762..73896fd 100644 --- a/docs/wallet-connections.md +++ b/docs/wallet-connections.md @@ -1,6 +1,6 @@ # Wallet Connections -Wallet Connections is a Hub-managed Sovran_SystemsOS feature that lets members create isolated Lightning app connections and reusable Lightning Addresses. +Wallet Connections is a Hub-managed Sovran_SystemsOS feature that lets members create isolated Lightning app connections and reusable Lightning Addresses backed by a local LND node via Alby Hub. ## Enablement flow @@ -11,60 +11,96 @@ 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) +## Service-detail modal UX -Wallet management now runs inside the existing **Wallet Connections** service-detail modal with dedicated states: +Wallet management 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. +3. **Created/secret state**: one-time pairing secret (URI + QR) shown with prominent warning: + **Keep the NWC string and QR private. The NWC connection secret cannot be displayed again.** 4. **Wallet list state**: per-wallet actions for verify/test, drain, and delete. -Guardrails in modal flow: +Pairing URI and QR data are cleared when: +- "I Saved This Secret" is clicked +- the service modal X is closed +- the overlay closes the modal +- navigation otherwise leaves the secret view +Guardrails: - 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 -Public path: +``` +Authenticated Hub management API + -> local Alby Hub (port 8080, loopback only) + -> local LND -`Internet -> DNS/DDNS -> router 80/443 -> Caddy -> LNURL endpoints -> Hub NWC backend -> local LND stack` +Public Lightning Address + -> local Caddy on 80/443 + -> loopback nwc-lnurl service (port 8181) + -> local Alby Hub invoice API with isolated appId + -> local LND +``` Security invariants: -- Alby/NWC management remains local to the host. -- LNURL callback/discovery are exposed only through Caddy on 80/443. +- Alby Hub management port (8080) is never opened to the public firewall. +- Dedicated LNURL service port (8181) is never opened to the public firewall. +- LNURL callback and 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 secrets are returned only on create responses and are never stored. - 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. +- Invoice attribution enforces wallet isolation via numeric appId on every LNURL callback. +- Wallet Connections state (Alby Hub database) is never exposed as a plain JSON file. ## Domain and runtime files - Domain key: `lightning` - Runtime domain file: `/var/lib/domains/lightning` -- Wallet state: `/var/lib/nwc-wallets/state.json` +- Alby Hub state: `/var/lib/albyhub/` (restrictive permissions, secret-bearing) +- Alby Hub database: `/var/lib/albyhub/nwc.db` +- Alby Hub unlock password: `/var/lib/albyhub/unlock-password` (generated once, mode 0600) +- LND macaroon for Alby Hub: `/run/lnd/albyhub.macaroon` (restricted permissions) + +## Alby Hub version pin and patches + +Alby Hub is packaged in `modules/nwc-wallets.nix` with the following patches: + +1. **Private route hints** (`0001-lnd-private-route-hints.patch`): sets `Private: true` in regular LND `MakeInvoice` requests so that wallets behind private channels can receive payments via route hints. Hold-invoice behavior is unchanged. +2. **Invoice app attribution** (`0002-invoice-app-attribution.patch`): extends `CreateInvoice`, `MakeInvoiceRequest`, `http_service`, and `wails_handlers` to accept and pass an optional numeric `appId` so that LNURL callbacks can attribute invoices to a specific isolated subwallet. Desktop/Wails calls pass `nil` and continue using the primary wallet. + +The `vendorHash` and `sha256` fields in the derivation must be updated whenever the Alby Hub version changes. + +## Services + +| Service | User | Description | +|---|---|---| +| `albyhub.service` | `albyhub` | Headless Alby Hub NWC wallet server | +| `nwc-lnurl.service` | `nwc-lnurl` | Dedicated LNURL discovery and callback service | +| `albyhub-init.service` | `root` (oneshot) | Generates `unlock-password` once on first boot | ## 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` +Management (authenticated): -Public LNURL: +- `GET /api/nwc/wallets` — list all managed wallets (no secrets) +- `POST /api/nwc/wallets` — create a wallet; returns `pairing_uri` exactly once +- `DELETE /api/nwc/wallets/{id-or-pubkey}` — drain and delete +- `POST /api/nwc/wallets/{id-or-pubkey}/drain` — transfer funds to primary wallet +- `POST /api/nwc/addresses/{alias}/test` — verify public LNURL endpoint -- `GET /.well-known/lnurlp/{alias}` -- `GET /lnurlp/{alias}/callback?amount=` +Public LNURL (served by dedicated `nwc-lnurl` service via Caddy): + +- `GET /.well-known/lnurlp/{alias}` — LNURL-pay discovery +- `GET /lnurlp/{alias}/callback?amount=` — invoice creation via Alby Hub ## Recovery CLI -`nwc-wallet` is included with Hub package: +`nwc-wallet` is included with the Hub package and calls the real Alby Hub manager: - `nwc-wallet create --receive-only` - `nwc-wallet create --limit-sats ` @@ -74,6 +110,37 @@ Public LNURL: - `nwc-wallet address show ` - `nwc-wallet health` +A CLI `create` command prints the real NWC pairing secret once. Keep it private. + ## 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. +`/var/lib/albyhub` is the authoritative Alby Hub state directory. It contains: +- The SQLite database (`nwc.db`) with all wallet app records. +- The unlock password (`unlock-password`). + +This directory **must be treated as secret-bearing wallet material**. Backups containing it must be encrypted and access-controlled. + +To back up while the service is running, use SQLite online backup (`VACUUM INTO`) or a controlled brief service stop rather than a live `cp`. A brief stop of `albyhub.service` before copying `nwc.db` is the safest approach. + +Disabling Wallet Connections stops `albyhub.service` and `nwc-lnurl.service` and removes the Caddy exposure, while preserving `/var/lib/albyhub`. Re-enabling and rebuilding restores all existing connections — no secrets need to be regenerated. + +## Troubleshooting + +**Alby Hub service not starting:** +- Check `journalctl -u albyhub.service` for errors. +- Verify `/var/lib/albyhub/unlock-password` exists and is readable by `albyhub`. +- Verify `/run/lnd/albyhub.macaroon` exists (LND must be running and the macaroon generated). + +**LNURL discovery returning 503:** +- Check that `albyhub.service` is running. +- Check that `/var/lib/domains/lightning` contains the correct domain. +- Check `journalctl -u nwc-lnurl.service`. + +**Invoice creation failing:** +- Verify LND has sufficient inbound liquidity on channels with route hints. +- Check `journalctl -u albyhub.service` for LND RPC errors. + +**Wallet creation partial failure (funding not transferred):** +- The wallet was created and the NWC connection secret was shown. Save it. +- Do not create another wallet for the same alias. +- Fund the isolated wallet manually via Alby Hub's internal transfer API. diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index 5c91296..ec00a3c 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -193,9 +193,11 @@ EOF cat >> /run/caddy/Caddyfile </dev/null || true + ''; + + meta = { + description = "Alby Hub — self-hosted NWC wallet server (Sovran_SystemsOS build)"; + license = lib.licenses.gpl3; + mainProgram = "hub"; + }; + }; + + # Python environment for the dedicated LNURL service + nwcLnurlPython = pkgs.python3.withPackages (_ps: []); + +in lib.mkIf config.sovran_systemsOS.features."nwc-wallets" { assertions = [ { assertion = config.services.lnd.enable; - message = "Wallet Connections requires services.lnd.enable = true."; + message = "Wallet Connections requires services.lnd.enable = true."; } ]; - users.groups.nwc-wallets = {}; - users.users.nwc-wallets = { + # ── Users and groups ───────────────────────────────────────────── + users.groups.albyhub = {}; + users.users.albyhub = { isSystemUser = true; - group = "nwc-wallets"; - home = "/var/lib/nwc-wallets"; - createHome = true; + group = "albyhub"; + home = "/var/lib/albyhub"; + createHome = false; + extraGroups = []; }; + users.groups.nwc-lnurl = {}; + users.users.nwc-lnurl = { + isSystemUser = true; + group = "nwc-lnurl"; + home = "/var/lib/nwc-lnurl"; + createHome = false; + extraGroups = [ "albyhub" ]; # needs to read /var/lib/albyhub/unlock-password + }; + + # ── State directories ──────────────────────────────────────────── systemd.tmpfiles.rules = [ - "d /var/lib/nwc-wallets 0750 nwc-wallets nwc-wallets -" - "f /var/lib/nwc-wallets/state.json 0640 nwc-wallets nwc-wallets -" + "d /var/lib/albyhub 0700 albyhub albyhub -" + "d /var/lib/nwc-lnurl 0750 nwc-lnurl nwc-lnurl -" ]; - systemd.services.nwc-wallets = { - description = "Wallet Connections state initializer"; - wantedBy = [ "multi-user.target" ]; - after = [ "lnd.service" "sovran-hub-web.service" ]; - requires = [ "lnd.service" "sovran-hub-web.service" ]; + # ── Restricted LND macaroon for Alby Hub ──────────────────────── + services.lnd.macaroons.albyhub = { + user = "albyhub"; + permissions = '' + {"entity":"info","action":"read"}, + {"entity":"offchain","action":"read"}, + {"entity":"offchain","action":"write"}, + {"entity":"invoices","action":"read"}, + {"entity":"invoices","action":"write"}, + {"entity":"onchain","action":"read"}, + {"entity":"address","action":"read"}, + {"entity":"message","action":"read"}, + {"entity":"message","action":"write"} + ''; + }; + + # ── Alby Hub unlock-password (generated once) ──────────────────── + systemd.services.albyhub-init = { + description = "Initialise Alby Hub state directory and unlock password"; + wantedBy = [ "multi-user.target" ]; + before = [ "albyhub.service" ]; serviceConfig = { - Type = "oneshot"; + Type = "oneshot"; RemainAfterExit = true; - User = "nwc-wallets"; - Group = "nwc-wallets"; - UMask = "0027"; + User = "root"; + UMask = "0077"; + }; + script = '' + install -d -m 0700 -o albyhub -g albyhub /var/lib/albyhub + if [ ! -f /var/lib/albyhub/unlock-password ]; then + ${pkgs.openssl}/bin/openssl rand -hex 32 > /var/lib/albyhub/unlock-password + chown albyhub:albyhub /var/lib/albyhub/unlock-password + chmod 0600 /var/lib/albyhub/unlock-password + fi + ''; + }; + + # ── Alby Hub service ───────────────────────────────────────────── + systemd.services.albyhub = { + description = "Alby Hub — NWC wallet server"; + wantedBy = [ "multi-user.target" ]; + after = [ + "network.target" + "lnd.service" + "albyhub-init.service" + ]; + requires = [ "lnd.service" "albyhub-init.service" ]; + + environment = { + WORK_DIR = "/var/lib/albyhub"; + PORT = "8080"; + LDK_NETWORK = "bitcoin"; + LOG_TO_FILE = "false"; + AUTO_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password"; + ALBY_ACCOUNT_AUTOLINK = "false"; + ALBY_DISABLE_EVENTS = "true"; + ENABLE_SECURE_COOKIE = "false"; + ALBY_HUB_HIDE_VERSION_BANNER = "true"; + }; + + serviceConfig = { + Type = "simple"; + User = "albyhub"; + Group = "albyhub"; + WorkingDirectory = "/var/lib/albyhub"; + ExecStart = "${albyhub}/bin/hub"; + Restart = "on-failure"; + RestartSec = "10s"; + UMask = "0027"; NoNewPrivileges = true; - PrivateTmp = true; - ProtectHome = true; - ProtectSystem = "strict"; - ReadWritePaths = [ "/var/lib/nwc-wallets" ]; - ExecStart = pkgs.writeShellScript "nwc-wallets-init" '' - set -euo pipefail - install -d -m 0750 -o nwc-wallets -g nwc-wallets /var/lib/nwc-wallets - if [ ! -s /var/lib/nwc-wallets/state.json ]; then - cat > /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 - ''; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + ReadWritePaths = [ "/var/lib/albyhub" ]; + ReadOnlyPaths = [ + config.services.lnd.certFile or "/var/lib/lnd/tls.cert" + "/run/lnd" + ]; }; }; + # ── Dedicated LNURL service ────────────────────────────────────── + systemd.services.nwc-lnurl = { + description = "Wallet Connections public LNURL service"; + wantedBy = [ "multi-user.target" ]; + after = [ "albyhub.service" "sovran-hub-web.service" ]; + wants = [ "albyhub.service" ]; + + serviceConfig = { + Type = "simple"; + User = "nwc-lnurl"; + Group = "nwc-lnurl"; + ExecStart = pkgs.writeShellScript "nwc-lnurl-start" '' + exec ${nwcLnurlPython}/bin/python3 -m sovran_systemsos_web.nwc_lnurl_service + ''; + Restart = "on-failure"; + RestartSec = "10s"; + UMask = "0027"; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + ReadOnlyPaths = [ + "/var/lib/domains/lightning" + "/var/lib/albyhub/unlock-password" + ]; + }; + }; + + # ── Domain requirement ─────────────────────────────────────────── sovran_systemsOS.domainRequirements = [ { - name = "lightning"; - label = "Lightning Address Domain"; - example = "pay.yourdomain.com"; + name = "lightning"; + label = "Lightning Address Domain"; + example = "pay.yourdomain.com"; needsDDNS = true; } ];