Merge pull request #337 from naturallaw777/copilot/urgent-fix-alby-hub-api

Restore Wallet Connections to exact proven Alby Hub/LND contract and remove placeholder packaging
This commit is contained in:
Sovran Systems
2026-07-27 05:17:10 +00:00
committed by GitHub
9 changed files with 720 additions and 326 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="120" fill="none"><g clip-path="url(#a)"><path fill="#472459" d="M127.2 65.67c-5.13 5.11-12.23 3.88-17.35-1.25l-41.4-41.6 19.4-19.47a11.35 11.35 0 0 1 18.4 3.37l21.58 56.22a2.5 2.5 0 0 1-.57 2.67l-13.63 13.57 13.58-13.51Z"/><path fill="url(#b)" d="m109.85 64.42-54.16-54.4a13.13 13.13 0 0 0-18.57-.05L3.87 43.07a13.13 13.13 0 0 0-.05 18.57l54.16 54.4a13.13 13.13 0 0 0 18.57.04l10.7-10.64 3.12-3.12-10-10.03a19.04 19.04 0 0 1-23.83-2.56l-6.8-6.85a2.46 2.46 0 0 1 0-3.5l3.35-3.32-8.4-8.46a3.67 3.67 0 0 1-.34-4.9 3.57 3.57 0 0 1 5.29-.23l8.51 8.56 6.68-6.63-8.41-8.46a3.67 3.67 0 0 1-.33-4.9 3.58 3.58 0 0 1 5.3-.24l8.5 8.57 3.36-3.34a2.47 2.47 0 0 1 3.5.01l6.8 6.84a19.03 19.03 0 0 1 2.41 23.86l10 10.03 5.69-5.67 8.16-8.12 17.4-17.31c-5.15 5.11-12.25 3.88-17.36-1.25Z"/></g><defs><linearGradient id="b" x1="63.6" x2="63.6" y1="6.15" y2="119.91" gradientUnits="userSpaceOnUse"><stop stop-color="#FFCA4A"/><stop offset="1" stop-color="#F7931A"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h128v119.91H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+102 -109
View File
@@ -26,9 +26,11 @@ logger = logging.getLogger(__name__)
DEFAULT_API_BASE = "http://127.0.0.1:8080" DEFAULT_API_BASE = "http://127.0.0.1:8080"
DEFAULT_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password" DEFAULT_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password"
DEFAULT_MACAROON_FILE = "/run/lnd/albyhub.macaroon" DEFAULT_MACAROON_FILE = os.environ.get(
DEFAULT_LND_ADDRESS = "localhost" "NWC_LND_MACAROON_FILE", "/run/lnd/albyhub.macaroon"
DEFAULT_LND_CERT_FILE = "/var/lib/lnd/tls.cert" )
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" DEFAULT_LND_SOCKET = "/run/lnd/lnd.socket"
LNURL_DESCRIPTION_DEFAULT = "Pay via Lightning" LNURL_DESCRIPTION_DEFAULT = "Pay via Lightning"
@@ -172,16 +174,22 @@ class AlbyHubManager:
path = path_template.format(limit=page_size, offset=offset) path = path_template.format(limit=page_size, offset=offset)
page = self._request("GET", path, token=token) page = self._request("GET", path, token=token)
# Alby Hub returns apps at the top level or under "apps"/"transactions" # Alby Hub returns apps at the top level or under "apps"/"transactions"
total_count: int | None = None
if isinstance(page, list): if isinstance(page, list):
items = page items = page
elif isinstance(page, dict): elif isinstance(page, dict):
items = page.get("apps") or page.get("transactions") or [] items = page.get("apps") or page.get("transactions") or []
if page.get("totalCount") is not None:
total_count = int(page.get("totalCount"))
else: else:
items = [] items = []
if not isinstance(items, list): if not isinstance(items, list):
break break
results.extend(items) 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 break
offset += page_size offset += page_size
return results return results
@@ -229,21 +237,12 @@ class AlbyHubManager:
except AlbyHubError: except AlbyHubError:
pass 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 = { setup_body = {
"backendType": "LND",
"unlockPassword": password, "unlockPassword": password,
"lndAddress": self.lnd_address, "lndAddress": self.lnd_address,
"lndCertFile": self.lnd_cert_file, "lndCertFile": self.lnd_cert_file,
"lndMacaroon": macaroon_hex, "lndMacaroonFile": self.macaroon_file,
"backendType": "LND",
} }
try: try:
self._request("POST", "/api/setup", body=setup_body, timeout=30) self._request("POST", "/api/setup", body=setup_body, timeout=30)
@@ -252,26 +251,25 @@ class AlbyHubManager:
return # already setup return # already setup
raise raise
def _hub_unlock(self, password: str) -> None: def _obtain_token(self, password: str) -> str:
try: info = self._request("GET", "/api/info", timeout=10)
self._request( if info.get("running"):
resp = self._request(
"POST", "POST",
"/api/unlock", "/api/unlock",
body={
"unlockPassword": password,
"permission": "full",
},
timeout=30,
)
else:
resp = self._request(
"POST",
"/api/start",
body={"unlockPassword": password}, body={"unlockPassword": password},
timeout=30, 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 = ( token = (
resp.get("token") resp.get("token")
or resp.get("accessToken") or resp.get("accessToken")
@@ -309,7 +307,6 @@ class AlbyHubManager:
self._wait_for_file(self.macaroon_file, timeout=120) self._wait_for_file(self.macaroon_file, timeout=120)
self._wait_for_hub_api(timeout=120) self._wait_for_hub_api(timeout=120)
self._hub_setup(password) self._hub_setup(password)
self._hub_unlock(password)
token = self._obtain_token(password) token = self._obtain_token(password)
self._wait_for_node_ready(token, timeout=120) self._wait_for_node_ready(token, timeout=120)
self._token = token self._token = token
@@ -332,7 +329,11 @@ class AlbyHubManager:
def _is_managed_app(self, app: dict) -> bool: def _is_managed_app(self, app: dict) -> bool:
meta = self._parse_metadata(app.get("metadata")) 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: def _app_to_wallet_meta(self, app: dict, domain: str | None) -> dict:
meta = self._parse_metadata(app.get("metadata")) meta = self._parse_metadata(app.get("metadata"))
@@ -344,15 +345,9 @@ class AlbyHubManager:
"send_receive_limited" if "pay_invoice" in scopes else "receive_only" "send_receive_limited" if "pay_invoice" in scopes else "receive_only"
) )
balance_sats = 0 balance_msat = int(app.get("balanceMsat", 0) or 0)
budget = app.get("budget") or {} balance_sats = balance_msat // 1000
used_msat = int(budget.get("usedBudget", 0) or 0) dust_msat = balance_msat % 1000
balance_sats = used_msat // 1000
remaining_sats: int | None = None
remaining_raw = budget.get("remainingBudget")
if remaining_raw is not None:
remaining_sats = int(remaining_raw) // 1000
spending_limit_sats: int | None = None spending_limit_sats: int | None = None
max_amount = app.get("maxAmountSat") or 0 max_amount = app.get("maxAmountSat") or 0
@@ -360,21 +355,18 @@ class AlbyHubManager:
spending_limit_sats = int(max_amount) spending_limit_sats = int(max_amount)
# Count pending transactions from the budget or transactions list # Count pending transactions from the budget or transactions list
pending_txs = len( pending_txs = int(app.get("pendingTransactionsCount", 0) or 0)
[t for t in (app.get("pendingTransactions") or []) if t]
)
return { return {
"id": str(app.get("id", "")), "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", ""), "name": app.get("name", ""),
"alias": alias, "alias": alias,
"lightning_address": address, "lightning_address": address,
"access_preset": access_preset, "access_preset": access_preset,
"spending_limit_sats": spending_limit_sats, "spending_limit_sats": spending_limit_sats,
"remaining_budget_sats": remaining_sats,
"balance_sats": balance_sats, "balance_sats": balance_sats,
"dust_msat": 0, "dust_msat": dust_msat,
"pending_transactions": pending_txs, "pending_transactions": pending_txs,
"created_at": app.get("createdAt") or app.get("created_at"), "created_at": app.get("createdAt") or app.get("created_at"),
"min_sendable_msat": int( "min_sendable_msat": int(
@@ -386,7 +378,7 @@ class AlbyHubManager:
} }
def _all_managed_apps(self) -> list[dict]: 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)] 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: def _find_managed_app(self, identifier: str) -> dict | None:
@@ -395,7 +387,7 @@ class AlbyHubManager:
if str(app.get("id", "")).lower() == needle: if str(app.get("id", "")).lower() == needle:
return app return app
pubkey = ( pubkey = (
app.get("nostrPubkey") or app.get("pubkey") or "" app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
).lower() ).lower()
if pubkey == needle: if pubkey == needle:
return app return app
@@ -405,10 +397,12 @@ class AlbyHubManager:
def list_wallets(self, domain: str | None = None) -> list[dict]: def list_wallets(self, domain: str | None = None) -> list[dict]:
"""Return all managed isolated app wallets (no secrets).""" """Return all managed isolated app wallets (no secrets)."""
return [ wallets = []
self._app_to_wallet_meta(a, domain) for app in self._all_managed_apps():
for a 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( def create_wallet(
self, self,
@@ -474,7 +468,7 @@ class AlbyHubManager:
if app_id is not None: if app_id is not None:
try: try:
app_detail = self._authenticated_request( app_detail = self._authenticated_request(
"GET", f"/api/apps/{app_id}" "GET", f"/api/v2/apps/{app_id}"
) )
except AlbyHubError: except AlbyHubError:
pass pass
@@ -503,16 +497,17 @@ class AlbyHubManager:
"/api/transfers", "/api/transfers",
body={ body={
"toAppId": int(app_id), "toAppId": int(app_id),
"amountMsat": spending_limit_sats * 1000, "amountSat": spending_limit_sats,
"description": f"Initial funding for {name}",
}, },
) )
funding_result["success"] = True funding_result["success"] = True
except AlbyHubError as exc: except AlbyHubError as exc:
funding_result["error"] = exc.code funding_result["error"] = exc.code
funding_result["message"] = ( 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. " "above, but initial funding failed. Save the NWC secret now. "
"Do not create another wallet." "Do not recreate this wallet."
) )
return { return {
@@ -527,37 +522,15 @@ class AlbyHubManager:
} }
def _get_app_balance_msat(self, app: dict) -> int: def _get_app_balance_msat(self, app: dict) -> int:
budget = app.get("budget") or {} return int(app.get("balanceMsat", 0) or 0)
return int(budget.get("usedBudget", 0) or 0)
def _get_app_pending_txs(self, app_id: int) -> list[dict]: def _get_app_pending_txs(self, app_id: int) -> list[dict]:
"""Return pending transactions for the app. txs = self._paginate(
f"/api/transactions?appId={app_id}&limit={{limit}}&offset={{offset}}"
Paginates only as far as needed: stops after finding the first )
pending transaction since the caller rejects *any* pending tx. return [
""" t for t in txs if str(t.get("state", "")).lower() == "pending"
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
def drain_wallet(self, identifier: str) -> dict: def drain_wallet(self, identifier: str) -> dict:
"""Drain all whole-satoshi funds from an isolated app to the primary wallet. """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.", "Wallet has pending transactions and cannot be drained.",
) )
whole_sats = balance_msat // 1000 transferable_msat = (balance_msat // 1000) * 1000
dust_msat = balance_msat % 1000 expected_dust_msat = balance_msat - transferable_msat
if whole_sats == 0: if transferable_msat == 0:
return {"ok": True, "drained_sats": 0, "dust_msat": dust_msat} return {"ok": True, "drained_sats": 0, "dust_msat": expected_dust_msat}
# Save original permissions # Save original permissions
original_scopes = list(app.get("scopes") or []) original_scopes = list(app.get("scopes") or [])
@@ -594,12 +567,19 @@ class AlbyHubManager:
original_renewal = app.get("budgetRenewal") or "never" original_renewal = app.get("budgetRenewal") or "never"
# Temporarily grant pay_invoice scope with sufficient budget # 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 = { patch_body = {
"scopes": sorted(set(original_scopes) | {"pay_invoice"}), "scopes": sorted(set(original_scopes) | {"pay_invoice"}),
"maxAmountSat": whole_sats, "maxAmountSat": 0,
"budgetRenewal": "never", "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 drain_error: AlbyHubError | None = None
drained_sats = 0 drained_sats = 0
@@ -607,9 +587,13 @@ class AlbyHubManager:
self._authenticated_request( self._authenticated_request(
"POST", "POST",
"/api/transfers", "/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: except AlbyHubError as exc:
drain_error = exc drain_error = exc
finally: finally:
@@ -621,7 +605,7 @@ class AlbyHubManager:
} }
try: try:
self._authenticated_request( self._authenticated_request(
"PATCH", f"/api/apps/{app_id}", body=restore_body "PATCH", f"/api/apps/{app_pubkey}", body=restore_body
) )
except AlbyHubError: except AlbyHubError:
pass # best-effort restore; don't mask the original error pass # best-effort restore; don't mask the original error
@@ -630,13 +614,18 @@ class AlbyHubManager:
raise drain_error raise drain_error
# Verify remaining balance equals expected dust # 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) 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 { return {
"ok": True, "ok": True,
"drained_sats": drained_sats, "drained_sats": drained_sats,
"dust_msat": dust_msat, "dust_msat": expected_dust_msat,
"remaining_msat": remaining_msat, "remaining_msat": remaining_msat,
} }
@@ -661,16 +650,21 @@ class AlbyHubManager:
drain_result = self.drain_wallet(identifier) drain_result = self.drain_wallet(identifier)
# Verify no transferable balance remains # 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) 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: if remaining_msat >= 1000:
raise AlbyHubError( raise AlbyHubError(
"drain_incomplete", "drain_incomplete",
f"Drain verification failed: funds still remain.", f"Drain verification failed: funds still remain.",
) )
# Delete by nostr pubkey # Delete by app pubkey
pubkey = app.get("nostrPubkey") or app.get("pubkey") or "" pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
if not pubkey: if not pubkey:
raise AlbyHubError( raise AlbyHubError(
"app_pubkey_missing", "app_pubkey_missing",
@@ -681,7 +675,11 @@ class AlbyHubManager:
f"/api/apps/{urllib.parse.quote(pubkey, safe='')}", 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( def issue_invoice(
self, app_id: int, amount_msat: int, description: str = "" self, app_id: int, amount_msat: int, description: str = ""
@@ -700,24 +698,19 @@ class AlbyHubManager:
"appId": app_id, "appId": app_id,
}, },
) )
invoice: str = ( invoice: str = resp.get("invoice") or ""
resp.get("paymentRequest")
or resp.get("pr")
or resp.get("invoice")
or ""
)
returned_app_id = resp.get("appId") returned_app_id = resp.get("appId")
if not invoice: if not invoice:
raise AlbyHubError("invoice_creation_failed", "Hub returned empty invoice.") raise AlbyHubError("invoice_creation_failed", "Hub returned empty invoice.")
# Require a valid BOLT11 prefix (mainnet, testnet, signet, regtest) # 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( raise AlbyHubError(
"invalid_invoice", "Hub returned a non-BOLT11 invoice string." "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( raise AlbyHubError(
"invoice_attribution_failed", "invoice_attribution_failed",
"Invoice attribution mismatch: returned appId does not match.", "Invoice attribution mismatch: returned appId does not match.",
+14 -2
View File
@@ -186,8 +186,20 @@ def _make_handler(manager: "AlbyHubManager") -> type:
m = re.fullmatch(r"/lnurlp/([^/]+)/callback", path) m = re.fullmatch(r"/lnurlp/([^/]+)/callback", path)
if m: if m:
alias = urllib.parse.unquote(m.group(1)) alias = urllib.parse.unquote(m.group(1))
amount_list = qs.get("amount") amount_values = qs.get("amount")
amount_str = amount_list[0] if amount_list else None 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) payload, code = _lnurl_callback(alias, amount_str, self._manager)
self._send_json(code, payload) self._send_json(code, payload)
return return
+385 -39
View File
@@ -26,6 +26,8 @@ Tests cover:
import json import json
import re import re
import shutil
import subprocess
import sys import sys
import tempfile import tempfile
import types import types
@@ -139,12 +141,13 @@ def _make_app(
return { return {
"id": id_, "id": id_,
"name": name, "name": name,
"appPubkey": pubkey,
"nostrPubkey": pubkey, "nostrPubkey": pubkey,
"scopes": scopes, "scopes": scopes,
"isolated": True, "isolated": True,
"maxAmountSat": max_amount, "maxAmountSat": max_amount,
"budgetRenewal": "never", "budgetRenewal": "never",
"budget": {"usedBudget": balance_msat, "remainingBudget": 0}, "balanceMsat": balance_msat,
"pendingTransactions": pending or [], "pendingTransactions": pending or [],
"metadata": { "metadata": {
"app_store_app_id": "uncle-jim", "app_store_app_id": "uncle-jim",
@@ -185,6 +188,13 @@ class FeatureRegistryTests(unittest.TestCase):
self.assertIn(("80", "TCP"), ports) self.assertIn(("80", "TCP"), ports)
self.assertIn(("443", "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): def test_service_map_points_to_albyhub(self):
self.assertEqual(server.FEATURE_SERVICE_MAP["nwc-wallets"], "albyhub.service") 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): def test_setup_and_token_cached(self):
m = self._manager_with_stubs() m = self._manager_with_stubs()
m._hub_setup = MagicMock() m._hub_setup = MagicMock()
m._hub_unlock = MagicMock()
m._obtain_token = MagicMock(return_value="tok123") m._obtain_token = MagicMock(return_value="tok123")
token = m.ensure_ready() token = m.ensure_ready()
self.assertEqual(token, "tok123") self.assertEqual(token, "tok123")
@@ -260,7 +269,6 @@ class ManagerEnsureReadyTests(unittest.TestCase):
def test_idempotent_setup_skipped_when_already_complete(self): def test_idempotent_setup_skipped_when_already_complete(self):
m = self._manager_with_stubs() m = self._manager_with_stubs()
m._hub_setup = MagicMock() m._hub_setup = MagicMock()
m._hub_unlock = MagicMock()
m._obtain_token = MagicMock(return_value="tok-setup") m._obtain_token = MagicMock(return_value="tok-setup")
m.ensure_ready() m.ensure_ready()
m._hub_setup.assert_called_once() m._hub_setup.assert_called_once()
@@ -268,7 +276,6 @@ class ManagerEnsureReadyTests(unittest.TestCase):
def test_401_triggers_token_refresh(self): def test_401_triggers_token_refresh(self):
m = self._manager_with_stubs() m = self._manager_with_stubs()
m._hub_setup = MagicMock() m._hub_setup = MagicMock()
m._hub_unlock = MagicMock()
tokens = iter(["first-token", "refreshed-token"]) tokens = iter(["first-token", "refreshed-token"])
m._obtain_token = MagicMock(side_effect=tokens) m._obtain_token = MagicMock(side_effect=tokens)
m.ensure_ready() m.ensure_ready()
@@ -292,7 +299,6 @@ class ManagerEnsureReadyTests(unittest.TestCase):
def test_403_triggers_token_refresh(self): def test_403_triggers_token_refresh(self):
m = self._manager_with_stubs() m = self._manager_with_stubs()
m._hub_setup = MagicMock() m._hub_setup = MagicMock()
m._hub_unlock = MagicMock()
tokens = iter(["first", "second", "third"]) tokens = iter(["first", "second", "third"])
m._obtain_token = MagicMock(side_effect=tokens) m._obtain_token = MagicMock(side_effect=tokens)
m.ensure_ready() m.ensure_ready()
@@ -310,12 +316,65 @@ class ManagerEnsureReadyTests(unittest.TestCase):
result = m._authenticated_request("GET", "/api/apps") result = m._authenticated_request("GET", "/api/apps")
self.assertEqual(result, {}) 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): class ManagerPaginationTests(unittest.TestCase):
def test_paginate_collects_all_pages(self): def test_paginate_uses_total_count(self):
m = _fresh_manager() m = _fresh_manager()
page1 = [{"id": i} for i in range(100)] page1 = {"apps": [{"id": i} for i in range(3)], "totalCount": 5}
page2 = [{"id": i} for i in range(100, 150)] page2 = {"apps": [{"id": 3}, {"id": 4}], "totalCount": 5}
def _request(method, path, **_kw): def _request(method, path, **_kw):
if "offset=0" in path: if "offset=0" in path:
@@ -324,8 +383,8 @@ class ManagerPaginationTests(unittest.TestCase):
m._token = "tok" m._token = "tok"
m._request = MagicMock(side_effect=_request) m._request = MagicMock(side_effect=_request)
result = m._paginate("/api/apps?limit={limit}&offset={offset}") result = m._paginate("/api/apps?limit={limit}&offset={offset}", page_size=3)
self.assertEqual(len(result), 150) self.assertEqual(len(result), 5)
def test_paginate_single_page_stops(self): def test_paginate_single_page_stops(self):
m = _fresh_manager() m = _fresh_manager()
@@ -385,6 +444,14 @@ class ManagerListTests(unittest.TestCase):
result = m.list_wallets(domain="pay.example.com") result = m.list_wallets(domain="pay.example.com")
self.assertEqual(result[0]["lightning_address"], "bob@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): class ManagerCreateTests(unittest.TestCase):
def _mgr(self, existing_apps=None, create_resp=None): def _mgr(self, existing_apps=None, create_resp=None):
@@ -407,7 +474,7 @@ class ManagerCreateTests(unittest.TestCase):
return existing_apps return existing_apps
if method == "POST" and path == "/api/apps": if method == "POST" and path == "/api/apps":
return create_resp 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 _make_app(id_=99, alias="new")
return {} return {}
@@ -425,6 +492,15 @@ class ManagerCreateTests(unittest.TestCase):
self.assertIn("pairing_uri", result) self.assertIn("pairing_uri", result)
self.assertTrue(result["pairing_uri"].startswith("nostr+walletconnect://")) 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): def test_create_sends_isolated_true(self):
m = self._mgr() m = self._mgr()
m.create_wallet("W", "w", "receive_only", None) 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) m.create_wallet("W", "w", "send_receive_limited", 5000)
self.assertEqual(len(transfers), 1) self.assertEqual(len(transfers), 1)
self.assertEqual(transfers[0]["toAppId"], 99) 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): def test_create_partial_failure_funding_returns_pairing_uri(self):
"""Even when initial funding fails, the real pairing URI must be returned.""" """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": if method == "POST" and path == "/api/transfers":
raise mgr.AlbyHubError("transfer_failed", "Insufficient funds") 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 _make_app(id_=99, alias="new")
return {} return {}
@@ -529,6 +606,33 @@ class ManagerCreateTests(unittest.TestCase):
self.assertFalse(result["result"]["funding"]["success"]) self.assertFalse(result["result"]["funding"]["success"])
self.assertIn("message", result["result"]["funding"]) 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): class ManagerDrainTests(unittest.TestCase):
def _mgr(self, app, transfer_ok=True): def _mgr(self, app, transfer_ok=True):
@@ -537,11 +641,11 @@ class ManagerDrainTests(unittest.TestCase):
def _request(method, path, **kw): def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"): if method == "GET" and path.startswith("/api/apps?"):
return [app] return {"apps": [app], "totalCount": 1}
if method == "GET" and f"/api/apps/{app['id']}" in path and "transactions" not in path: if method == "GET" and path.startswith(f"/api/v2/apps/{app['id']}"):
return app return {**app, "balanceMsat": app.get("balanceMsat", 0) % 1000}
if method == "GET" and "transactions" in path: if method == "GET" and path.startswith("/api/transactions?"):
return [] return {"transactions": [], "totalCount": 0}
if method == "PATCH": if method == "PATCH":
return {} return {}
if method == "POST" and path == "/api/transfers": if method == "POST" and path == "/api/transfers":
@@ -559,6 +663,13 @@ class ManagerDrainTests(unittest.TestCase):
result = m.drain_wallet("1") result = m.drain_wallet("1")
self.assertTrue(result["ok"]) self.assertTrue(result["ok"])
self.assertEqual(result["drained_sats"], 5000) 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): def test_drain_preserves_dust(self):
app = _make_app(balance_msat=5_000_500) 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) app = _make_app(scopes=list(mgr.RECEIVE_ONLY_SCOPES), balance_msat=1_000_000)
m = self._mgr(app) m = self._mgr(app)
patches = [] patches = []
patch_paths = []
original = m._request.side_effect original = m._request.side_effect
def _request(method, path, **kw): def _request(method, path, **kw):
if method == "PATCH": if method == "PATCH":
patch_paths.append(path)
patches.append(kw.get("body")) patches.append(kw.get("body"))
return {} return {}
return original(method, path, **kw) return original(method, path, **kw)
@@ -587,6 +700,7 @@ class ManagerDrainTests(unittest.TestCase):
second_patch = patches[1] second_patch = patches[1]
# First patch must add pay_invoice # First patch must add pay_invoice
self.assertIn("pay_invoice", first_patch.get("scopes", [])) 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 # Second patch (restore) must match original scopes
self.assertEqual( self.assertEqual(
sorted(second_patch.get("scopes", [])), sorted(second_patch.get("scopes", [])),
@@ -600,9 +714,9 @@ class ManagerDrainTests(unittest.TestCase):
def _request(method, path, **kw): def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"): if method == "GET" and path.startswith("/api/apps?"):
return [app] return {"apps": [app], "totalCount": 1}
if "transactions" in path: if path.startswith("/api/transactions?"):
return [{"state": "pending"}] return {"transactions": [{"state": "pending"}], "totalCount": 1}
return {} return {}
m._request = MagicMock(side_effect=_request) m._request = MagicMock(side_effect=_request)
@@ -630,6 +744,29 @@ class ManagerDrainTests(unittest.TestCase):
restore = patches[-1] restore = patches[-1]
self.assertEqual(sorted(restore.get("scopes", [])), sorted(mgr.RECEIVE_ONLY_SCOPES)) 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): class ManagerDeleteTests(unittest.TestCase):
def _mgr(self, app, drain_ok=True): def _mgr(self, app, drain_ok=True):
@@ -639,13 +776,13 @@ class ManagerDeleteTests(unittest.TestCase):
def _request(method, path, **kw): def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"): if method == "GET" and path.startswith("/api/apps?"):
return [app] return {"apps": [app], "totalCount": 1}
if method == "GET" and "transactions" in path: if method == "GET" and path.startswith("/api/transactions?"):
return [] return {"transactions": [], "totalCount": 0}
if method == "GET" and f"/api/apps/{app['id']}" in path: if method == "GET" and path.startswith(f"/api/v2/apps/{app['id']}"):
# After drain the balance is zero # After drain the balance is zero
a = dict(app) a = dict(app)
a["budget"] = {"usedBudget": 0} a["balanceMsat"] = 0
return a return a
if method == "PATCH": if method == "PATCH":
return {} return {}
@@ -683,9 +820,9 @@ class ManagerDeleteTests(unittest.TestCase):
def _request(method, path, **kw): def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"): if method == "GET" and path.startswith("/api/apps?"):
return [app] return {"apps": [app], "totalCount": 1}
if "transactions" in path: if path.startswith("/api/transactions?"):
return [{"state": "pending"}] return {"transactions": [{"state": "pending"}], "totalCount": 1}
return {} return {}
m._request = MagicMock(side_effect=_request) m._request = MagicMock(side_effect=_request)
@@ -699,7 +836,7 @@ class ManagerInvoiceTests(unittest.TestCase):
m = _fresh_manager() m = _fresh_manager()
m._token = "tok" m._token = "tok"
m._request = MagicMock( m._request = MagicMock(
return_value={"paymentRequest": invoice, "appId": app_id} return_value={"invoice": invoice, "appId": app_id}
) )
return m return m
@@ -712,7 +849,7 @@ class ManagerInvoiceTests(unittest.TestCase):
m = self._mgr("lnbc5n1" + "z" * 40) m = self._mgr("lnbc5n1" + "z" * 40)
# This starts with lnbc so is valid format - test the appId mismatch instead # This starts with lnbc so is valid format - test the appId mismatch instead
m._request = MagicMock( 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: with self.assertRaises(mgr.AlbyHubError) as ctx:
m.issue_invoice(1, 5_000_000) m.issue_invoice(1, 5_000_000)
@@ -722,18 +859,26 @@ class ManagerInvoiceTests(unittest.TestCase):
m = _fresh_manager() m = _fresh_manager()
m._token = "tok" m._token = "tok"
m._request = MagicMock( m._request = MagicMock(
return_value={"paymentRequest": "lnbc1000n1test", "appId": 999} return_value={"invoice": "lnbc1000n1test", "appId": 999}
) )
with self.assertRaises(mgr.AlbyHubError) as ctx: with self.assertRaises(mgr.AlbyHubError) as ctx:
m.issue_invoice(1, 1_000_000) m.issue_invoice(1, 1_000_000)
self.assertEqual(ctx.exception.code, "invoice_attribution_failed") 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): def test_invoice_request_includes_app_id(self):
# Use a manager that returns the correct appId matching what we request # Use a manager that returns the correct appId matching what we request
m = _fresh_manager() m = _fresh_manager()
m._token = "tok" m._token = "tok"
m._request = MagicMock( m._request = MagicMock(
return_value={"paymentRequest": "lnbc1000n1test", "appId": 42} return_value={"invoice": "lnbc1000n1test", "appId": 42}
) )
m.issue_invoice(42, 2_000_000) m.issue_invoice(42, 2_000_000)
call_body = m._request.call_args.kwargs.get("body") or m._request.call_args[1].get("body") 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): def _request(method, path, **kw):
if path.startswith("/api/apps"): if path.startswith("/api/apps"):
return [app] return {"apps": [app], "totalCount": 1}
if path == "/api/invoices": if path == "/api/invoices":
body = kw.get("body") or {} body = kw.get("body") or {}
return {"paymentRequest": invoice, "appId": body.get("appId")} return {"invoice": invoice, "appId": body.get("appId")}
return {} return {}
m._request = MagicMock(side_effect=_request) m._request = MagicMock(side_effect=_request)
@@ -848,10 +993,10 @@ class LnurlCallbackTests(unittest.TestCase):
def _request(method, path, **kw): def _request(method, path, **kw):
if path.startswith("/api/apps"): if path.startswith("/api/apps"):
return [app] return {"apps": [app], "totalCount": 1}
if path == "/api/invoices": if path == "/api/invoices":
# Return wrong appId # Return wrong appId
return {"paymentRequest": "lnbc1000n1pfake", "appId": 999} return {"invoice": "lnbc1000n1pfake", "appId": 999}
return {} return {}
m._request = MagicMock(side_effect=_request) m._request = MagicMock(side_effect=_request)
@@ -866,9 +1011,9 @@ class LnurlCallbackTests(unittest.TestCase):
def _request(method, path, **kw): def _request(method, path, **kw):
if path.startswith("/api/apps"): if path.startswith("/api/apps"):
return [app] return {"apps": [app], "totalCount": 1}
if path == "/api/invoices": if path == "/api/invoices":
return {"paymentRequest": "not_a_bolt11_string", "appId": 1} return {"invoice": "not_a_bolt11_string", "appId": 1}
return {} return {}
m._request = MagicMock(side_effect=_request) m._request = MagicMock(side_effect=_request)
@@ -877,6 +1022,207 @@ class LnurlCallbackTests(unittest.TestCase):
self.assertEqual(code, 502) 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 ───────────────────────────────── # ── Server API integration tests ─────────────────────────────────
+37 -6
View File
@@ -66,23 +66,34 @@ Security invariants:
- Alby Hub unlock password: `/var/lib/albyhub/unlock-password` (generated once, mode 0600) - Alby Hub unlock password: `/var/lib/albyhub/unlock-password` (generated once, mode 0600)
- LND macaroon for Alby Hub: `/run/lnd/albyhub.macaroon` (restricted permissions) - 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. 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** (`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. 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 ## Services
| Service | User | Description | | Service | User | Description |
|---|---|---| |---|---|---|
| `albyhub.service` | `albyhub` | Headless Alby Hub NWC wallet server | | `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 | | `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 ## API
Management (authenticated): Management (authenticated):
@@ -93,6 +104,26 @@ Management (authenticated):
- `POST /api/nwc/wallets/{id-or-pubkey}/drain` — transfer funds to primary wallet - `POST /api/nwc/wallets/{id-or-pubkey}/drain` — transfer funds to primary wallet
- `POST /api/nwc/addresses/{alias}/test` — verify public LNURL endpoint - `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=<N>&offset=<N>&order_by=created_at` (full pagination using `totalCount`)
- `GET /api/v2/apps/{id}` for app-by-id fetches
- `GET /api/transactions?appId={id}&limit=<N>&offset=<N>` (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): Public LNURL (served by dedicated `nwc-lnurl` service via Caddy):
- `GET /.well-known/lnurlp/{alias}` — LNURL-pay discovery - `GET /.well-known/lnurlp/{alias}` — LNURL-pay discovery
+1 -1
View File
@@ -61,7 +61,7 @@ let
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; } { label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
{ label = "Status"; value = "Auto-configured on first boot"; } { 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"; } { 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 = [ { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
+92 -169
View File
@@ -1,223 +1,146 @@
{ config, pkgs, lib, ... }: { 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 let
albyhubVersion = "1.14.2"; patchedAlbyHub = pkgs.albyhub.overrideAttrs (old: {
albyhubSrc = pkgs.fetchFromGitHub { patches = (old.patches or []) ++ [
owner = "getAlby"; ../packages/albyhub/0001-private-route-hints.patch
repo = "hub"; ../packages/albyhub/0002-isolated-invoice-app-id.patch
rev = "v${albyhubVersion}"; ];
sha256 = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; });
};
# Patch 1 — private route hints for regular invoices. lndRpcAddress = lib.attrByPath [ "services" "lnd" "rpcAddress" ] "127.0.0.1" config;
# Sets the Private field to true in MakeInvoice so that wallets behind lndRpcPort = toString (lib.attrByPath [ "services" "lnd" "rpcPort" ] 10009 config);
# private channels can receive payments via route hints. lndCertPath = lib.attrByPath [ "services" "lnd" "certPath" ] "/var/lib/lnd/tls.cert" config;
# Context lines must match getalby/hub v1.14.2 exactly; patch fails on drift.
patchPrivateRouteHints = pkgs.writeText "0001-lnd-private-route-hints.patch" '' albyhubWrapper = pkgs.writeShellScript "albyhub-wrapper" ''
--- a/lnclient/lnd/lnd.go set -euo pipefail
+++ b/lnclient/lnd/lnd.go password_file="/var/lib/albyhub/unlock-password"
@@ -1,5 +1,6 @@ if [ ! -s "$password_file" ]; then
invoice := &lnrpc.Invoice{ umask 077
Memo: description, ${pkgs.openssl}/bin/openssl rand -hex 32 > "$password_file"
Value: amountSat, fi
+ Private: true, export AUTO_UNLOCK_PASSWORD="$(cat "$password_file")"
Expiry: expiry, 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 in
lib.mkIf config.sovran_systemsOS.features."nwc-wallets" { lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
assertions = [ assertions = [
{ {
assertion = config.services.lnd.enable; 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 = { users.users.albyhub = {
isSystemUser = true; isSystemUser = true;
group = "albyhub"; group = "albyhub";
home = "/var/lib/albyhub"; home = "/var/lib/albyhub";
createHome = false; createHome = false;
extraGroups = []; 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 = [ systemd.tmpfiles.rules = [
"d /var/lib/albyhub 0700 albyhub albyhub -" "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 = { services.lnd.macaroons.albyhub = {
user = "albyhub"; user = "albyhub";
permissions = '' permissions = lib.concatStringsSep "," [
{"entity":"info","action":"read"}, ''{"entity":"info","action":"read"}''
{"entity":"offchain","action":"read"}, ''{"entity":"offchain","action":"read"}''
{"entity":"offchain","action":"write"}, ''{"entity":"offchain","action":"write"}''
{"entity":"invoices","action":"read"}, ''{"entity":"invoices","action":"read"}''
{"entity":"invoices","action":"write"}, ''{"entity":"invoices","action":"write"}''
{"entity":"onchain","action":"read"}, ''{"entity":"onchain","action":"read"}''
{"entity":"address","action":"read"}, ''{"entity":"address","action":"read"}''
{"entity":"message","action":"read"}, ''{"entity":"message","action":"read"}''
{"entity":"message","action":"write"} ''{"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 = { systemd.services.albyhub = {
description = "Alby Hub NWC wallet server"; description = "Alby Hub NWC wallet server";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ after = [ "network.target" "lnd.service" ];
"network.target" requires = [ "lnd.service" ];
"lnd.service"
"albyhub-init.service"
];
requires = [ "lnd.service" "albyhub-init.service" ];
environment = { environment = {
WORK_DIR = "/var/lib/albyhub"; HOME = "/var/lib/albyhub";
PORT = "8080"; HOST = "127.0.0.1";
LDK_NETWORK = "bitcoin"; LN_BACKEND_TYPE = "LND";
LOG_TO_FILE = "false"; ENABLE_ADVANCED_SETUP = "false";
AUTO_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password"; LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
ALBY_ACCOUNT_AUTOLINK = "false"; LND_CERT_FILE = lndCertPath;
ALBY_DISABLE_EVENTS = "true"; LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
ENABLE_SECURE_COOKIE = "false"; WORK_DIR = "/var/lib/albyhub";
ALBY_HUB_HIDE_VERSION_BANNER = "true"; 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 = { serviceConfig = {
Type = "simple"; Type = "simple";
User = "albyhub"; User = "albyhub";
Group = "albyhub"; Group = "albyhub";
WorkingDirectory = "/var/lib/albyhub"; WorkingDirectory = "/var/lib/albyhub";
ExecStart = "${albyhub}/bin/hub"; ExecStart = albyhubWrapper;
Restart = "on-failure"; Restart = "on-failure";
RestartSec = "10s"; RestartSec = "10s";
UMask = "0027"; UMask = "0077";
NoNewPrivileges = true; NoNewPrivileges = true;
PrivateTmp = true; PrivateTmp = true;
ProtectHome = true; ProtectHome = true;
ProtectSystem = "strict"; ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/albyhub" ]; ReadWritePaths = [ "/var/lib/albyhub" ];
ReadOnlyPaths = [ ReadOnlyPaths = [ lndCertPath "/run/lnd" ];
config.services.lnd.certFile or "/var/lib/lnd/tls.cert"
"/run/lnd"
];
}; };
}; };
# ── Dedicated LNURL service ──────────────────────────────────────
systemd.services.nwc-lnurl = { systemd.services.nwc-lnurl = {
description = "Wallet Connections public LNURL service"; description = "Wallet Connections public LNURL service";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "albyhub.service" "sovran-hub-web.service" ]; after = [ "albyhub.service" "sovran-hub-web.service" ];
wants = [ "albyhub.service" ]; wants = [ "albyhub.service" ];
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
User = "nwc-lnurl"; User = "albyhub";
Group = "nwc-lnurl"; Group = "albyhub";
ExecStart = "${config.services.sovranHub.webPackage}/bin/nwc-lnurl"; ExecStart = "${config.services.sovranHub.webPackage}/bin/nwc-lnurl";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = "10s"; RestartSec = "10s";
UMask = "0027"; UMask = "0027";
NoNewPrivileges = true; NoNewPrivileges = true;
PrivateTmp = true; PrivateTmp = true;
ProtectHome = true; ProtectHome = true;
ProtectSystem = "strict"; ProtectSystem = "strict";
ReadOnlyPaths = [ ReadOnlyPaths = [
"/var/lib/domains/lightning" "/var/lib/domains/lightning"
"/var/lib/albyhub/unlock-password" "/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 = [ sovran_systemsOS.domainRequirements = [
{ {
name = "lightning"; name = "lightning";
label = "Lightning Address Domain"; label = "Lightning Address Domain";
example = "pay.yourdomain.com"; example = "pay.yourdomain.com";
needsDDNS = true; needsDDNS = true;
} }
]; ];
@@ -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)
@@ -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()}
}