Align Wallet Connections with proven Alby Hub API contract
This commit is contained in:
committed by
GitHub
parent
f24be16b98
commit
82ee21f13f
@@ -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,24 +251,23 @@ class AlbyHubManager:
|
|||||||
return # already setup
|
return # already setup
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _hub_unlock(self, password: str) -> None:
|
|
||||||
try:
|
|
||||||
self._request(
|
|
||||||
"POST",
|
|
||||||
"/api/unlock",
|
|
||||||
body={"unlockPassword": password},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
except AlbyHubHttpError as exc:
|
|
||||||
if exc.status_code == 409:
|
|
||||||
return # already unlocked
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _obtain_token(self, password: str) -> str:
|
def _obtain_token(self, password: str) -> str:
|
||||||
|
info = self._request("GET", "/api/info", timeout=10)
|
||||||
|
if info.get("running"):
|
||||||
resp = self._request(
|
resp = self._request(
|
||||||
"POST",
|
"POST",
|
||||||
"/api/auth",
|
"/api/unlock",
|
||||||
body={"password": password},
|
body={
|
||||||
|
"unlockPassword": password,
|
||||||
|
"permission": "full",
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
resp = self._request(
|
||||||
|
"POST",
|
||||||
|
"/api/start",
|
||||||
|
body={"unlockPassword": password},
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
token = (
|
token = (
|
||||||
@@ -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 already exists 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.",
|
||||||
|
|||||||
@@ -139,12 +139,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",
|
||||||
@@ -248,7 +249,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 +260,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 +267,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 +290,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 +307,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 +374,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 +435,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 +465,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 +483,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 +566,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 +584,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 {}
|
||||||
|
|
||||||
@@ -537,11 +605,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 +627,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 +646,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 +664,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 +678,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 +708,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 +740,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 +784,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 +800,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 +813,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 +823,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 +900,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 +957,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 +975,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 +986,41 @@ class LnurlCallbackTests(unittest.TestCase):
|
|||||||
self.assertEqual(code, 502)
|
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 ─────────────────────────────────
|
# ── Server API integration tests ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,14 +66,14 @@ 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
|
||||||
|
|
||||||
@@ -93,6 +93,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
|
||||||
|
|||||||
+58
-125
@@ -1,76 +1,27 @@
|
|||||||
{ 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 ${patchedAlbyHub}/bin/hub
|
||||||
}
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Patch 2 — optional isolated app attribution for invoice creation.
|
|
||||||
# Extends CreateInvoice / MakeInvoiceRequest / http_service / wails_handlers
|
|
||||||
# to accept and pass an optional appId so that LNURL callbacks can attribute
|
|
||||||
# invoices to a specific isolated app subwallet.
|
|
||||||
# Context lines must match getalby/hub v1.14.2 exactly; patch fails on drift.
|
|
||||||
patchAppIdAttribution = pkgs.writeText "0002-invoice-app-attribution.patch" ''
|
|
||||||
--- a/api/models.go
|
|
||||||
+++ b/api/models.go
|
|
||||||
@@ -1,5 +1,6 @@
|
|
||||||
type MakeInvoiceRequest struct {
|
|
||||||
Amount int64 `json:"amount"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
DescriptionHash string `json:"descriptionHash"`
|
|
||||||
Expiry *int64 `json:"expiry"`
|
|
||||||
+ AppId *uint `json:"appId"`
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
|
|
||||||
albyhub = pkgs.buildGoModule {
|
|
||||||
pname = "albyhub";
|
|
||||||
version = albyhubVersion;
|
|
||||||
src = albyhubSrc;
|
|
||||||
|
|
||||||
# go.sum-derived vendor hash — regenerate after any Go dependency change
|
|
||||||
vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
||||||
|
|
||||||
patches = [ patchPrivateRouteHints patchAppIdAttribution ];
|
|
||||||
|
|
||||||
# Run gofmt on modified Go sources after patching
|
|
||||||
postPatch = ''
|
|
||||||
gofmt -w lnclient/lnd/lnd.go api/models.go api/transactions.go \
|
|
||||||
http/http_service.go wails/wails_handlers.go
|
|
||||||
'';
|
|
||||||
|
|
||||||
meta = {
|
|
||||||
description = "Alby Hub — self-hosted NWC wallet server (Sovran_SystemsOS build)";
|
|
||||||
license = lib.licenses.gpl3;
|
|
||||||
mainProgram = "hub";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
in
|
in
|
||||||
lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
||||||
assertions = [
|
assertions = [
|
||||||
@@ -78,9 +29,12 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|||||||
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;
|
||||||
@@ -96,73 +50,51 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|||||||
group = "nwc-lnurl";
|
group = "nwc-lnurl";
|
||||||
home = "/var/lib/nwc-lnurl";
|
home = "/var/lib/nwc-lnurl";
|
||||||
createHome = false;
|
createHome = false;
|
||||||
extraGroups = [ "albyhub" ]; # needs to read /var/lib/albyhub/unlock-password
|
extraGroups = [ "albyhub" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── 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 -"
|
"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 = {
|
||||||
|
HOME = "/var/lib/albyhub";
|
||||||
|
HOST = "127.0.0.1";
|
||||||
|
LN_BACKEND_TYPE = "LND";
|
||||||
|
ENABLE_ADVANCED_SETUP = "false";
|
||||||
|
LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
||||||
|
LND_CERT_FILE = lndCertPath;
|
||||||
|
LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
||||||
WORK_DIR = "/var/lib/albyhub";
|
WORK_DIR = "/var/lib/albyhub";
|
||||||
|
DATABASE_URI = "/var/lib/albyhub/nwc.db";
|
||||||
PORT = "8080";
|
PORT = "8080";
|
||||||
LDK_NETWORK = "bitcoin";
|
RELAY = "wss://relay.getalby.com,wss://relay2.getalby.com";
|
||||||
|
AUTO_LINK_ALBY_ACCOUNT = "false";
|
||||||
|
SEND_EVENTS_TO_ALBY = "false";
|
||||||
LOG_TO_FILE = "false";
|
LOG_TO_FILE = "false";
|
||||||
AUTO_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password";
|
HIDE_UPDATE_BANNER = "true";
|
||||||
ALBY_ACCOUNT_AUTOLINK = "false";
|
|
||||||
ALBY_DISABLE_EVENTS = "true";
|
|
||||||
ENABLE_SECURE_COOKIE = "false";
|
|
||||||
ALBY_HUB_HIDE_VERSION_BANNER = "true";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
@@ -170,23 +102,19 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|||||||
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" ];
|
||||||
@@ -212,7 +140,12 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── 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";
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user