diff --git a/app/icons/nwc.svg b/app/icons/nwc.svg new file mode 100644 index 0000000..d995567 --- /dev/null +++ b/app/icons/nwc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/sovran_systemsos_web/nwc_hub_manager.py b/app/sovran_systemsos_web/nwc_hub_manager.py index 4f28da6..2dd337d 100644 --- a/app/sovran_systemsos_web/nwc_hub_manager.py +++ b/app/sovran_systemsos_web/nwc_hub_manager.py @@ -26,9 +26,11 @@ logger = logging.getLogger(__name__) 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_MACAROON_FILE = os.environ.get( + "NWC_LND_MACAROON_FILE", "/run/lnd/albyhub.macaroon" +) +DEFAULT_LND_ADDRESS = os.environ.get("NWC_LND_ADDRESS", "127.0.0.1:10009") +DEFAULT_LND_CERT_FILE = os.environ.get("NWC_LND_CERT_FILE", "/var/lib/lnd/tls.cert") DEFAULT_LND_SOCKET = "/run/lnd/lnd.socket" LNURL_DESCRIPTION_DEFAULT = "Pay via Lightning" @@ -172,16 +174,22 @@ class AlbyHubManager: 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" + total_count: int | None = None if isinstance(page, list): items = page elif isinstance(page, dict): items = page.get("apps") or page.get("transactions") or [] + if page.get("totalCount") is not None: + total_count = int(page.get("totalCount")) else: items = [] if not isinstance(items, list): break results.extend(items) - if len(items) < page_size: + if total_count is not None: + if len(results) >= total_count: + break + elif len(items) < page_size: break offset += page_size return results @@ -229,21 +237,12 @@ class AlbyHubManager: 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 = { + "backendType": "LND", "unlockPassword": password, "lndAddress": self.lnd_address, "lndCertFile": self.lnd_cert_file, - "lndMacaroon": macaroon_hex, - "backendType": "LND", + "lndMacaroonFile": self.macaroon_file, } try: self._request("POST", "/api/setup", body=setup_body, timeout=30) @@ -252,26 +251,25 @@ class AlbyHubManager: return # already setup raise - def _hub_unlock(self, password: str) -> None: - try: - self._request( + def _obtain_token(self, password: str) -> str: + info = self._request("GET", "/api/info", timeout=10) + if info.get("running"): + resp = self._request( "POST", "/api/unlock", + body={ + "unlockPassword": password, + "permission": "full", + }, + timeout=30, + ) + else: + resp = self._request( + "POST", + "/api/start", 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") @@ -309,7 +307,6 @@ class AlbyHubManager: 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 @@ -332,7 +329,11 @@ class AlbyHubManager: 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 + alias = str(meta.get("lnurl_alias", "")).strip().lower() + return ( + meta.get(_MANAGED_META_KEY) == _MANAGED_APP_STORE_ID + and bool(alias) + ) def _app_to_wallet_meta(self, app: dict, domain: str | None) -> dict: meta = self._parse_metadata(app.get("metadata")) @@ -344,15 +345,9 @@ class AlbyHubManager: "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 + balance_msat = int(app.get("balanceMsat", 0) or 0) + balance_sats = balance_msat // 1000 + dust_msat = balance_msat % 1000 spending_limit_sats: int | None = None max_amount = app.get("maxAmountSat") or 0 @@ -360,21 +355,18 @@ class AlbyHubManager: 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] - ) + pending_txs = int(app.get("pendingTransactionsCount", 0) or 0) return { "id": str(app.get("id", "")), - "pubkey": app.get("nostrPubkey") or app.get("pubkey") or "", + "pubkey": app.get("appPubkey") or 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, + "dust_msat": dust_msat, "pending_transactions": pending_txs, "created_at": app.get("createdAt") or app.get("created_at"), "min_sendable_msat": int( @@ -386,7 +378,7 @@ class AlbyHubManager: } def _all_managed_apps(self) -> list[dict]: - apps = self._paginate("/api/apps?limit={limit}&offset={offset}") + apps = self._paginate("/api/apps?limit={limit}&offset={offset}&order_by=created_at") 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: @@ -395,7 +387,7 @@ class AlbyHubManager: if str(app.get("id", "")).lower() == needle: return app pubkey = ( - app.get("nostrPubkey") or app.get("pubkey") or "" + app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "" ).lower() if pubkey == needle: return app @@ -405,10 +397,12 @@ class AlbyHubManager: 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() - ] + wallets = [] + for app in self._all_managed_apps(): + app_copy = dict(app) + app_copy["pendingTransactionsCount"] = len(self._get_app_pending_txs(int(app["id"]))) + wallets.append(self._app_to_wallet_meta(app_copy, domain)) + return wallets def create_wallet( self, @@ -474,7 +468,7 @@ class AlbyHubManager: if app_id is not None: try: app_detail = self._authenticated_request( - "GET", f"/api/apps/{app_id}" + "GET", f"/api/v2/apps/{app_id}" ) except AlbyHubError: pass @@ -503,16 +497,17 @@ class AlbyHubManager: "/api/transfers", body={ "toAppId": int(app_id), - "amountMsat": spending_limit_sats * 1000, + "amountSat": spending_limit_sats, + "description": f"Initial funding for {name}", }, ) 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 " + "The wallet was created successfully and the NWC connection secret is shown " "above, but initial funding failed. Save the NWC secret now. " - "Do not create another wallet." + "Do not recreate this wallet." ) return { @@ -527,37 +522,15 @@ class AlbyHubManager: } def _get_app_balance_msat(self, app: dict) -> int: - budget = app.get("budget") or {} - return int(budget.get("usedBudget", 0) or 0) + return int(app.get("balanceMsat", 0) or 0) def _get_app_pending_txs(self, app_id: int) -> list[dict]: - """Return pending transactions for the app. - - Paginates only as far as needed: stops after finding the first - pending transaction since the caller rejects *any* pending tx. - """ - path_tmpl = f"/api/apps/{app_id}/transactions?limit={{limit}}&offset={{offset}}" - page_size = 100 - token = self.ensure_ready() - offset = 0 - pending: list[dict] = [] - while True: - path = path_tmpl.format(limit=page_size, offset=offset) - page = self._request("GET", path, token=token) - if isinstance(page, list): - items = page - elif isinstance(page, dict): - items = page.get("transactions") or [] - else: - items = [] - for t in items: - if t.get("state", "").lower() == "pending": - pending.append(t) - return pending # early exit: one is enough to block - if len(items) < page_size: - break - offset += page_size - return pending + txs = self._paginate( + f"/api/transactions?appId={app_id}&limit={{limit}}&offset={{offset}}" + ) + return [ + t for t in txs if str(t.get("state", "")).lower() == "pending" + ] def drain_wallet(self, identifier: str) -> dict: """Drain all whole-satoshi funds from an isolated app to the primary wallet. @@ -582,11 +555,11 @@ class AlbyHubManager: "Wallet has pending transactions and cannot be drained.", ) - whole_sats = balance_msat // 1000 - dust_msat = balance_msat % 1000 + transferable_msat = (balance_msat // 1000) * 1000 + expected_dust_msat = balance_msat - transferable_msat - if whole_sats == 0: - return {"ok": True, "drained_sats": 0, "dust_msat": dust_msat} + if transferable_msat == 0: + return {"ok": True, "drained_sats": 0, "dust_msat": expected_dust_msat} # Save original permissions original_scopes = list(app.get("scopes") or []) @@ -594,12 +567,19 @@ class AlbyHubManager: original_renewal = app.get("budgetRenewal") or "never" # Temporarily grant pay_invoice scope with sufficient budget + app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "" + if not app_pubkey: + raise AlbyHubError( + "app_pubkey_missing", + "Cannot drain app: app public key not available.", + ) + patch_body = { "scopes": sorted(set(original_scopes) | {"pay_invoice"}), - "maxAmountSat": whole_sats, + "maxAmountSat": 0, "budgetRenewal": "never", } - self._authenticated_request("PATCH", f"/api/apps/{app_id}", body=patch_body) + self._authenticated_request("PATCH", f"/api/apps/{app_pubkey}", body=patch_body) drain_error: AlbyHubError | None = None drained_sats = 0 @@ -607,9 +587,13 @@ class AlbyHubManager: self._authenticated_request( "POST", "/api/transfers", - body={"fromAppId": app_id, "amountMsat": whole_sats * 1000}, + body={ + "fromAppId": app_id, + "amountMsat": transferable_msat, + "description": f"Drain isolated subwallet {app.get('name', '')}", + }, ) - drained_sats = whole_sats + drained_sats = transferable_msat // 1000 except AlbyHubError as exc: drain_error = exc finally: @@ -621,7 +605,7 @@ class AlbyHubManager: } try: self._authenticated_request( - "PATCH", f"/api/apps/{app_id}", body=restore_body + "PATCH", f"/api/apps/{app_pubkey}", body=restore_body ) except AlbyHubError: pass # best-effort restore; don't mask the original error @@ -630,13 +614,18 @@ class AlbyHubManager: raise drain_error # Verify remaining balance equals expected dust - refreshed = self._authenticated_request("GET", f"/api/apps/{app_id}") + refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}") remaining_msat = self._get_app_balance_msat(refreshed) + if remaining_msat != expected_dust_msat: + raise AlbyHubError( + "drain_incomplete", + "Drain verification failed: final balance does not match expected dust.", + ) return { "ok": True, "drained_sats": drained_sats, - "dust_msat": dust_msat, + "dust_msat": expected_dust_msat, "remaining_msat": remaining_msat, } @@ -661,16 +650,21 @@ class AlbyHubManager: drain_result = self.drain_wallet(identifier) # Verify no transferable balance remains - refreshed = self._authenticated_request("GET", f"/api/apps/{app_id}") + refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}") remaining_msat = self._get_app_balance_msat(refreshed) + if remaining_msat < 0: + raise AlbyHubError( + "negative_balance", + "Wallet has a negative final balance and cannot be deleted.", + ) 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 "" + # Delete by app pubkey + pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "" if not pubkey: raise AlbyHubError( "app_pubkey_missing", @@ -681,7 +675,11 @@ class AlbyHubManager: f"/api/apps/{urllib.parse.quote(pubkey, safe='')}", ) - return {"ok": True, "drained_sats": drain_result.get("drained_sats", 0)} + return { + "ok": True, + "drained_sats": drain_result.get("drained_sats", 0), + "dust_msat": remaining_msat, + } def issue_invoice( self, app_id: int, amount_msat: int, description: str = "" @@ -700,24 +698,19 @@ class AlbyHubManager: "appId": app_id, }, ) - invoice: str = ( - resp.get("paymentRequest") - or resp.get("pr") - or resp.get("invoice") - or "" - ) + invoice: str = 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(bc|tb|bcrt|tbs)[0-9]", invoice, re.IGNORECASE): + if not re.match(r"^ln", 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: + if returned_app_id is None or int(returned_app_id) != app_id: raise AlbyHubError( "invoice_attribution_failed", "Invoice attribution mismatch: returned appId does not match.", diff --git a/app/sovran_systemsos_web/nwc_lnurl_service.py b/app/sovran_systemsos_web/nwc_lnurl_service.py index ab78a74..f6d358d 100644 --- a/app/sovran_systemsos_web/nwc_lnurl_service.py +++ b/app/sovran_systemsos_web/nwc_lnurl_service.py @@ -186,8 +186,20 @@ def _make_handler(manager: "AlbyHubManager") -> type: 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 + amount_values = qs.get("amount") + if not amount_values: + amount_str = None + elif len(amount_values) != 1: + self._send_json( + 400, + { + "status": "ERROR", + "reason": "A single amount parameter is required", + }, + ) + return + else: + amount_str = amount_values[0] payload, code = _lnurl_callback(alias, amount_str, self._manager) self._send_json(code, payload) return diff --git a/app/tests/test_wallet_connections.py b/app/tests/test_wallet_connections.py index 3c234c1..676d2d5 100644 --- a/app/tests/test_wallet_connections.py +++ b/app/tests/test_wallet_connections.py @@ -26,6 +26,8 @@ Tests cover: import json import re +import shutil +import subprocess import sys import tempfile import types @@ -139,12 +141,13 @@ def _make_app( return { "id": id_, "name": name, + "appPubkey": pubkey, "nostrPubkey": pubkey, "scopes": scopes, "isolated": True, "maxAmountSat": max_amount, "budgetRenewal": "never", - "budget": {"usedBudget": balance_msat, "remainingBudget": 0}, + "balanceMsat": balance_msat, "pendingTransactions": pending or [], "metadata": { "app_store_app_id": "uncle-jim", @@ -185,6 +188,13 @@ class FeatureRegistryTests(unittest.TestCase): self.assertIn(("80", "TCP"), ports) self.assertIn(("443", "TCP"), ports) + def test_wallet_connections_tile_icon_is_nwc(self): + repo_root = Path(__file__).resolve().parents[2] + hub_module = repo_root / "modules" / "core" / "sovran-hub.nix" + text = hub_module.read_text() + self.assertIn('{ name = "Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "nwc";', text) + self.assertIn('{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus";', text) + def test_service_map_points_to_albyhub(self): self.assertEqual(server.FEATURE_SERVICE_MAP["nwc-wallets"], "albyhub.service") @@ -248,7 +258,6 @@ class ManagerEnsureReadyTests(unittest.TestCase): 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") @@ -260,7 +269,6 @@ class ManagerEnsureReadyTests(unittest.TestCase): 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() @@ -268,7 +276,6 @@ class ManagerEnsureReadyTests(unittest.TestCase): 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() @@ -292,7 +299,6 @@ class ManagerEnsureReadyTests(unittest.TestCase): 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() @@ -310,12 +316,65 @@ class ManagerEnsureReadyTests(unittest.TestCase): result = m._authenticated_request("GET", "/api/apps") self.assertEqual(result, {}) + def test_obtain_token_uses_start_when_not_running(self): + m = _fresh_manager() + + def _request(method, path, **_kw): + if method == "GET" and path == "/api/info": + return {"running": False} + if method == "POST" and path == "/api/start": + return {"token": "start-token"} + self.fail(f"unexpected call: {method} {path}") + + m._request = MagicMock(side_effect=_request) + token = m._obtain_token("pw") + self.assertEqual(token, "start-token") + + def test_obtain_token_uses_unlock_when_running(self): + m = _fresh_manager() + + calls = [] + + def _request(method, path, **kw): + calls.append((method, path, kw.get("body"))) + if method == "GET" and path == "/api/info": + return {"running": True} + if method == "POST" and path == "/api/unlock": + return {"token": "unlock-token"} + self.fail(f"unexpected call: {method} {path}") + + m._request = MagicMock(side_effect=_request) + token = m._obtain_token("pw") + self.assertEqual(token, "unlock-token") + unlock_call = [c for c in calls if c[0] == "POST" and c[1] == "/api/unlock"][0] + self.assertEqual(unlock_call[2]["permission"], "full") + + def test_hub_setup_uses_lnd_macaroon_file(self): + m = _fresh_manager() + calls = [] + + def _request(method, path, **kw): + calls.append((method, path, kw.get("body"))) + if method == "GET" and path == "/api/info": + return {"setupCompleted": False} + if method == "POST" and path == "/api/setup": + return {} + self.fail(f"unexpected call: {method} {path}") + + m._request = MagicMock(side_effect=_request) + m._hub_setup("pw") + setup_call = [c for c in calls if c[0] == "POST" and c[1] == "/api/setup"][0] + body = setup_call[2] + self.assertEqual(body["backendType"], "LND") + self.assertEqual(body["lndMacaroonFile"], m.macaroon_file) + self.assertNotIn("lndMacaroon", body) + class ManagerPaginationTests(unittest.TestCase): - def test_paginate_collects_all_pages(self): + def test_paginate_uses_total_count(self): m = _fresh_manager() - page1 = [{"id": i} for i in range(100)] - page2 = [{"id": i} for i in range(100, 150)] + page1 = {"apps": [{"id": i} for i in range(3)], "totalCount": 5} + page2 = {"apps": [{"id": 3}, {"id": 4}], "totalCount": 5} def _request(method, path, **_kw): if "offset=0" in path: @@ -324,8 +383,8 @@ class ManagerPaginationTests(unittest.TestCase): m._token = "tok" m._request = MagicMock(side_effect=_request) - result = m._paginate("/api/apps?limit={limit}&offset={offset}") - self.assertEqual(len(result), 150) + result = m._paginate("/api/apps?limit={limit}&offset={offset}", page_size=3) + self.assertEqual(len(result), 5) def test_paginate_single_page_stops(self): m = _fresh_manager() @@ -385,6 +444,14 @@ class ManagerListTests(unittest.TestCase): result = m.list_wallets(domain="pay.example.com") self.assertEqual(result[0]["lightning_address"], "bob@pay.example.com") + def test_list_uses_app_pubkey_and_balance_msat(self): + apps = [_make_app(pubkey="pubkey-1", balance_msat=12345)] + m = self._mgr_with_token(apps) + wallets = m.list_wallets() + self.assertEqual(wallets[0]["pubkey"], "pubkey-1") + self.assertEqual(wallets[0]["balance_sats"], 12) + self.assertEqual(wallets[0]["dust_msat"], 345) + class ManagerCreateTests(unittest.TestCase): def _mgr(self, existing_apps=None, create_resp=None): @@ -407,7 +474,7 @@ class ManagerCreateTests(unittest.TestCase): return existing_apps if method == "POST" and path == "/api/apps": return create_resp - if method == "GET" and path.startswith("/api/apps/99"): + if method == "GET" and path.startswith("/api/v2/apps/99"): return _make_app(id_=99, alias="new") return {} @@ -425,6 +492,15 @@ class ManagerCreateTests(unittest.TestCase): self.assertIn("pairing_uri", result) self.assertTrue(result["pairing_uri"].startswith("nostr+walletconnect://")) + def test_create_uses_ordered_apps_list_and_v2_app_lookup(self): + m = self._mgr() + m.create_wallet("New Wallet", "new", "receive_only", None) + paths = [c.args[1] for c in m._request.call_args_list if len(c.args) > 1] + self.assertTrue( + any("/api/apps?limit=100&offset=0&order_by=created_at" in p for p in paths) + ) + self.assertTrue(any(p.startswith("/api/v2/apps/99") for p in paths)) + def test_create_sends_isolated_true(self): m = self._mgr() m.create_wallet("W", "w", "receive_only", None) @@ -499,7 +575,8 @@ class ManagerCreateTests(unittest.TestCase): 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) + self.assertEqual(transfers[0]["amountSat"], 5000) + self.assertEqual(transfers[0]["description"], "Initial funding for W") def test_create_partial_failure_funding_returns_pairing_uri(self): """Even when initial funding fails, the real pairing URI must be returned.""" @@ -516,7 +593,7 @@ class ManagerCreateTests(unittest.TestCase): } if method == "POST" and path == "/api/transfers": raise mgr.AlbyHubError("transfer_failed", "Insufficient funds") - if method == "GET" and "/api/apps/99" in path: + if method == "GET" and "/api/v2/apps/99" in path: return _make_app(id_=99, alias="new") return {} @@ -529,6 +606,33 @@ class ManagerCreateTests(unittest.TestCase): self.assertFalse(result["result"]["funding"]["success"]) self.assertIn("message", result["result"]["funding"]) + def test_create_partial_failure_message_says_created_successfully(self): + """Partial-funding message must say 'was created successfully', not 'already exists'.""" + 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/v2/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) + + message = result["result"]["funding"]["message"] + self.assertIn("was created successfully", message) + self.assertNotIn("already exists", message) + self.assertIn("Do not recreate", message) + class ManagerDrainTests(unittest.TestCase): def _mgr(self, app, transfer_ok=True): @@ -537,11 +641,11 @@ class ManagerDrainTests(unittest.TestCase): 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 [] + return {"apps": [app], "totalCount": 1} + if method == "GET" and path.startswith(f"/api/v2/apps/{app['id']}"): + return {**app, "balanceMsat": app.get("balanceMsat", 0) % 1000} + if method == "GET" and path.startswith("/api/transactions?"): + return {"transactions": [], "totalCount": 0} if method == "PATCH": return {} if method == "POST" and path == "/api/transfers": @@ -559,6 +663,13 @@ class ManagerDrainTests(unittest.TestCase): result = m.drain_wallet("1") self.assertTrue(result["ok"]) self.assertEqual(result["drained_sats"], 5000) + transfer_call = next( + c for c in m._request.call_args_list + if c.args[0] == "POST" and c.args[1] == "/api/transfers" + ) + body = transfer_call.kwargs["body"] + self.assertEqual(body["fromAppId"], 1) + self.assertEqual(body["amountMsat"], 5_000_000) def test_drain_preserves_dust(self): app = _make_app(balance_msat=5_000_500) @@ -571,11 +682,13 @@ class ManagerDrainTests(unittest.TestCase): app = _make_app(scopes=list(mgr.RECEIVE_ONLY_SCOPES), balance_msat=1_000_000) m = self._mgr(app) patches = [] + patch_paths = [] original = m._request.side_effect def _request(method, path, **kw): if method == "PATCH": + patch_paths.append(path) patches.append(kw.get("body")) return {} return original(method, path, **kw) @@ -587,6 +700,7 @@ class ManagerDrainTests(unittest.TestCase): second_patch = patches[1] # First patch must add pay_invoice self.assertIn("pay_invoice", first_patch.get("scopes", [])) + self.assertTrue(all("/api/apps/aabbcc" in p for p in patch_paths)) # Second patch (restore) must match original scopes self.assertEqual( sorted(second_patch.get("scopes", [])), @@ -600,9 +714,9 @@ class ManagerDrainTests(unittest.TestCase): def _request(method, path, **kw): if method == "GET" and path.startswith("/api/apps?"): - return [app] - if "transactions" in path: - return [{"state": "pending"}] + return {"apps": [app], "totalCount": 1} + if path.startswith("/api/transactions?"): + return {"transactions": [{"state": "pending"}], "totalCount": 1} return {} m._request = MagicMock(side_effect=_request) @@ -630,6 +744,29 @@ class ManagerDrainTests(unittest.TestCase): restore = patches[-1] self.assertEqual(sorted(restore.get("scopes", [])), sorted(mgr.RECEIVE_ONLY_SCOPES)) + def test_drain_fails_when_final_balance_not_expected_dust(self): + app = _make_app(balance_msat=2_000) + m = _fresh_manager() + m._token = "tok" + + def _request(method, path, **kw): + if method == "GET" and path.startswith("/api/apps?"): + return {"apps": [app], "totalCount": 1} + if method == "GET" and path.startswith("/api/transactions?"): + return {"transactions": [], "totalCount": 0} + if method == "PATCH": + return {} + if method == "POST" and path == "/api/transfers": + return {} + if method == "GET" and path.startswith("/api/v2/apps/1"): + return {**app, "balanceMsat": 999} + return {} + + m._request = MagicMock(side_effect=_request) + with self.assertRaises(mgr.AlbyHubError) as ctx: + m.drain_wallet("1") + self.assertEqual(ctx.exception.code, "drain_incomplete") + class ManagerDeleteTests(unittest.TestCase): def _mgr(self, app, drain_ok=True): @@ -639,13 +776,13 @@ class ManagerDeleteTests(unittest.TestCase): 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: + return {"apps": [app], "totalCount": 1} + if method == "GET" and path.startswith("/api/transactions?"): + return {"transactions": [], "totalCount": 0} + if method == "GET" and path.startswith(f"/api/v2/apps/{app['id']}"): # After drain the balance is zero a = dict(app) - a["budget"] = {"usedBudget": 0} + a["balanceMsat"] = 0 return a if method == "PATCH": return {} @@ -683,9 +820,9 @@ class ManagerDeleteTests(unittest.TestCase): def _request(method, path, **kw): if method == "GET" and path.startswith("/api/apps?"): - return [app] - if "transactions" in path: - return [{"state": "pending"}] + return {"apps": [app], "totalCount": 1} + if path.startswith("/api/transactions?"): + return {"transactions": [{"state": "pending"}], "totalCount": 1} return {} m._request = MagicMock(side_effect=_request) @@ -699,7 +836,7 @@ class ManagerInvoiceTests(unittest.TestCase): m = _fresh_manager() m._token = "tok" m._request = MagicMock( - return_value={"paymentRequest": invoice, "appId": app_id} + return_value={"invoice": invoice, "appId": app_id} ) return m @@ -712,7 +849,7 @@ class ManagerInvoiceTests(unittest.TestCase): 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} + return_value={"invoice": "not_a_bolt11", "appId": 1} ) with self.assertRaises(mgr.AlbyHubError) as ctx: m.issue_invoice(1, 5_000_000) @@ -722,18 +859,26 @@ class ManagerInvoiceTests(unittest.TestCase): m = _fresh_manager() m._token = "tok" m._request = MagicMock( - return_value={"paymentRequest": "lnbc1000n1test", "appId": 999} + return_value={"invoice": "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_requires_returned_appid(self): + m = _fresh_manager() + m._token = "tok" + m._request = MagicMock(return_value={"invoice": "lnbc1000n1test"}) + 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} + return_value={"invoice": "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") @@ -791,10 +936,10 @@ class LnurlCallbackTests(unittest.TestCase): def _request(method, path, **kw): if path.startswith("/api/apps"): - return [app] + return {"apps": [app], "totalCount": 1} if path == "/api/invoices": body = kw.get("body") or {} - return {"paymentRequest": invoice, "appId": body.get("appId")} + return {"invoice": invoice, "appId": body.get("appId")} return {} m._request = MagicMock(side_effect=_request) @@ -848,10 +993,10 @@ class LnurlCallbackTests(unittest.TestCase): def _request(method, path, **kw): if path.startswith("/api/apps"): - return [app] + return {"apps": [app], "totalCount": 1} if path == "/api/invoices": # Return wrong appId - return {"paymentRequest": "lnbc1000n1pfake", "appId": 999} + return {"invoice": "lnbc1000n1pfake", "appId": 999} return {} m._request = MagicMock(side_effect=_request) @@ -866,9 +1011,9 @@ class LnurlCallbackTests(unittest.TestCase): def _request(method, path, **kw): if path.startswith("/api/apps"): - return [app] + return {"apps": [app], "totalCount": 1} if path == "/api/invoices": - return {"paymentRequest": "not_a_bolt11_string", "appId": 1} + return {"invoice": "not_a_bolt11_string", "appId": 1} return {} m._request = MagicMock(side_effect=_request) @@ -877,6 +1022,207 @@ class LnurlCallbackTests(unittest.TestCase): self.assertEqual(code, 502) +# ── LNURL HTTP handler tests ────────────────────────────────────── + + +class LnurlHandlerAmountTests(unittest.TestCase): + """Tests the HTTP handler layer for amount parameter validation. + + Uses the handler's do_GET directly with _send_json patched on the instance + so no real socket is needed. + """ + + def _run_handler(self, path: str, manager=None) -> list[tuple[int, dict]]: + """Invoke do_GET for *path* and return all (code, body) pairs sent.""" + from sovran_systemsos_web.nwc_lnurl_service import _make_handler + + if manager is None: + manager = _fresh_manager() + handler_class = _make_handler(manager) + sent: list[tuple[int, dict]] = [] + handler = handler_class.__new__(handler_class) + handler._manager = manager + handler.path = path + # Intercept output without a real socket + handler._send_json = lambda code, body: sent.append((code, body)) + handler.do_GET() + return sent + + def test_duplicate_amount_returns_400_with_protocol_error(self): + """Two amount values must be rejected at the HTTP handler level.""" + sent = self._run_handler("/lnurlp/alice/callback?amount=1000&amount=500000") + self.assertEqual(len(sent), 1) + code, body = sent[0] + self.assertEqual(code, 400) + self.assertEqual(body["status"], "ERROR") + self.assertIn("single amount", body["reason"]) + + def test_three_amount_values_returns_400(self): + sent = self._run_handler("/lnurlp/alice/callback?amount=1000&amount=2000&amount=3000") + self.assertEqual(len(sent), 1) + code, body = sent[0] + self.assertEqual(code, 400) + self.assertEqual(body["status"], "ERROR") + + def test_single_amount_passes_to_callback(self): + """A single valid amount must reach the callback helper (not short-circuit).""" + app = _make_app(alias="alice") + m = _fresh_manager() + m._token = "tok" + + def _request(method, path, **kw): + if path.startswith("/api/apps"): + return {"apps": [app], "totalCount": 1} + if path == "/api/invoices": + body = kw.get("body") or {} + return {"invoice": "lnbc1000n1pfake", "appId": body.get("appId")} + return {} + + m._request = MagicMock(side_effect=_request) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + sent = self._run_handler("/lnurlp/alice/callback?amount=1000", manager=m) + self.assertEqual(len(sent), 1) + code, _ = sent[0] + self.assertEqual(code, 200) + + def test_missing_amount_returns_400_missing_reason(self): + """No amount parameter must produce a 'Missing amount' error.""" + app = _make_app(alias="alice") + m = _fresh_manager() + m._token = "tok" + m._request = MagicMock(side_effect=lambda method, path, **kw: ( + {"apps": [app], "totalCount": 1} if path.startswith("/api/apps") else {} + )) + with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"): + sent = self._run_handler("/lnurlp/alice/callback", manager=m) + self.assertEqual(len(sent), 1) + code, body = sent[0] + self.assertEqual(code, 400) + self.assertIn("Missing", body["reason"]) + + +# ── Nix/Patch contract tests ─────────────────────────────────────── + + +class NixPatchContractTests(unittest.TestCase): + @staticmethod + def _patched_albyhub_nix_expr(result_expr: str) -> str: + return ( + "let flake = builtins.getFlake (toString ./.); " + "pkgs = import flake.inputs.nixpkgs { system = builtins.currentSystem; }; " + "patchedAlbyHub = pkgs.albyhub.overrideAttrs (old: { patches = (old.patches or []) ++ [ " + "./packages/albyhub/0001-private-route-hints.patch " + "./packages/albyhub/0002-isolated-invoice-app-id.patch " + "]; }); " + f"in {result_expr}" + ) + + def test_nwc_module_uses_non_placeholder_albyhub_strategy(self): + repo_root = Path(__file__).resolve().parents[2] + module_path = repo_root / "modules" / "nwc-wallets.nix" + text = module_path.read_text() + self.assertIn("pkgs.albyhub.overrideAttrs", text) + self.assertIn("../packages/albyhub/0001-private-route-hints.patch", text) + self.assertIn("../packages/albyhub/0002-isolated-invoice-app-id.patch", text) + self.assertNotIn("sha256-AAAA", text) + self.assertNotIn("lib.fakeHash", text) + self.assertIn("AUTO_UNLOCK_PASSWORD", text) + self.assertNotIn("AUTO_UNLOCK_PASSWORD_FILE", text) + + def test_nwc_module_uses_lib_getexe_for_albyhub_binary(self): + repo_root = Path(__file__).resolve().parents[2] + module_path = repo_root / "modules" / "nwc-wallets.nix" + text = module_path.read_text() + self.assertIn("exec ${lib.getExe patchedAlbyHub}", text) + self.assertNotIn("${patchedAlbyHub}/bin/hub", text) + + def test_official_nwc_icon_asset_is_committed(self): + repo_root = Path(__file__).resolve().parents[2] + icon_path = repo_root / "app" / "icons" / "nwc.svg" + self.assertTrue(icon_path.exists()) + text = icon_path.read_text() + self.assertIn("linearGradient", text) + self.assertIn("#F7931A", text) + + def test_albyhub_main_program_via_nix_eval(self): + if shutil.which("nix") is None: + self.skipTest("nix not installed in this environment") + repo_root = Path(__file__).resolve().parents[2] + get_exe_expr = self._patched_albyhub_nix_expr("pkgs.lib.getExe patchedAlbyHub") + get_exe_result = subprocess.run( + [ + "nix", + "eval", + "--raw", + "--impure", + "--expr", + get_exe_expr, + ], + check=True, + capture_output=True, + text=True, + cwd=repo_root, + ) + exe_path = get_exe_result.stdout.strip() + self.assertIn("/nix/store/", exe_path) + self.assertTrue(exe_path.endswith("/bin/albyhub")) + self.assertNotIn("/bin/hub", exe_path) + + main_program_result = subprocess.run( + [ + "nix", + "eval", + "--raw", + "--impure", + "--expr", + self._patched_albyhub_nix_expr("patchedAlbyHub.meta.mainProgram"), + ], + check=True, + capture_output=True, + text=True, + cwd=repo_root, + ) + self.assertEqual(main_program_result.stdout.strip(), "albyhub") + + def test_private_route_hint_patch_exact_change(self): + repo_root = Path(__file__).resolve().parents[2] + patch_path = repo_root / "packages" / "albyhub" / "0001-private-route-hints.patch" + text = patch_path.read_text() + self.assertIn("Private: !hasPublicChannels", text) + self.assertIn("Private: true", text) + + def test_isolated_invoice_appid_patch_contains_all_required_files(self): + repo_root = Path(__file__).resolve().parents[2] + patch_path = repo_root / "packages" / "albyhub" / "0002-isolated-invoice-app-id.patch" + text = patch_path.read_text() + self.assertIn("diff --git a/api/models.go b/api/models.go", text) + self.assertIn("diff --git a/api/transactions.go b/api/transactions.go", text) + self.assertIn("diff --git a/http/http_service.go b/http/http_service.go", text) + self.assertIn("diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go", text) + self.assertIn("AppId *uint `json:\"appId\"`", text) + self.assertIn("CreateInvoice(ctx context.Context, amount uint64, description string, appId *uint)", text) + + def test_nwc_lnurl_service_runs_as_albyhub(self): + """nwc-lnurl.service must run as albyhub to read /var/lib/albyhub/unlock-password.""" + repo_root = Path(__file__).resolve().parents[2] + module_path = repo_root / "modules" / "nwc-wallets.nix" + text = module_path.read_text() + # Locate the nwc-lnurl service block + service_idx = text.find("systemd.services.nwc-lnurl") + self.assertNotEqual(service_idx, -1, "nwc-lnurl service declaration not found") + service_section = text[service_idx:] + self.assertIn('User = "albyhub"', service_section) + self.assertIn('Group = "albyhub"', service_section) + + def test_nwc_module_no_separate_nwc_lnurl_user(self): + """No standalone nwc-lnurl user or group should exist; albyhub identity is reused.""" + repo_root = Path(__file__).resolve().parents[2] + module_path = repo_root / "modules" / "nwc-wallets.nix" + text = module_path.read_text() + self.assertNotIn("users.users.nwc-lnurl", text) + self.assertNotIn("users.groups.nwc-lnurl", text) + + # ── Server API integration tests ───────────────────────────────── diff --git a/docs/wallet-connections.md b/docs/wallet-connections.md index 73896fd..2b02bce 100644 --- a/docs/wallet-connections.md +++ b/docs/wallet-connections.md @@ -66,23 +66,34 @@ Security invariants: - 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 package and patches -Alby Hub is packaged in `modules/nwc-wallets.nix` with the following patches: +Wallet Connections uses `pkgs.albyhub` from the repository's pinned `nixpkgs` input and applies two conventional patches via `overrideAttrs`: -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. +1. **Private route hints** (`packages/albyhub/0001-private-route-hints.patch`): changes regular LND invoice creation from `Private: !hasPublicChannels` to `Private: true` and leaves hold-invoice logic unchanged. +2. **Invoice app attribution** (`packages/albyhub/0002-isolated-invoice-app-id.patch`): updates `api/models.go`, `api/transactions.go`, `http/http_service.go`, and `wails/wails_handlers.go` so invoice creation accepts and forwards optional `appId`. -The `vendorHash` and `sha256` fields in the derivation must be updated whenever the Alby Hub version changes. +No placeholder source/vendor hashes are used in the Wallet Connections module. ## Services | Service | User | Description | |---|---|---| | `albyhub.service` | `albyhub` | Headless Alby Hub NWC wallet server | -| `nwc-lnurl.service` | `nwc-lnurl` | Dedicated LNURL discovery and callback service | +| `nwc-lnurl.service` | `albyhub` | Dedicated LNURL discovery and callback service | | `albyhub-init.service` | `root` (oneshot) | Generates `unlock-password` once on first boot | +`nwc-lnurl.service` runs as `albyhub` so it can traverse `/var/lib/albyhub` (0700) and read `/var/lib/albyhub/unlock-password` (0600) without weakening permissions. + +## Wallet Connections icon asset + +- Hub service icon identifier: `nwc` (feature name remains **Wallet Connections**) +- Asset path in this repository: `app/icons/nwc.svg` +- Official source: `https://raw.githubusercontent.com/getAlby/nostr-wallet-connect/5fb6831739c7e6b089cd7205e11910ef542432ad/public/images/nwc-logo.svg` +- Upstream repository: `https://github.com/getAlby/nostr-wallet-connect` +- Upstream license: Apache-2.0 (`https://github.com/getAlby/nostr-wallet-connect/blob/5fb6831739c7e6b089cd7205e11910ef542432ad/LICENSE`) +- Attribution/redistribution note: Apache-2.0 permits redistribution; preserve upstream license notices in distributions. + ## API Management (authenticated): @@ -93,6 +104,26 @@ Management (authenticated): - `POST /api/nwc/wallets/{id-or-pubkey}/drain` — transfer funds to primary wallet - `POST /api/nwc/addresses/{alias}/test` — verify public LNURL endpoint +Exact Hub setup/auth flow used by the manager: + +- `GET /api/info` +- If `setupCompleted == false`: `POST /api/setup` with `backendType`, `unlockPassword`, `lndAddress`, `lndCertFile`, `lndMacaroonFile` +- `GET /api/info` again +- If `running == false`: `POST /api/start` with `unlockPassword` +- If `running == true`: `POST /api/unlock` with `unlockPassword` and `permission: "full"` +- Poll authenticated `GET /api/node/status` until `isReady == true` +- Cached bearer token is refreshed once on 401/403 + +Exact app/transaction usage: + +- `GET /api/apps?limit=&offset=&order_by=created_at` (full pagination using `totalCount`) +- `GET /api/v2/apps/{id}` for app-by-id fetches +- `GET /api/transactions?appId={id}&limit=&offset=` (full pagination using `totalCount`) +- Wallet metadata uses `appPubkey` and `balanceMsat` (`balance_sats = balanceMsat / 1000`, `dust_msat = balanceMsat % 1000`) +- Limited-wallet initial funding uses `POST /api/transfers` with `toAppId`, `amountSat`, `description` +- Drain uses `PATCH /api/apps/{appPubkey}`, transfer with `fromAppId` + `amountMsat`, and enforces final dust equality +- Delete uses `DELETE /api/apps/{appPubkey}` after pending/drain checks + Public LNURL (served by dedicated `nwc-lnurl` service via Caddy): - `GET /.well-known/lnurlp/{alias}` — LNURL-pay discovery diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index ba5a1b7..08b796b 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -61,7 +61,7 @@ let { label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; } { label = "Status"; value = "Auto-configured on first boot"; } ]; } - { name = "Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "zeus"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [ + { name = "Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "nwc"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [ { label = "Lightning Address Domain"; file = "/var/lib/domains/lightning"; } ]; } { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [ diff --git a/modules/nwc-wallets.nix b/modules/nwc-wallets.nix index 2cee96e..4c01c58 100644 --- a/modules/nwc-wallets.nix +++ b/modules/nwc-wallets.nix @@ -1,223 +1,146 @@ { config, pkgs, lib, ... }: -# ── Alby Hub version pin ─────────────────────────────────────────────────────── -# Pinned to getalby/hub release v1.14.2 (2024-11-15). -# Update `rev` and `sha256` together when upgrading. The patch application step -# will fail clearly on upstream drift so that stale patches are not silently -# skipped. let - albyhubVersion = "1.14.2"; - albyhubSrc = pkgs.fetchFromGitHub { - owner = "getAlby"; - repo = "hub"; - rev = "v${albyhubVersion}"; - sha256 = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; - }; + patchedAlbyHub = pkgs.albyhub.overrideAttrs (old: { + patches = (old.patches or []) ++ [ + ../packages/albyhub/0001-private-route-hints.patch + ../packages/albyhub/0002-isolated-invoice-app-id.patch + ]; + }); - # Patch 1 — private route hints for regular invoices. - # Sets the Private field to true in MakeInvoice so that wallets behind - # private channels can receive payments via route hints. - # Context lines must match getalby/hub v1.14.2 exactly; patch fails on drift. - patchPrivateRouteHints = pkgs.writeText "0001-lnd-private-route-hints.patch" '' - --- a/lnclient/lnd/lnd.go - +++ b/lnclient/lnd/lnd.go - @@ -1,5 +1,6 @@ - invoice := &lnrpc.Invoice{ - Memo: description, - Value: amountSat, - + Private: true, - Expiry: expiry, - } + lndRpcAddress = lib.attrByPath [ "services" "lnd" "rpcAddress" ] "127.0.0.1" config; + lndRpcPort = toString (lib.attrByPath [ "services" "lnd" "rpcPort" ] 10009 config); + lndCertPath = lib.attrByPath [ "services" "lnd" "certPath" ] "/var/lib/lnd/tls.cert" config; + + albyhubWrapper = pkgs.writeShellScript "albyhub-wrapper" '' + set -euo pipefail + password_file="/var/lib/albyhub/unlock-password" + if [ ! -s "$password_file" ]; then + umask 077 + ${pkgs.openssl}/bin/openssl rand -hex 32 > "$password_file" + fi + export AUTO_UNLOCK_PASSWORD="$(cat "$password_file")" + exec ${lib.getExe patchedAlbyHub} ''; - - # Patch 2 — optional isolated app attribution for invoice creation. - # Extends CreateInvoice / MakeInvoiceRequest / http_service / wails_handlers - # to accept and pass an optional appId so that LNURL callbacks can attribute - # invoices to a specific isolated app subwallet. - # Context lines must match getalby/hub v1.14.2 exactly; patch fails on drift. - patchAppIdAttribution = pkgs.writeText "0002-invoice-app-attribution.patch" '' - --- a/api/models.go - +++ b/api/models.go - @@ -1,5 +1,6 @@ - type MakeInvoiceRequest struct { - Amount int64 `json:"amount"` - Description string `json:"description"` - DescriptionHash string `json:"descriptionHash"` - Expiry *int64 `json:"expiry"` - + AppId *uint `json:"appId"` - } - ''; - - albyhub = pkgs.buildGoModule { - pname = "albyhub"; - version = albyhubVersion; - src = albyhubSrc; - - # go.sum-derived vendor hash — regenerate after any Go dependency change - vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; - - patches = [ patchPrivateRouteHints patchAppIdAttribution ]; - - # Run gofmt on modified Go sources after patching - postPatch = '' - gofmt -w lnclient/lnd/lnd.go api/models.go api/transactions.go \ - http/http_service.go wails/wails_handlers.go - ''; - - meta = { - description = "Alby Hub — self-hosted NWC wallet server (Sovran_SystemsOS build)"; - license = lib.licenses.gpl3; - mainProgram = "hub"; - }; - }; - 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."; + } + { + assertion = !(lib.attrByPath [ "nix-bitcoin" "netns-isolation" "enable" ] false config); + message = "Wallet Connections requires nix-bitcoin.netns-isolation.enable = false."; } ]; - # ── Users and groups ───────────────────────────────────────────── - users.groups.albyhub = {}; + users.groups.albyhub = { }; users.users.albyhub = { isSystemUser = true; - group = "albyhub"; - home = "/var/lib/albyhub"; - createHome = false; - extraGroups = []; + 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/albyhub 0700 albyhub albyhub -" - "d /var/lib/nwc-lnurl 0750 nwc-lnurl nwc-lnurl -" ]; - # ── 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"} - ''; + permissions = lib.concatStringsSep "," [ + ''{"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"; - RemainAfterExit = true; - 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" ]; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "lnd.service" ]; + requires = [ "lnd.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"; + HOME = "/var/lib/albyhub"; + HOST = "127.0.0.1"; + LN_BACKEND_TYPE = "LND"; + ENABLE_ADVANCED_SETUP = "false"; + LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}"; + LND_CERT_FILE = lndCertPath; + LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon"; + WORK_DIR = "/var/lib/albyhub"; + DATABASE_URI = "/var/lib/albyhub/nwc.db"; + PORT = "8080"; + RELAY = "wss://relay.getalby.com,wss://relay2.getalby.com"; + AUTO_LINK_ALBY_ACCOUNT = "false"; + SEND_EVENTS_TO_ALBY = "false"; + LOG_TO_FILE = "false"; + HIDE_UPDATE_BANNER = "true"; }; serviceConfig = { - Type = "simple"; - User = "albyhub"; - Group = "albyhub"; + Type = "simple"; + User = "albyhub"; + Group = "albyhub"; WorkingDirectory = "/var/lib/albyhub"; - ExecStart = "${albyhub}/bin/hub"; - Restart = "on-failure"; - RestartSec = "10s"; - UMask = "0027"; + ExecStart = albyhubWrapper; + Restart = "on-failure"; + RestartSec = "10s"; + UMask = "0077"; NoNewPrivileges = true; - PrivateTmp = true; - ProtectHome = true; - ProtectSystem = "strict"; - ReadWritePaths = [ "/var/lib/albyhub" ]; - ReadOnlyPaths = [ - config.services.lnd.certFile or "/var/lib/lnd/tls.cert" - "/run/lnd" - ]; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + ReadWritePaths = [ "/var/lib/albyhub" ]; + ReadOnlyPaths = [ lndCertPath "/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" ]; + wantedBy = [ "multi-user.target" ]; + after = [ "albyhub.service" "sovran-hub-web.service" ]; + wants = [ "albyhub.service" ]; serviceConfig = { - Type = "simple"; - User = "nwc-lnurl"; - Group = "nwc-lnurl"; + Type = "simple"; + User = "albyhub"; + Group = "albyhub"; ExecStart = "${config.services.sovranHub.webPackage}/bin/nwc-lnurl"; - Restart = "on-failure"; - RestartSec = "10s"; - UMask = "0027"; + Restart = "on-failure"; + RestartSec = "10s"; + UMask = "0027"; NoNewPrivileges = true; - PrivateTmp = true; - ProtectHome = true; - ProtectSystem = "strict"; - ReadOnlyPaths = [ + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + ReadOnlyPaths = [ "/var/lib/domains/lightning" "/var/lib/albyhub/unlock-password" ]; }; }; - # ── Domain requirement ─────────────────────────────────────────── + systemd.services.sovran-hub-web.environment = { + NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}"; + NWC_LND_CERT_FILE = lndCertPath; + NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon"; + }; + 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; } ]; diff --git a/packages/albyhub/0001-private-route-hints.patch b/packages/albyhub/0001-private-route-hints.patch new file mode 100644 index 0000000..a2cf97d --- /dev/null +++ b/packages/albyhub/0001-private-route-hints.patch @@ -0,0 +1,14 @@ +diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go +index 35c2e40..f6f4996 100644 +--- a/lnclient/lnd/lnd.go ++++ b/lnclient/lnd/lnd.go +@@ -373,7 +373,7 @@ func (svc *LNDService) MakeInvoice(ctx context.Context, amount int64, descripti + ValueMsat: amount, + Memo: description, + DescriptionHash: descriptionHashBytes, + Expiry: expiry, +- Private: !hasPublicChannels, // use private channel hints in the invoice ++ Private: true, // always include private channel hints in the invoice + } + + resp, err := svc.client.AddInvoice(ctx, addInvoiceRequest) diff --git a/packages/albyhub/0002-isolated-invoice-app-id.patch b/packages/albyhub/0002-isolated-invoice-app-id.patch new file mode 100644 index 0000000..1b0300e --- /dev/null +++ b/packages/albyhub/0002-isolated-invoice-app-id.patch @@ -0,0 +1,74 @@ +diff --git a/api/models.go b/api/models.go +index 27bc0c6..3986c0f 100644 +--- a/api/models.go ++++ b/api/models.go +@@ -43,7 +43,7 @@ type API interface { + GetBalances(ctx context.Context) (*BalancesResponse, error) + ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error) + SendPayment(ctx context.Context, invoice string, amountMsat *uint64) (*SendPaymentResponse, error) +- CreateInvoice(ctx context.Context, amount uint64, description string) (*MakeInvoiceResponse, error) ++ CreateInvoice(ctx context.Context, amount uint64, description string, appId *uint) (*MakeInvoiceResponse, error) + LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error) + RequestMempoolApi(endpoint string) (interface{}, error) + GetInfo(ctx context.Context) (*InfoResponse, error) +@@ -308,8 +308,9 @@ type PayInvoiceRequest struct { + } + + type MakeInvoiceRequest struct { +- Amount uint64 `json:"amount"` +- Description string `json:"description"` ++ Amount uint64 `json:"amount"` ++ Description string `json:"description"` ++ AppId *uint `json:"appId"` + } + + type ResetRouterRequest struct { +diff --git a/api/transactions.go b/api/transactions.go +index 8a10267..ce8e080 100644 +--- a/api/transactions.go ++++ b/api/transactions.go +@@ -13,11 +13,11 @@ import ( + "github.com/sirupsen/logrus" + ) + +-func (api *api) CreateInvoice(ctx context.Context, amount uint64, description string) (*MakeInvoiceResponse, error) { ++func (api *api) CreateInvoice(ctx context.Context, amount uint64, description string, appId *uint) (*MakeInvoiceResponse, error) { + if api.svc.GetLNClient() == nil { + return nil, errors.New("LNClient not started") + } +- transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amount, description, "", 0, nil, api.svc.GetLNClient(), nil, nil) ++ transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amount, description, "", 0, nil, api.svc.GetLNClient(), appId, nil) + if err != nil { + return nil, err + } +diff --git a/http/http_service.go b/http/http_service.go +index 1ec1de6..84a2650 100644 +--- a/http/http_service.go ++++ b/http/http_service.go +@@ -518,7 +518,12 @@ func (httpSvc *HttpService) makeInvoiceHandler(c echo.Context) error { + }) + } + +- invoice, err := httpSvc.api.CreateInvoice(c.Request().Context(), makeInvoiceRequest.Amount, makeInvoiceRequest.Description) ++ invoice, err := httpSvc.api.CreateInvoice( ++ c.Request().Context(), ++ makeInvoiceRequest.Amount, ++ makeInvoiceRequest.Description, ++ makeInvoiceRequest.AppId, ++ ) + + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ +diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go +index 1ca87f2..8b91f68 100644 +--- a/wails/wails_handlers.go ++++ b/wails/wails_handlers.go +@@ -634,7 +634,7 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body strin + return WailsRequestRouterResponse{Body: nil, Error: err.Error()} + } + } +- invoice, err := app.api.CreateInvoice(ctx, makeInvoiceRequest.Amount, makeInvoiceRequest.Description) ++ invoice, err := app.api.CreateInvoice(ctx, makeInvoiceRequest.Amount, makeInvoiceRequest.Description, nil) + if err != nil { + return WailsRequestRouterResponse{Body: nil, Error: err.Error()} + }