From 82ee21f13f57409aceacc1e737e534f3a7e5b7d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:41:41 +0000 Subject: [PATCH] Align Wallet Connections with proven Alby Hub API contract --- app/sovran_systemsos_web/nwc_hub_manager.py | 211 +++++++------- app/tests/test_wallet_connections.py | 222 ++++++++++++--- docs/wallet-connections.md | 30 +- modules/nwc-wallets.nix | 261 +++++++----------- .../albyhub/0001-private-route-hints.patch | 14 + .../0002-isolated-invoice-app-id.patch | 74 +++++ 6 files changed, 495 insertions(+), 317 deletions(-) create mode 100644 packages/albyhub/0001-private-route-hints.patch create mode 100644 packages/albyhub/0002-isolated-invoice-app-id.patch diff --git a/app/sovran_systemsos_web/nwc_hub_manager.py b/app/sovran_systemsos_web/nwc_hub_manager.py index 4f28da6..4ced9cc 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 already exists 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/tests/test_wallet_connections.py b/app/tests/test_wallet_connections.py index 3c234c1..758c63d 100644 --- a/app/tests/test_wallet_connections.py +++ b/app/tests/test_wallet_connections.py @@ -139,12 +139,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", @@ -248,7 +249,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 +260,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 +267,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 +290,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 +307,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 +374,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 +435,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 +465,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 +483,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 +566,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 +584,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 {} @@ -537,11 +605,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 +627,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 +646,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 +664,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 +678,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 +708,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 +740,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 +784,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 +800,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 +813,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 +823,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 +900,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 +957,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 +975,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 +986,41 @@ class LnurlCallbackTests(unittest.TestCase): self.assertEqual(code, 502) +# ── Nix/Patch contract tests ─────────────────────────────────────── + + +class NixPatchContractTests(unittest.TestCase): + 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_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) + + # ── Server API integration tests ───────────────────────────────── diff --git a/docs/wallet-connections.md b/docs/wallet-connections.md index 73896fd..54bbf2f 100644 --- a/docs/wallet-connections.md +++ b/docs/wallet-connections.md @@ -66,14 +66,14 @@ 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 @@ -93,6 +93,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/nwc-wallets.nix b/modules/nwc-wallets.nix index 2cee96e..76014b8 100644 --- a/modules/nwc-wallets.nix +++ b/modules/nwc-wallets.nix @@ -1,223 +1,156 @@ { 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 ${patchedAlbyHub}/bin/hub ''; - - # 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.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 + group = "nwc-lnurl"; + home = "/var/lib/nwc-lnurl"; + createHome = false; + extraGroups = [ "albyhub" ]; }; - # ── 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 = "nwc-lnurl"; + Group = "nwc-lnurl"; 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()} + }