Align Wallet Connections with proven Alby Hub API contract

This commit is contained in:
copilot-swe-agent[bot]
2026-07-27 03:41:41 +00:00
committed by GitHub
parent f24be16b98
commit 82ee21f13f
6 changed files with 495 additions and 317 deletions
+102 -109
View File
@@ -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.",
+183 -39
View File
@@ -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 ─────────────────────────────────