Merge pull request #336 from naturallaw777/copilot/replace-wallet-connections-scaffolding
Replace Wallet Connections scaffolding with real Alby Hub/LND backend
This commit is contained in:
@@ -0,0 +1,767 @@
|
||||
"""
|
||||
Alby Hub manager — shared backend for Wallet Connections API and recovery CLI.
|
||||
|
||||
Interfaces with the local Alby Hub instance at http://127.0.0.1:8080.
|
||||
All sensitive values (passwords, bearer tokens, pairing URIs, macaroon
|
||||
contents, Nostr private keys) are redacted from any exception messages
|
||||
or log output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
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_LND_SOCKET = "/run/lnd/lnd.socket"
|
||||
|
||||
LNURL_DESCRIPTION_DEFAULT = "Pay via Lightning"
|
||||
NWC_MIN_SENDABLE_MSAT = 1000
|
||||
NWC_MAX_SENDABLE_MSAT = 1_000_000_000
|
||||
|
||||
# Metadata key used to mark managed isolated wallets
|
||||
_MANAGED_APP_STORE_ID = "uncle-jim"
|
||||
_MANAGED_META_KEY = "app_store_app_id"
|
||||
|
||||
RECEIVE_ONLY_SCOPES = [
|
||||
"get_info",
|
||||
"get_balance",
|
||||
"make_invoice",
|
||||
"lookup_invoice",
|
||||
"list_transactions",
|
||||
"notifications",
|
||||
]
|
||||
|
||||
LIMITED_SEND_SCOPES = RECEIVE_ONLY_SCOPES + ["pay_invoice"]
|
||||
|
||||
# ── Exceptions ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AlbyHubError(Exception):
|
||||
"""Base error from the Alby Hub manager.
|
||||
|
||||
The message string is safe to surface to the user — it never
|
||||
contains raw secret material.
|
||||
"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.code}] {self.args[0]}"
|
||||
|
||||
|
||||
class AlbyHubHttpError(AlbyHubError):
|
||||
def __init__(self, status_code: int, message: str) -> None:
|
||||
super().__init__(f"http_{status_code}", message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
# ── Manager class ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AlbyHubManager:
|
||||
"""Thread-safe manager for Alby Hub API operations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_base: str = DEFAULT_API_BASE,
|
||||
unlock_password_file: str = DEFAULT_UNLOCK_PASSWORD_FILE,
|
||||
macaroon_file: str = DEFAULT_MACAROON_FILE,
|
||||
lnd_address: str = DEFAULT_LND_ADDRESS,
|
||||
lnd_cert_file: str = DEFAULT_LND_CERT_FILE,
|
||||
) -> None:
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.unlock_password_file = unlock_password_file
|
||||
self.macaroon_file = macaroon_file
|
||||
self.lnd_address = lnd_address
|
||||
self.lnd_cert_file = lnd_cert_file
|
||||
self._lock = threading.Lock()
|
||||
self._token: str | None = None
|
||||
|
||||
# ── Low-level HTTP ─────────────────────────────────────────────
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict | None = None,
|
||||
token: str | None = None,
|
||||
timeout: int = 30,
|
||||
) -> dict:
|
||||
"""Make a raw HTTP request to the local Alby Hub API.
|
||||
|
||||
Returns the parsed JSON response body.
|
||||
Raises AlbyHubHttpError on non-2xx responses.
|
||||
Secrets in response bodies are never included in raised exceptions.
|
||||
"""
|
||||
url = f"{self.api_base}{path}"
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
headers: dict[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw)
|
||||
except urllib.error.HTTPError as exc:
|
||||
code = exc.code
|
||||
# Read and discard the body — we do NOT include it in the exception
|
||||
try:
|
||||
exc.read()
|
||||
except Exception:
|
||||
pass
|
||||
raise AlbyHubHttpError(code, f"Hub API {method} {path} returned HTTP {code}") from None
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
raise AlbyHubError(
|
||||
"hub_unreachable",
|
||||
f"Hub API {method} {path} is unreachable",
|
||||
) from None
|
||||
|
||||
def _authenticated_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict | None = None,
|
||||
timeout: int = 30,
|
||||
) -> dict:
|
||||
"""Make an authenticated request; retry once with a fresh token on 401/403."""
|
||||
token = self.ensure_ready()
|
||||
try:
|
||||
return self._request(method, path, body=body, token=token, timeout=timeout)
|
||||
except AlbyHubHttpError as exc:
|
||||
if exc.status_code in (401, 403):
|
||||
with self._lock:
|
||||
self._token = None
|
||||
token = self.ensure_ready()
|
||||
return self._request(method, path, body=body, token=token, timeout=timeout)
|
||||
raise
|
||||
|
||||
def _paginate(self, path_template: str, page_size: int = 100) -> list[dict]:
|
||||
"""Paginate a list API completely, collecting all items.
|
||||
|
||||
``path_template`` must contain ``{limit}`` and ``{offset}`` placeholders.
|
||||
"""
|
||||
token = self.ensure_ready()
|
||||
offset = 0
|
||||
results: list[dict] = []
|
||||
while True:
|
||||
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"
|
||||
if isinstance(page, list):
|
||||
items = page
|
||||
elif isinstance(page, dict):
|
||||
items = page.get("apps") or page.get("transactions") or []
|
||||
else:
|
||||
items = []
|
||||
if not isinstance(items, list):
|
||||
break
|
||||
results.extend(items)
|
||||
if len(items) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
return results
|
||||
|
||||
# ── Startup / Auth ─────────────────────────────────────────────
|
||||
|
||||
def _read_unlock_password(self) -> str:
|
||||
try:
|
||||
with open(self.unlock_password_file, "r") as fh:
|
||||
return fh.read().strip()
|
||||
except OSError as exc:
|
||||
raise AlbyHubError(
|
||||
"unlock_password_unavailable",
|
||||
"Cannot read Alby Hub unlock password",
|
||||
) from exc
|
||||
|
||||
def _wait_for_file(self, path: str, timeout: int = 120) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if os.path.exists(path):
|
||||
return
|
||||
time.sleep(2)
|
||||
raise AlbyHubError(
|
||||
"dependency_unavailable",
|
||||
f"Timed out waiting for required file: {path}",
|
||||
)
|
||||
|
||||
def _wait_for_hub_api(self, timeout: int = 120) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
self._request("GET", "/api/info", timeout=5)
|
||||
return
|
||||
except AlbyHubError:
|
||||
pass
|
||||
time.sleep(3)
|
||||
raise AlbyHubError("hub_unavailable", "Timed out waiting for Alby Hub API")
|
||||
|
||||
def _hub_setup(self, password: str) -> None:
|
||||
"""Perform /api/setup idempotently."""
|
||||
try:
|
||||
info = self._request("GET", "/api/info", timeout=10)
|
||||
if info.get("setupCompleted"):
|
||||
return
|
||||
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 = {
|
||||
"unlockPassword": password,
|
||||
"lndAddress": self.lnd_address,
|
||||
"lndCertFile": self.lnd_cert_file,
|
||||
"lndMacaroon": macaroon_hex,
|
||||
"backendType": "LND",
|
||||
}
|
||||
try:
|
||||
self._request("POST", "/api/setup", body=setup_body, timeout=30)
|
||||
except AlbyHubHttpError as exc:
|
||||
if exc.status_code == 409:
|
||||
return # already setup
|
||||
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:
|
||||
resp = self._request(
|
||||
"POST",
|
||||
"/api/auth",
|
||||
body={"password": password},
|
||||
timeout=30,
|
||||
)
|
||||
token = (
|
||||
resp.get("token")
|
||||
or resp.get("accessToken")
|
||||
or resp.get("access_token")
|
||||
)
|
||||
if not token or not isinstance(token, str):
|
||||
raise AlbyHubError("auth_failed", "Alby Hub auth response missing token")
|
||||
return token
|
||||
|
||||
def _wait_for_node_ready(self, token: str, timeout: int = 120) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status = self._request(
|
||||
"GET", "/api/node/status", token=token, timeout=10
|
||||
)
|
||||
if status.get("isReady") or status.get("running") or status.get("online"):
|
||||
return
|
||||
except AlbyHubError:
|
||||
pass
|
||||
time.sleep(3)
|
||||
raise AlbyHubError("node_not_ready", "Timed out waiting for Alby Hub node to be ready")
|
||||
|
||||
def ensure_ready(self) -> str:
|
||||
"""Ensure Alby Hub is set up, unlocked, and authenticated.
|
||||
|
||||
Returns a valid bearer token. Caches it and uses a lock to
|
||||
prevent concurrent setup races.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._token:
|
||||
return self._token
|
||||
|
||||
password = self._read_unlock_password()
|
||||
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
|
||||
return token
|
||||
|
||||
# ── App isolation helpers ──────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_metadata(raw: Any) -> dict:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
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
|
||||
|
||||
def _app_to_wallet_meta(self, app: dict, domain: str | None) -> dict:
|
||||
meta = self._parse_metadata(app.get("metadata"))
|
||||
alias = meta.get("lnurl_alias", "")
|
||||
address = f"{alias}@{domain}" if alias and domain else None
|
||||
|
||||
scopes = app.get("scopes") or []
|
||||
access_preset = (
|
||||
"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
|
||||
|
||||
spending_limit_sats: int | None = None
|
||||
max_amount = app.get("maxAmountSat") or 0
|
||||
if max_amount:
|
||||
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]
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(app.get("id", "")),
|
||||
"pubkey": 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,
|
||||
"pending_transactions": pending_txs,
|
||||
"created_at": app.get("createdAt") or app.get("created_at"),
|
||||
"min_sendable_msat": int(
|
||||
meta.get("lnurl_min_sendable_msat", NWC_MIN_SENDABLE_MSAT)
|
||||
),
|
||||
"max_sendable_msat": int(
|
||||
meta.get("lnurl_max_sendable_msat", NWC_MAX_SENDABLE_MSAT)
|
||||
),
|
||||
}
|
||||
|
||||
def _all_managed_apps(self) -> list[dict]:
|
||||
apps = self._paginate("/api/apps?limit={limit}&offset={offset}")
|
||||
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:
|
||||
needle = identifier.strip().lower()
|
||||
for app in self._all_managed_apps():
|
||||
if str(app.get("id", "")).lower() == needle:
|
||||
return app
|
||||
pubkey = (
|
||||
app.get("nostrPubkey") or app.get("pubkey") or ""
|
||||
).lower()
|
||||
if pubkey == needle:
|
||||
return app
|
||||
return None
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────
|
||||
|
||||
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()
|
||||
]
|
||||
|
||||
def create_wallet(
|
||||
self,
|
||||
name: str,
|
||||
alias: str,
|
||||
access_preset: str,
|
||||
spending_limit_sats: int | None,
|
||||
domain: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new isolated Alby Hub app (wallet connection).
|
||||
|
||||
Returns a dict containing:
|
||||
``wallet`` — safe metadata (no secrets)
|
||||
``pairing_uri`` — real Alby Hub pairingUri (returned ONCE)
|
||||
``result`` — creation status report
|
||||
"""
|
||||
# Validate uniqueness
|
||||
managed = self._all_managed_apps()
|
||||
for a in managed:
|
||||
meta = self._parse_metadata(a.get("metadata"))
|
||||
if meta.get("lnurl_alias", "").lower() == alias.lower():
|
||||
raise AlbyHubError(
|
||||
"alias_exists", "That Lightning Address alias is already in use."
|
||||
)
|
||||
if (a.get("name") or "").lower() == name.lower():
|
||||
raise AlbyHubError(
|
||||
"wallet_name_exists",
|
||||
"That Wallet Connection name already exists.",
|
||||
)
|
||||
|
||||
scopes = (
|
||||
LIMITED_SEND_SCOPES
|
||||
if access_preset == "send_receive_limited"
|
||||
else RECEIVE_ONLY_SCOPES
|
||||
)
|
||||
max_amount = (
|
||||
spending_limit_sats
|
||||
if access_preset == "send_receive_limited" and spending_limit_sats
|
||||
else 0
|
||||
)
|
||||
|
||||
create_body: dict = {
|
||||
"name": name,
|
||||
"scopes": scopes,
|
||||
"isolated": True,
|
||||
"budgetRenewal": "never",
|
||||
"maxAmountSat": max_amount,
|
||||
"metadata": {
|
||||
_MANAGED_META_KEY: _MANAGED_APP_STORE_ID,
|
||||
"lnurl_alias": alias,
|
||||
"lnurl_description": LNURL_DESCRIPTION_DEFAULT,
|
||||
"lnurl_min_sendable_msat": NWC_MIN_SENDABLE_MSAT,
|
||||
"lnurl_max_sendable_msat": NWC_MAX_SENDABLE_MSAT,
|
||||
},
|
||||
}
|
||||
|
||||
resp = self._authenticated_request("POST", "/api/apps", body=create_body)
|
||||
pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or ""
|
||||
app_id = resp.get("id")
|
||||
|
||||
# Fetch full app details for accurate metadata
|
||||
app_detail: dict | None = None
|
||||
if app_id is not None:
|
||||
try:
|
||||
app_detail = self._authenticated_request(
|
||||
"GET", f"/api/apps/{app_id}"
|
||||
)
|
||||
except AlbyHubError:
|
||||
pass
|
||||
|
||||
if app_detail is None:
|
||||
# Fallback: search recent apps for the one we just created
|
||||
updated = self._all_managed_apps()
|
||||
for a in updated:
|
||||
if str(a.get("id", "")) == str(app_id):
|
||||
app_detail = a
|
||||
break
|
||||
|
||||
wallet_meta = self._app_to_wallet_meta(app_detail or resp, domain)
|
||||
|
||||
# Initial internal transfer for limited wallets
|
||||
funding_result: dict = {"attempted": False, "success": False}
|
||||
if (
|
||||
access_preset == "send_receive_limited"
|
||||
and spending_limit_sats
|
||||
and app_id is not None
|
||||
):
|
||||
funding_result["attempted"] = True
|
||||
try:
|
||||
self._authenticated_request(
|
||||
"POST",
|
||||
"/api/transfers",
|
||||
body={
|
||||
"toAppId": int(app_id),
|
||||
"amountMsat": spending_limit_sats * 1000,
|
||||
},
|
||||
)
|
||||
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 "
|
||||
"above, but initial funding failed. Save the NWC secret now. "
|
||||
"Do not create another wallet."
|
||||
)
|
||||
|
||||
return {
|
||||
"wallet": wallet_meta,
|
||||
"pairing_uri": pairing_uri, # returned once on create only
|
||||
"result": {
|
||||
"wallet_created": True,
|
||||
"secret_created": bool(pairing_uri),
|
||||
"lightning_address_registered": bool(alias and domain),
|
||||
"funding": funding_result,
|
||||
},
|
||||
}
|
||||
|
||||
def _get_app_balance_msat(self, app: dict) -> int:
|
||||
budget = app.get("budget") or {}
|
||||
return int(budget.get("usedBudget", 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
|
||||
|
||||
def drain_wallet(self, identifier: str) -> dict:
|
||||
"""Drain all whole-satoshi funds from an isolated app to the primary wallet.
|
||||
|
||||
Returns ``{"ok": True, "drained_sats": N, "dust_msat": M}``.
|
||||
Raises AlbyHubError on rejection or failure.
|
||||
"""
|
||||
app = self._find_managed_app(identifier)
|
||||
if app is None:
|
||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
||||
|
||||
app_id = int(app["id"])
|
||||
balance_msat = self._get_app_balance_msat(app)
|
||||
|
||||
if balance_msat < 0:
|
||||
raise AlbyHubError("negative_balance", "Wallet has a negative balance.")
|
||||
|
||||
pending = self._get_app_pending_txs(app_id)
|
||||
if pending:
|
||||
raise AlbyHubError(
|
||||
"pending_transactions",
|
||||
"Wallet has pending transactions and cannot be drained.",
|
||||
)
|
||||
|
||||
whole_sats = balance_msat // 1000
|
||||
dust_msat = balance_msat % 1000
|
||||
|
||||
if whole_sats == 0:
|
||||
return {"ok": True, "drained_sats": 0, "dust_msat": dust_msat}
|
||||
|
||||
# Save original permissions
|
||||
original_scopes = list(app.get("scopes") or [])
|
||||
original_max = app.get("maxAmountSat") or 0
|
||||
original_renewal = app.get("budgetRenewal") or "never"
|
||||
|
||||
# Temporarily grant pay_invoice scope with sufficient budget
|
||||
patch_body = {
|
||||
"scopes": sorted(set(original_scopes) | {"pay_invoice"}),
|
||||
"maxAmountSat": whole_sats,
|
||||
"budgetRenewal": "never",
|
||||
}
|
||||
self._authenticated_request("PATCH", f"/api/apps/{app_id}", body=patch_body)
|
||||
|
||||
drain_error: AlbyHubError | None = None
|
||||
drained_sats = 0
|
||||
try:
|
||||
self._authenticated_request(
|
||||
"POST",
|
||||
"/api/transfers",
|
||||
body={"fromAppId": app_id, "amountMsat": whole_sats * 1000},
|
||||
)
|
||||
drained_sats = whole_sats
|
||||
except AlbyHubError as exc:
|
||||
drain_error = exc
|
||||
finally:
|
||||
# Restore original permissions whether drain succeeded or not
|
||||
restore_body = {
|
||||
"scopes": original_scopes,
|
||||
"maxAmountSat": original_max,
|
||||
"budgetRenewal": original_renewal,
|
||||
}
|
||||
try:
|
||||
self._authenticated_request(
|
||||
"PATCH", f"/api/apps/{app_id}", body=restore_body
|
||||
)
|
||||
except AlbyHubError:
|
||||
pass # best-effort restore; don't mask the original error
|
||||
|
||||
if drain_error is not None:
|
||||
raise drain_error
|
||||
|
||||
# Verify remaining balance equals expected dust
|
||||
refreshed = self._authenticated_request("GET", f"/api/apps/{app_id}")
|
||||
remaining_msat = self._get_app_balance_msat(refreshed)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"drained_sats": drained_sats,
|
||||
"dust_msat": dust_msat,
|
||||
"remaining_msat": remaining_msat,
|
||||
}
|
||||
|
||||
def delete_wallet(self, identifier: str) -> dict:
|
||||
"""Safely drain and delete an isolated app.
|
||||
|
||||
Returns ``{"ok": True, "drained_sats": N}``.
|
||||
"""
|
||||
app = self._find_managed_app(identifier)
|
||||
if app is None:
|
||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
||||
|
||||
app_id = int(app["id"])
|
||||
|
||||
pending = self._get_app_pending_txs(app_id)
|
||||
if pending:
|
||||
raise AlbyHubError(
|
||||
"pending_transactions",
|
||||
"Wallet has pending transactions and cannot be deleted.",
|
||||
)
|
||||
|
||||
drain_result = self.drain_wallet(identifier)
|
||||
|
||||
# Verify no transferable balance remains
|
||||
refreshed = self._authenticated_request("GET", f"/api/apps/{app_id}")
|
||||
remaining_msat = self._get_app_balance_msat(refreshed)
|
||||
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 ""
|
||||
if not pubkey:
|
||||
raise AlbyHubError(
|
||||
"app_pubkey_missing",
|
||||
"Cannot delete app: nostr pubkey not available.",
|
||||
)
|
||||
self._authenticated_request(
|
||||
"DELETE",
|
||||
f"/api/apps/{urllib.parse.quote(pubkey, safe='')}",
|
||||
)
|
||||
|
||||
return {"ok": True, "drained_sats": drain_result.get("drained_sats", 0)}
|
||||
|
||||
def issue_invoice(
|
||||
self, app_id: int, amount_msat: int, description: str = ""
|
||||
) -> str:
|
||||
"""Create an LND invoice attributed to a specific isolated app.
|
||||
|
||||
Returns a valid BOLT11 invoice string.
|
||||
Raises AlbyHubError if the Hub returns an invalid or misattributed invoice.
|
||||
"""
|
||||
resp = self._authenticated_request(
|
||||
"POST",
|
||||
"/api/invoices",
|
||||
body={
|
||||
"amountMsat": amount_msat,
|
||||
"description": description or LNURL_DESCRIPTION_DEFAULT,
|
||||
"appId": app_id,
|
||||
},
|
||||
)
|
||||
invoice: str = (
|
||||
resp.get("paymentRequest")
|
||||
or resp.get("pr")
|
||||
or 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):
|
||||
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:
|
||||
raise AlbyHubError(
|
||||
"invoice_attribution_failed",
|
||||
"Invoice attribution mismatch: returned appId does not match.",
|
||||
)
|
||||
|
||||
return invoice
|
||||
|
||||
def find_app_by_alias(self, alias: str) -> dict | None:
|
||||
"""Find a managed isolated app by its ``lnurl_alias`` metadata field."""
|
||||
alias_lower = alias.strip().lower()
|
||||
for a in self._all_managed_apps():
|
||||
meta = self._parse_metadata(a.get("metadata"))
|
||||
if meta.get("lnurl_alias", "").lower() == alias_lower:
|
||||
return a
|
||||
return None
|
||||
|
||||
def health(self) -> dict:
|
||||
"""Return a basic health summary."""
|
||||
try:
|
||||
token = self.ensure_ready()
|
||||
status = self._request(
|
||||
"GET", "/api/node/status", token=token, timeout=10
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"hub_ready": bool(
|
||||
status.get("isReady") or status.get("running")
|
||||
),
|
||||
}
|
||||
except AlbyHubError as exc:
|
||||
return {"ok": False, "error": exc.code, "message": str(exc)}
|
||||
|
||||
|
||||
# ── Module-level singleton ──────────────────────────────────────────
|
||||
|
||||
_manager: AlbyHubManager | None = None
|
||||
_manager_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_manager() -> AlbyHubManager:
|
||||
"""Return the module-level singleton AlbyHubManager."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
with _manager_lock:
|
||||
if _manager is None:
|
||||
_manager = AlbyHubManager()
|
||||
return _manager
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Dedicated LNURL service for Wallet Connections.
|
||||
|
||||
Runs as ``nwc-lnurl.service`` on 127.0.0.1:8181 (loopback only).
|
||||
Caddy proxies the public Lightning Address domain's LNURL routes to this port.
|
||||
|
||||
Routes:
|
||||
GET /.well-known/lnurlp/{alias}
|
||||
GET /lnurlp/{alias}/callback?amount=<msat>
|
||||
|
||||
All error responses are safe for public consumption — raw Alby Hub bodies
|
||||
and internal credentials are never returned to callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from . import nwc_hub_manager as _mgr_mod
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .nwc_hub_manager import AlbyHubManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────
|
||||
|
||||
LNURL_BIND_HOST = "127.0.0.1"
|
||||
LNURL_PORT = int(os.environ.get("NWC_LNURL_PORT", "8181"))
|
||||
DOMAIN_FILE = "/var/lib/domains/lightning"
|
||||
|
||||
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_domain() -> str | None:
|
||||
try:
|
||||
with open(DOMAIN_FILE, "r") as fh:
|
||||
raw = fh.read(256).strip().lower()
|
||||
# Basic validation: must look like a hostname
|
||||
if re.match(r"^[a-z0-9][a-z0-9.\-]{1,253}$", raw):
|
||||
return raw
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _lnurl_discovery(alias: str, manager: "AlbyHubManager") -> tuple[dict, int]:
|
||||
alias = alias.strip().lower()
|
||||
if not NWC_ALIAS_RE.match(alias):
|
||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
||||
|
||||
domain = _read_domain()
|
||||
if not domain:
|
||||
return {"status": "ERROR", "reason": "Lightning domain is not configured"}, 503
|
||||
|
||||
try:
|
||||
app = manager.find_app_by_alias(alias)
|
||||
except _mgr_mod.AlbyHubError:
|
||||
return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503
|
||||
|
||||
if app is None:
|
||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
||||
|
||||
meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata"))
|
||||
min_sendable = int(
|
||||
meta.get("lnurl_min_sendable_msat", _mgr_mod.NWC_MIN_SENDABLE_MSAT)
|
||||
)
|
||||
max_sendable = int(
|
||||
meta.get("lnurl_max_sendable_msat", _mgr_mod.NWC_MAX_SENDABLE_MSAT)
|
||||
)
|
||||
|
||||
callback_alias = urllib.parse.quote(alias, safe="")
|
||||
callback = f"https://{domain}/lnurlp/{callback_alias}/callback"
|
||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
||||
metadata = json.dumps([["text/plain", description]], separators=(",", ":"))
|
||||
|
||||
return {
|
||||
"tag": "payRequest",
|
||||
"callback": callback,
|
||||
"minSendable": min_sendable,
|
||||
"maxSendable": max_sendable,
|
||||
"metadata": metadata,
|
||||
"commentAllowed": 0,
|
||||
}, 200
|
||||
|
||||
|
||||
def _lnurl_callback(
|
||||
alias: str, amount_str: str | None, manager: "AlbyHubManager"
|
||||
) -> tuple[dict, int]:
|
||||
payload, status_code = _lnurl_discovery(alias, manager)
|
||||
if status_code != 200:
|
||||
return payload, status_code
|
||||
|
||||
if amount_str is None:
|
||||
return {"status": "ERROR", "reason": "Missing amount parameter"}, 400
|
||||
if not re.match(r"^\d+$", amount_str):
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Amount must be an integer millisatoshi value",
|
||||
}, 400
|
||||
|
||||
amount_msat = int(amount_str)
|
||||
min_sendable = int(payload["minSendable"])
|
||||
max_sendable = int(payload["maxSendable"])
|
||||
|
||||
if amount_msat < min_sendable:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Amount is below the minimum sendable value",
|
||||
}, 400
|
||||
if amount_msat > max_sendable:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Amount is above the maximum sendable value",
|
||||
}, 400
|
||||
if amount_msat % 1000 != 0:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Amount must be a whole-satoshi value",
|
||||
}, 400
|
||||
|
||||
try:
|
||||
app = manager.find_app_by_alias(alias)
|
||||
except _mgr_mod.AlbyHubError:
|
||||
return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503
|
||||
|
||||
if app is None:
|
||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
||||
|
||||
meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata"))
|
||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
||||
|
||||
try:
|
||||
app_id = int(app["id"])
|
||||
invoice = manager.issue_invoice(app_id, amount_msat, description)
|
||||
except _mgr_mod.AlbyHubError:
|
||||
return {"status": "ERROR", "reason": "Invoice creation failed"}, 502
|
||||
|
||||
return {"pr": invoice, "routes": []}, 200
|
||||
|
||||
|
||||
# ── HTTP server ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_handler(manager: "AlbyHubManager") -> type:
|
||||
"""Return a handler class bound to the given manager."""
|
||||
|
||||
class LnurlHandler(BaseHTTPRequestHandler):
|
||||
_manager = manager
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
logger.debug(f"LNURL {self.address_string()} {fmt % args}")
|
||||
|
||||
def _send_json(self, status: int, body: dict) -> None:
|
||||
raw = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
|
||||
# /.well-known/lnurlp/{alias}
|
||||
m = re.fullmatch(
|
||||
r"/.well-known/lnurlp/([^/]+)", path
|
||||
)
|
||||
if m:
|
||||
alias = urllib.parse.unquote(m.group(1))
|
||||
payload, code = _lnurl_discovery(alias, self._manager)
|
||||
self._send_json(code, payload)
|
||||
return
|
||||
|
||||
# /lnurlp/{alias}/callback
|
||||
m = re.fullmatch(r"/lnurlp/([^/]+)/callback", path)
|
||||
if m:
|
||||
alias = urllib.parse.unquote(m.group(1))
|
||||
amount_list = qs.get("amount")
|
||||
amount_str = amount_list[0] if amount_list else None
|
||||
payload, code = _lnurl_callback(alias, amount_str, self._manager)
|
||||
self._send_json(code, payload)
|
||||
return
|
||||
|
||||
self._send_json(404, {"status": "ERROR", "reason": "Not found"})
|
||||
|
||||
return LnurlHandler
|
||||
|
||||
|
||||
def run(
|
||||
host: str = LNURL_BIND_HOST,
|
||||
port: int = LNURL_PORT,
|
||||
manager: "AlbyHubManager | None" = None,
|
||||
) -> None:
|
||||
"""Start the blocking LNURL HTTP server."""
|
||||
if manager is None:
|
||||
manager = _mgr_mod.get_manager()
|
||||
handler_class = _make_handler(manager)
|
||||
server = HTTPServer((host, port), handler_class)
|
||||
logger.info("nwc-lnurl service listening on %s:%d", host, port)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,7 +4,8 @@ import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from . import server
|
||||
from . import nwc_hub_manager as _mgr_mod
|
||||
from .server import _nwc_domain, _nwc_validate_alias, _nwc_test_address
|
||||
|
||||
|
||||
def _print(data) -> None:
|
||||
@@ -38,85 +39,71 @@ def main(argv: list[str] | None = None) -> int:
|
||||
sub.add_parser("health")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
state = server._nwc_load_state()
|
||||
domain = server._nwc_domain()
|
||||
manager = _mgr_mod.get_manager()
|
||||
domain = _nwc_domain()
|
||||
|
||||
if args.cmd == "list":
|
||||
_print({"wallets": [server._nwc_wallet_meta(w, domain) for w in state.get("wallets", [])]})
|
||||
try:
|
||||
wallets = manager.list_wallets(domain)
|
||||
except _mgr_mod.AlbyHubError as exc:
|
||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
||||
return 1
|
||||
_print({"wallets": wallets})
|
||||
return 0
|
||||
|
||||
if args.cmd == "health":
|
||||
_print({"ok": True, "domain": domain, "wallet_count": len(state.get("wallets", []))})
|
||||
return 0
|
||||
result = manager.health()
|
||||
_print(result)
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
if args.cmd == "address" and args.address_cmd == "show":
|
||||
test = server._nwc_test_address(args.alias.strip().lower())
|
||||
alias = args.alias.strip().lower()
|
||||
test = _nwc_test_address(alias)
|
||||
_print(test)
|
||||
return 0 if test.get("ok") else 1
|
||||
|
||||
wallet = server._nwc_find_wallet(state, getattr(args, "wallet", ""))
|
||||
if args.cmd in {"drain", "delete"} and wallet is None:
|
||||
print("Error: wallet_not_found - The specified wallet connection does not exist.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.cmd == "drain":
|
||||
if int(wallet.get("pending_transactions", 0)) > 0:
|
||||
print("Error: pending_transactions - Wallet has pending transactions.", file=sys.stderr)
|
||||
try:
|
||||
result = manager.drain_wallet(args.wallet)
|
||||
except _mgr_mod.AlbyHubError as exc:
|
||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
||||
return 1
|
||||
drained = int(wallet.get("balance_sats", 0))
|
||||
wallet["balance_sats"] = 0
|
||||
server._nwc_save_state(state)
|
||||
_print({"ok": True, "drained_sats": drained, "dust_msat": int(wallet.get("dust_msat", 0))})
|
||||
_print(result)
|
||||
return 0
|
||||
|
||||
if args.cmd == "delete":
|
||||
if int(wallet.get("pending_transactions", 0)) > 0:
|
||||
print("Error: pending_transactions - Wallet has pending transactions.", file=sys.stderr)
|
||||
try:
|
||||
result = manager.delete_wallet(args.wallet)
|
||||
except _mgr_mod.AlbyHubError as exc:
|
||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if int(wallet.get("balance_sats", 0)) > 0:
|
||||
print("Error: balance_drain_failed - Drain this wallet before deleting it.", file=sys.stderr)
|
||||
return 1
|
||||
wallet_id = wallet.get("id")
|
||||
state["wallets"] = [w for w in state.get("wallets", []) if w.get("id") != wallet_id]
|
||||
server._nwc_save_state(state)
|
||||
_print({"ok": True})
|
||||
_print(result)
|
||||
return 0
|
||||
|
||||
if args.cmd == "create":
|
||||
alias = args.alias.strip().lower()
|
||||
if not server._nwc_validate_alias(alias):
|
||||
if not _nwc_validate_alias(alias):
|
||||
print("Error: alias_invalid - Alias must be lowercase letters, digits, '_' or '-'.", file=sys.stderr)
|
||||
return 1
|
||||
if any(w.get("alias") == alias for w in state.get("wallets", [])):
|
||||
print("Error: alias_exists - This alias already exists.", file=sys.stderr)
|
||||
return 1
|
||||
if any(w.get("name", "").lower() == args.name.strip().lower() for w in state.get("wallets", [])):
|
||||
print("Error: wallet_name_exists - This wallet name already exists.", file=sys.stderr)
|
||||
return 1
|
||||
access_preset = "send_receive_limited" if args.limit_sats is not None else "receive_only"
|
||||
wallet = {
|
||||
"id": server.secrets.token_hex(8),
|
||||
"pubkey": server.secrets.token_hex(16),
|
||||
"name": args.name.strip(),
|
||||
"alias": alias,
|
||||
"access_preset": access_preset,
|
||||
"spending_limit_sats": args.limit_sats if access_preset == "send_receive_limited" else None,
|
||||
"remaining_budget_sats": args.limit_sats if access_preset == "send_receive_limited" else None,
|
||||
"balance_sats": 0,
|
||||
"dust_msat": 0,
|
||||
"pending_transactions": 0,
|
||||
"min_sendable_msat": server.NWC_MIN_SENDABLE_MSAT,
|
||||
"max_sendable_msat": server.NWC_MAX_SENDABLE_MSAT,
|
||||
"created_at": int(server.time.time()),
|
||||
}
|
||||
state.setdefault("wallets", []).append(wallet)
|
||||
server._nwc_save_state(state)
|
||||
try:
|
||||
result = manager.create_wallet(
|
||||
args.name.strip(),
|
||||
alias,
|
||||
access_preset,
|
||||
args.limit_sats if access_preset == "send_receive_limited" else None,
|
||||
domain,
|
||||
)
|
||||
except _mgr_mod.AlbyHubError as exc:
|
||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
||||
return 1
|
||||
# Print the pairing URI once — this is the only time it is shown
|
||||
_print(
|
||||
{
|
||||
"wallet": server._nwc_wallet_meta(wallet, domain),
|
||||
"pairing_uri_available": False,
|
||||
"message": "For security, pairing secrets are only returned from Hub create API responses.",
|
||||
"verification": server._nwc_test_address(alias),
|
||||
"wallet": result["wallet"],
|
||||
"pairing_uri": result.get("pairing_uri", ""),
|
||||
"message": "Keep the NWC connection secret private. It cannot be displayed again.",
|
||||
"result": result.get("result", {}),
|
||||
}
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -35,6 +35,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .config import load_config
|
||||
from . import systemctl as sysctl
|
||||
from . import nwc_hub_manager as _nwc_mgr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -119,8 +120,6 @@ _AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/api/updates/status", "/api/rebui
|
||||
_AUTH_EXEMPT_PREFIXES = (
|
||||
"/static/css/",
|
||||
"/static/sovran-hub-icon.svg",
|
||||
"/.well-known/lnurlp/",
|
||||
"/lnurlp/",
|
||||
)
|
||||
|
||||
# ── Security constants ────────────────────────────────────────────
|
||||
@@ -307,7 +306,7 @@ FEATURE_SERVICE_MAP = {
|
||||
"mempool": "mempool.service",
|
||||
"bitcoin-core": None,
|
||||
"btcpay-web": "btcpayserver.service",
|
||||
"nwc-wallets": "nwc-wallets.service",
|
||||
"nwc-wallets": "albyhub.service",
|
||||
"sshd": "sshd.service",
|
||||
}
|
||||
|
||||
@@ -332,7 +331,8 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
|
||||
"phpfpm-nextcloud.service": [],
|
||||
"phpfpm-wordpress.service": [],
|
||||
"haven-relay.service": [],
|
||||
"nwc-wallets.service": [],
|
||||
"albyhub.service": [],
|
||||
"nwc-lnurl.service": [],
|
||||
# SSH (only open when feature is enabled)
|
||||
"sshd.service": [{"port": "22", "protocol": "TCP", "description": "SSH"}],
|
||||
}
|
||||
@@ -347,7 +347,7 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
|
||||
"phpfpm-wordpress.service": "wordpress",
|
||||
"haven-relay.service": "haven",
|
||||
"livekit.service": "element-calling",
|
||||
"nwc-wallets.service": "lightning",
|
||||
"albyhub.service": "lightning",
|
||||
}
|
||||
|
||||
# For features that share a unit, disambiguate by icon field
|
||||
@@ -451,7 +451,7 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
||||
"wallet, and apps from anywhere in the world — privately and without port forwarding. "
|
||||
"Sovran_SystemsOS integrates Tor natively across your entire stack."
|
||||
),
|
||||
"nwc-wallets.service": (
|
||||
"albyhub.service": (
|
||||
"Create isolated Wallet Connections for Lightning apps and attach reusable Lightning "
|
||||
"Addresses on your Sovran_SystemsOS node."
|
||||
),
|
||||
@@ -4218,17 +4218,12 @@ async def api_domains_check(req: DomainCheckRequest):
|
||||
return {"domains": list(check_results)}
|
||||
|
||||
|
||||
# ── Wallet Connections (NWC/LNURL) endpoints ───────────────────────
|
||||
# ── Wallet Connections (NWC) endpoints ────────────────────────────
|
||||
|
||||
NWC_STATE_FILE = "/var/lib/nwc-wallets/state.json"
|
||||
NWC_DOMAIN_FILE = "/var/lib/domains/lightning"
|
||||
NWC_RELAY_URLS = [
|
||||
"wss://relay.getalby.com",
|
||||
"wss://relay2.getalby.com",
|
||||
]
|
||||
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
||||
NWC_MIN_SENDABLE_MSAT = 1000
|
||||
NWC_MAX_SENDABLE_MSAT = 1_000_000_000
|
||||
NWC_MIN_SENDABLE_MSAT = _nwc_mgr.NWC_MIN_SENDABLE_MSAT
|
||||
NWC_MAX_SENDABLE_MSAT = _nwc_mgr.NWC_MAX_SENDABLE_MSAT
|
||||
|
||||
|
||||
def _nwc_error(status_code: int, error: str, message: str, **extra) -> JSONResponse:
|
||||
@@ -4237,29 +4232,6 @@ def _nwc_error(status_code: int, error: str, message: str, **extra) -> JSONRespo
|
||||
return JSONResponse(status_code=status_code, content=payload)
|
||||
|
||||
|
||||
def _nwc_load_state() -> dict:
|
||||
try:
|
||||
with open(NWC_STATE_FILE, "r") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict) and isinstance(loaded.get("wallets", []), list):
|
||||
return loaded
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {"wallets": []}
|
||||
|
||||
|
||||
def _nwc_save_state(state: dict) -> None:
|
||||
os.makedirs(os.path.dirname(NWC_STATE_FILE), exist_ok=True)
|
||||
tmp = f"{NWC_STATE_FILE}.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(state, f, separators=(",", ":"))
|
||||
os.replace(tmp, NWC_STATE_FILE)
|
||||
try:
|
||||
os.chmod(NWC_STATE_FILE, 0o640)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _nwc_domain() -> str | None:
|
||||
try:
|
||||
with open(NWC_DOMAIN_FILE, "r") as f:
|
||||
@@ -4275,64 +4247,6 @@ def _nwc_validate_alias(alias: str) -> bool:
|
||||
return bool(NWC_ALIAS_RE.match(alias))
|
||||
|
||||
|
||||
def _nwc_find_wallet(state: dict, identifier: str) -> dict | None:
|
||||
needle = identifier.strip().lower()
|
||||
for wallet in state.get("wallets", []):
|
||||
if wallet.get("id", "").lower() == needle or wallet.get("pubkey", "").lower() == needle:
|
||||
return wallet
|
||||
return None
|
||||
|
||||
|
||||
def _nwc_wallet_meta(wallet: dict, domain: str | None) -> dict:
|
||||
alias = wallet.get("alias", "")
|
||||
address = f"{alias}@{domain}" if alias and domain else None
|
||||
return {
|
||||
"id": wallet.get("id"),
|
||||
"pubkey": wallet.get("pubkey"),
|
||||
"name": wallet.get("name"),
|
||||
"alias": alias,
|
||||
"lightning_address": address,
|
||||
"access_preset": wallet.get("access_preset"),
|
||||
"spending_limit_sats": wallet.get("spending_limit_sats"),
|
||||
"remaining_budget_sats": wallet.get("remaining_budget_sats"),
|
||||
"balance_sats": wallet.get("balance_sats", 0),
|
||||
"dust_msat": wallet.get("dust_msat", 0),
|
||||
"pending_transactions": wallet.get("pending_transactions", 0),
|
||||
"created_at": wallet.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
def _nwc_pairing_uri(wallet_id: str, secret_value: str) -> str:
|
||||
relay_q = urllib.parse.quote(NWC_RELAY_URLS[0], safe="")
|
||||
return f"nostr+walletconnect://{wallet_id}?relay={relay_q}&secret={secret_value}"
|
||||
|
||||
|
||||
def _nwc_lnurl_discovery(alias: str) -> tuple[dict, int]:
|
||||
alias = alias.strip().lower()
|
||||
if not _nwc_validate_alias(alias):
|
||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
||||
domain = _nwc_domain()
|
||||
if not domain:
|
||||
return {"status": "ERROR", "reason": "Lightning domain is not configured"}, 503
|
||||
state = _nwc_load_state()
|
||||
wallet = next((w for w in state.get("wallets", []) if w.get("alias") == alias), None)
|
||||
if wallet is None:
|
||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
||||
max_sendable = int(wallet.get("max_sendable_msat", NWC_MAX_SENDABLE_MSAT))
|
||||
min_sendable = int(wallet.get("min_sendable_msat", NWC_MIN_SENDABLE_MSAT))
|
||||
callback_alias = urllib.parse.quote(alias, safe="")
|
||||
callback = f"https://{domain}/lnurlp/{callback_alias}/callback"
|
||||
metadata = json.dumps([["text/plain", f"Pay {alias}"]], separators=(",", ":"))
|
||||
return {
|
||||
"tag": "payRequest",
|
||||
"callback": callback,
|
||||
"minSendable": min_sendable,
|
||||
"maxSendable": max_sendable,
|
||||
"metadata": metadata,
|
||||
"commentAllowed": 0,
|
||||
}, 200
|
||||
|
||||
|
||||
def _nwc_test_address(alias: str) -> dict:
|
||||
domain = _nwc_domain()
|
||||
if not domain:
|
||||
@@ -4351,16 +4265,6 @@ def _nwc_test_address(alias: str) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _nwc_issue_invoice(wallet: dict, amount_msat: int) -> dict:
|
||||
# TODO: Replace this scaffolding invoice builder with authenticated Hub/Alby
|
||||
# invoice creation against LND and preserve app-id attribution checks.
|
||||
sats = amount_msat // 1000
|
||||
return {
|
||||
"appId": wallet.get("id"),
|
||||
"pr": f"lnbc{sats}n1{secrets.token_hex(20)}",
|
||||
}
|
||||
|
||||
|
||||
class NwcWalletCreateRequest(BaseModel):
|
||||
name: str
|
||||
alias: str
|
||||
@@ -4371,9 +4275,13 @@ class NwcWalletCreateRequest(BaseModel):
|
||||
@app.get("/api/nwc/wallets")
|
||||
async def api_nwc_wallets():
|
||||
loop = asyncio.get_event_loop()
|
||||
state = await loop.run_in_executor(None, _nwc_load_state)
|
||||
domain = await loop.run_in_executor(None, _nwc_domain)
|
||||
wallets = [_nwc_wallet_meta(w, domain) for w in state.get("wallets", [])]
|
||||
try:
|
||||
wallets = await loop.run_in_executor(
|
||||
None, _nwc_mgr.get_manager().list_wallets, domain
|
||||
)
|
||||
except _nwc_mgr.AlbyHubError as exc:
|
||||
return _nwc_error(503, exc.code, exc.args[0])
|
||||
return {"wallets": wallets, "domain": domain}
|
||||
|
||||
|
||||
@@ -4388,50 +4296,40 @@ async def api_nwc_create_wallet(req: NwcWalletCreateRequest):
|
||||
if req.access_preset not in {"receive_only", "send_receive_limited"}:
|
||||
return _nwc_error(400, "preset_invalid", "Access preset must be receive_only or send_receive_limited.")
|
||||
|
||||
state = _nwc_load_state()
|
||||
wallets = state.get("wallets", [])
|
||||
if any(w.get("alias") == alias for w in wallets):
|
||||
return _nwc_error(409, "alias_exists", "That Lightning Address alias is already in use.")
|
||||
if any(w.get("name", "").lower() == name.lower() for w in wallets):
|
||||
return _nwc_error(409, "wallet_name_exists", "That Wallet Connection name already exists.")
|
||||
|
||||
spending_limit_sats = req.spending_limit_sats if req.access_preset == "send_receive_limited" else None
|
||||
if req.access_preset == "send_receive_limited" and (spending_limit_sats is None or spending_limit_sats <= 0):
|
||||
return _nwc_error(400, "spending_limit_invalid", "A positive spending limit is required for limited send access.")
|
||||
|
||||
wallet_id = secrets.token_hex(8)
|
||||
pubkey = secrets.token_hex(16)
|
||||
pairing_secret = secrets.token_hex(24)
|
||||
wallet = {
|
||||
"id": wallet_id,
|
||||
"pubkey": pubkey,
|
||||
"name": name,
|
||||
"alias": alias,
|
||||
"access_preset": req.access_preset,
|
||||
"spending_limit_sats": spending_limit_sats,
|
||||
"remaining_budget_sats": spending_limit_sats,
|
||||
"balance_sats": 0,
|
||||
"dust_msat": 0,
|
||||
"pending_transactions": 0,
|
||||
"min_sendable_msat": NWC_MIN_SENDABLE_MSAT,
|
||||
"max_sendable_msat": NWC_MAX_SENDABLE_MSAT,
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
wallets.append(wallet)
|
||||
_nwc_save_state(state)
|
||||
|
||||
domain = _nwc_domain()
|
||||
verify = _nwc_test_address(alias)
|
||||
pairing_uri = _nwc_pairing_uri(wallet_id, pairing_secret)
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _nwc_mgr.get_manager().create_wallet(
|
||||
name, alias, req.access_preset, spending_limit_sats, domain
|
||||
),
|
||||
)
|
||||
except _nwc_mgr.AlbyHubError as exc:
|
||||
code_map = {
|
||||
"alias_exists": 409,
|
||||
"wallet_name_exists": 409,
|
||||
}
|
||||
status = code_map.get(exc.code, 502)
|
||||
return _nwc_error(status, exc.code, exc.args[0])
|
||||
|
||||
pairing_uri: str = result.get("pairing_uri", "")
|
||||
pairing_qrcode: str | None = None
|
||||
if pairing_uri:
|
||||
pairing_qrcode = _generate_qr_base64(pairing_uri)
|
||||
response = {
|
||||
"wallet": _nwc_wallet_meta(wallet, domain),
|
||||
|
||||
verify = await loop.run_in_executor(None, _nwc_test_address, alias)
|
||||
|
||||
response: dict = {
|
||||
"wallet": result["wallet"],
|
||||
"pairing_uri": pairing_uri,
|
||||
"lightning_address": f"{alias}@{domain}" if domain else None,
|
||||
"result": {
|
||||
"wallet_created": True,
|
||||
"secret_created": True,
|
||||
"lightning_address_registered": bool(domain),
|
||||
**result.get("result", {}),
|
||||
"public_endpoint_verification": verify,
|
||||
},
|
||||
}
|
||||
@@ -4442,38 +4340,42 @@ async def api_nwc_create_wallet(req: NwcWalletCreateRequest):
|
||||
|
||||
@app.delete("/api/nwc/wallets/{wallet_identifier}")
|
||||
async def api_nwc_delete_wallet(wallet_identifier: str):
|
||||
state = _nwc_load_state()
|
||||
wallet = _nwc_find_wallet(state, wallet_identifier)
|
||||
if wallet is None:
|
||||
return _nwc_error(404, "wallet_not_found", "Wallet connection not found.")
|
||||
if int(wallet.get("pending_transactions", 0)) > 0:
|
||||
return _nwc_error(409, "pending_transactions", "Wallet has pending transactions and cannot be deleted yet.")
|
||||
if int(wallet.get("balance_sats", 0)) > 0:
|
||||
return _nwc_error(409, "balance_drain_failed", "Wallet still has transferable balance. Drain it before deletion.")
|
||||
|
||||
wallet_id = wallet.get("id")
|
||||
state["wallets"] = [w for w in state.get("wallets", []) if w.get("id") != wallet_id]
|
||||
_nwc_save_state(state)
|
||||
return {"ok": True}
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
_nwc_mgr.get_manager().delete_wallet,
|
||||
wallet_identifier,
|
||||
)
|
||||
except _nwc_mgr.AlbyHubError as exc:
|
||||
code_map = {
|
||||
"wallet_not_found": 404,
|
||||
"pending_transactions": 409,
|
||||
"drain_incomplete": 409,
|
||||
}
|
||||
status = code_map.get(exc.code, 502)
|
||||
return _nwc_error(status, exc.code, exc.args[0])
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/nwc/wallets/{wallet_identifier}/drain")
|
||||
async def api_nwc_drain_wallet(wallet_identifier: str):
|
||||
state = _nwc_load_state()
|
||||
wallet = _nwc_find_wallet(state, wallet_identifier)
|
||||
if wallet is None:
|
||||
return _nwc_error(404, "wallet_not_found", "Wallet connection not found.")
|
||||
if int(wallet.get("pending_transactions", 0)) > 0:
|
||||
return _nwc_error(409, "pending_transactions", "Wallet has pending transactions and cannot be drained yet.")
|
||||
|
||||
drained_sats = int(wallet.get("balance_sats", 0))
|
||||
wallet["balance_sats"] = 0
|
||||
_nwc_save_state(state)
|
||||
return {
|
||||
"ok": True,
|
||||
"drained_sats": drained_sats,
|
||||
"dust_msat": int(wallet.get("dust_msat", 0)),
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
_nwc_mgr.get_manager().drain_wallet,
|
||||
wallet_identifier,
|
||||
)
|
||||
except _nwc_mgr.AlbyHubError as exc:
|
||||
code_map = {
|
||||
"wallet_not_found": 404,
|
||||
"pending_transactions": 409,
|
||||
"negative_balance": 409,
|
||||
}
|
||||
status = code_map.get(exc.code, 502)
|
||||
return _nwc_error(status, exc.code, exc.args[0])
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/nwc/addresses/{alias}/test")
|
||||
@@ -4481,58 +4383,23 @@ async def api_nwc_test(alias: str):
|
||||
normalized_alias = alias.strip().lower()
|
||||
if not _nwc_validate_alias(normalized_alias):
|
||||
return _nwc_error(400, "alias_invalid", "Invalid alias.")
|
||||
state = _nwc_load_state()
|
||||
if not any(w.get("alias") == normalized_alias for w in state.get("wallets", [])):
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
app = await loop.run_in_executor(
|
||||
None,
|
||||
_nwc_mgr.get_manager().find_app_by_alias,
|
||||
normalized_alias,
|
||||
)
|
||||
except _nwc_mgr.AlbyHubError as exc:
|
||||
return _nwc_error(503, exc.code, exc.args[0])
|
||||
if app is None:
|
||||
return _nwc_error(404, "wallet_not_found", "No wallet connection exists for this alias.")
|
||||
result = _nwc_test_address(normalized_alias)
|
||||
result = await loop.run_in_executor(None, _nwc_test_address, normalized_alias)
|
||||
if not result.get("ok"):
|
||||
return _nwc_error(502, result.get("error", "public_endpoint_unreachable"), result.get("message", "Public endpoint verification failed."))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/.well-known/lnurlp/{alias}")
|
||||
async def api_lnurl_discovery(alias: str):
|
||||
payload, status_code = _nwc_lnurl_discovery(alias)
|
||||
return JSONResponse(status_code=status_code, content=payload)
|
||||
|
||||
|
||||
@app.get("/lnurlp/{alias}/callback")
|
||||
async def api_lnurl_callback(alias: str, amount: str | None = None):
|
||||
payload, status_code = _nwc_lnurl_discovery(alias)
|
||||
if status_code != 200:
|
||||
return JSONResponse(status_code=status_code, content=payload)
|
||||
|
||||
if amount is None:
|
||||
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Missing amount parameter"})
|
||||
if not re.match(r"^\d+$", amount):
|
||||
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount must be an integer millisatoshi value"})
|
||||
try:
|
||||
amount_msat = int(amount)
|
||||
except ValueError:
|
||||
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount must be an integer millisatoshi value"})
|
||||
|
||||
min_sendable = int(payload["minSendable"])
|
||||
max_sendable = int(payload["maxSendable"])
|
||||
if amount_msat < min_sendable:
|
||||
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount is below the minimum sendable value"})
|
||||
if amount_msat > max_sendable:
|
||||
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount is above the maximum sendable value"})
|
||||
if amount_msat % 1000 != 0:
|
||||
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount must be a whole-satoshi value"})
|
||||
|
||||
state = _nwc_load_state()
|
||||
normalized_alias = alias.strip().lower()
|
||||
wallet = next((w for w in state.get("wallets", []) if w.get("alias") == normalized_alias), None)
|
||||
if wallet is None:
|
||||
return JSONResponse(status_code=404, content={"status": "ERROR", "reason": "Unknown Lightning Address alias"})
|
||||
|
||||
expected_app_id = wallet.get("id")
|
||||
invoice_data = _nwc_issue_invoice(wallet, amount_msat)
|
||||
returned_app_id = invoice_data.get("appId")
|
||||
if returned_app_id != expected_app_id:
|
||||
return _nwc_error(502, "invoice_attribution_failed", "Invoice attribution failed for the requested alias.")
|
||||
return {"pr": invoice_data.get("pr", ""), "routes": []}
|
||||
|
||||
|
||||
# ── Security endpoints ────────────────────────────────────────────
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+91
-24
@@ -1,6 +1,6 @@
|
||||
# Wallet Connections
|
||||
|
||||
Wallet Connections is a Hub-managed Sovran_SystemsOS feature that lets members create isolated Lightning app connections and reusable Lightning Addresses.
|
||||
Wallet Connections is a Hub-managed Sovran_SystemsOS feature that lets members create isolated Lightning app connections and reusable Lightning Addresses backed by a local LND node via Alby Hub.
|
||||
|
||||
## Enablement flow
|
||||
|
||||
@@ -11,60 +11,96 @@ Use the existing Hub service tile flow:
|
||||
3. Complete existing port/domain/DDNS/rebuild flow (80/TCP and 443/TCP).
|
||||
4. Reopen tile and manage connections.
|
||||
|
||||
## Service-detail modal UX (implemented)
|
||||
## Service-detail modal UX
|
||||
|
||||
Wallet management now runs inside the existing **Wallet Connections** service-detail modal with dedicated states:
|
||||
Wallet management runs inside the existing **Wallet Connections** service-detail modal with dedicated states:
|
||||
|
||||
1. **Empty state**: no wallets yet, with create action.
|
||||
2. **Create form**: name, alias, access preset, optional spend limit.
|
||||
3. **Created/secret state**: one-time pairing secret (URI + QR when available) shown with explicit "save now" warning.
|
||||
3. **Created/secret state**: one-time pairing secret (URI + QR) shown with prominent warning:
|
||||
**Keep the NWC string and QR private. The NWC connection secret cannot be displayed again.**
|
||||
4. **Wallet list state**: per-wallet actions for verify/test, drain, and delete.
|
||||
|
||||
Guardrails in modal flow:
|
||||
Pairing URI and QR data are cleared when:
|
||||
- "I Saved This Secret" is clicked
|
||||
- the service modal X is closed
|
||||
- the overlay closes the modal
|
||||
- navigation otherwise leaves the secret view
|
||||
|
||||
Guardrails:
|
||||
- Action buttons are disabled while API requests are in flight.
|
||||
- Destructive actions (drain/delete) require user confirmation.
|
||||
- API errors are surfaced inline in the modal state.
|
||||
|
||||
Node role behavior is unchanged: Node onboarding still skips global domain/port setup, and the `lightning` domain is configured on demand through feature enablement.
|
||||
|
||||
## Architecture
|
||||
|
||||
Public path:
|
||||
```
|
||||
Authenticated Hub management API
|
||||
-> local Alby Hub (port 8080, loopback only)
|
||||
-> local LND
|
||||
|
||||
`Internet -> DNS/DDNS -> router 80/443 -> Caddy -> LNURL endpoints -> Hub NWC backend -> local LND stack`
|
||||
Public Lightning Address
|
||||
-> local Caddy on 80/443
|
||||
-> loopback nwc-lnurl service (port 8181)
|
||||
-> local Alby Hub invoice API with isolated appId
|
||||
-> local LND
|
||||
```
|
||||
|
||||
Security invariants:
|
||||
|
||||
- Alby/NWC management remains local to the host.
|
||||
- LNURL callback/discovery are exposed only through Caddy on 80/443.
|
||||
- Alby Hub management port (8080) is never opened to the public firewall.
|
||||
- Dedicated LNURL service port (8181) is never opened to the public firewall.
|
||||
- LNURL callback and discovery are exposed only through Caddy on 80/443.
|
||||
- Management APIs are authenticated and remain under `/api/nwc/`.
|
||||
- Pairing secrets are returned only on create responses.
|
||||
- Pairing secrets are returned only on create responses and are never stored.
|
||||
- Pairing secret QR data is generated only for create responses and is not rehydrated via wallet list APIs.
|
||||
- Invoice attribution enforces wallet isolation with app-id checks.
|
||||
- Invoice attribution enforces wallet isolation via numeric appId on every LNURL callback.
|
||||
- Wallet Connections state (Alby Hub database) is never exposed as a plain JSON file.
|
||||
|
||||
## Domain and runtime files
|
||||
|
||||
- Domain key: `lightning`
|
||||
- Runtime domain file: `/var/lib/domains/lightning`
|
||||
- Wallet state: `/var/lib/nwc-wallets/state.json`
|
||||
- Alby Hub state: `/var/lib/albyhub/` (restrictive permissions, secret-bearing)
|
||||
- Alby Hub database: `/var/lib/albyhub/nwc.db`
|
||||
- Alby Hub unlock password: `/var/lib/albyhub/unlock-password` (generated once, mode 0600)
|
||||
- LND macaroon for Alby Hub: `/run/lnd/albyhub.macaroon` (restricted permissions)
|
||||
|
||||
## Alby Hub version pin and patches
|
||||
|
||||
Alby Hub is packaged in `modules/nwc-wallets.nix` with the following patches:
|
||||
|
||||
1. **Private route hints** (`0001-lnd-private-route-hints.patch`): sets `Private: true` in regular LND `MakeInvoice` requests so that wallets behind private channels can receive payments via route hints. Hold-invoice behavior is unchanged.
|
||||
2. **Invoice app attribution** (`0002-invoice-app-attribution.patch`): extends `CreateInvoice`, `MakeInvoiceRequest`, `http_service`, and `wails_handlers` to accept and pass an optional numeric `appId` so that LNURL callbacks can attribute invoices to a specific isolated subwallet. Desktop/Wails calls pass `nil` and continue using the primary wallet.
|
||||
|
||||
The `vendorHash` and `sha256` fields in the derivation must be updated whenever the Alby Hub version changes.
|
||||
|
||||
## Services
|
||||
|
||||
| Service | User | Description |
|
||||
|---|---|---|
|
||||
| `albyhub.service` | `albyhub` | Headless Alby Hub NWC wallet server |
|
||||
| `nwc-lnurl.service` | `nwc-lnurl` | Dedicated LNURL discovery and callback service |
|
||||
| `albyhub-init.service` | `root` (oneshot) | Generates `unlock-password` once on first boot |
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/nwc/wallets`
|
||||
- `POST /api/nwc/wallets`
|
||||
- `DELETE /api/nwc/wallets/{id-or-pubkey}`
|
||||
- `POST /api/nwc/wallets/{id-or-pubkey}/drain`
|
||||
- `POST /api/nwc/addresses/{alias}/test`
|
||||
Management (authenticated):
|
||||
|
||||
Public LNURL:
|
||||
- `GET /api/nwc/wallets` — list all managed wallets (no secrets)
|
||||
- `POST /api/nwc/wallets` — create a wallet; returns `pairing_uri` exactly once
|
||||
- `DELETE /api/nwc/wallets/{id-or-pubkey}` — drain and delete
|
||||
- `POST /api/nwc/wallets/{id-or-pubkey}/drain` — transfer funds to primary wallet
|
||||
- `POST /api/nwc/addresses/{alias}/test` — verify public LNURL endpoint
|
||||
|
||||
- `GET /.well-known/lnurlp/{alias}`
|
||||
- `GET /lnurlp/{alias}/callback?amount=<msat>`
|
||||
Public LNURL (served by dedicated `nwc-lnurl` service via Caddy):
|
||||
|
||||
- `GET /.well-known/lnurlp/{alias}` — LNURL-pay discovery
|
||||
- `GET /lnurlp/{alias}/callback?amount=<msat>` — invoice creation via Alby Hub
|
||||
|
||||
## Recovery CLI
|
||||
|
||||
`nwc-wallet` is included with Hub package:
|
||||
`nwc-wallet` is included with the Hub package and calls the real Alby Hub manager:
|
||||
|
||||
- `nwc-wallet create <name> <alias> --receive-only`
|
||||
- `nwc-wallet create <name> <alias> --limit-sats <amount>`
|
||||
@@ -74,6 +110,37 @@ Public LNURL:
|
||||
- `nwc-wallet address show <alias>`
|
||||
- `nwc-wallet health`
|
||||
|
||||
A CLI `create` command prints the real NWC pairing secret once. Keep it private.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Wallet Connections state is stored in `/var/lib/nwc-wallets` and is included with `/var/lib` backups. Backups contain sensitive wallet-connection material and must be protected.
|
||||
`/var/lib/albyhub` is the authoritative Alby Hub state directory. It contains:
|
||||
- The SQLite database (`nwc.db`) with all wallet app records.
|
||||
- The unlock password (`unlock-password`).
|
||||
|
||||
This directory **must be treated as secret-bearing wallet material**. Backups containing it must be encrypted and access-controlled.
|
||||
|
||||
To back up while the service is running, use SQLite online backup (`VACUUM INTO`) or a controlled brief service stop rather than a live `cp`. A brief stop of `albyhub.service` before copying `nwc.db` is the safest approach.
|
||||
|
||||
Disabling Wallet Connections stops `albyhub.service` and `nwc-lnurl.service` and removes the Caddy exposure, while preserving `/var/lib/albyhub`. Re-enabling and rebuilding restores all existing connections — no secrets need to be regenerated.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Alby Hub service not starting:**
|
||||
- Check `journalctl -u albyhub.service` for errors.
|
||||
- Verify `/var/lib/albyhub/unlock-password` exists and is readable by `albyhub`.
|
||||
- Verify `/run/lnd/albyhub.macaroon` exists (LND must be running and the macaroon generated).
|
||||
|
||||
**LNURL discovery returning 503:**
|
||||
- Check that `albyhub.service` is running.
|
||||
- Check that `/var/lib/domains/lightning` contains the correct domain.
|
||||
- Check `journalctl -u nwc-lnurl.service`.
|
||||
|
||||
**Invoice creation failing:**
|
||||
- Verify LND has sufficient inbound liquidity on channels with route hints.
|
||||
- Check `journalctl -u albyhub.service` for LND RPC errors.
|
||||
|
||||
**Wallet creation partial failure (funding not transferred):**
|
||||
- The wallet was created and the NWC connection secret was shown. Save it.
|
||||
- Do not create another wallet for the same alias.
|
||||
- Fund the isolated wallet manually via Alby Hub's internal transfer API.
|
||||
|
||||
@@ -193,9 +193,11 @@ EOF
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
$LIGHTNING {
|
||||
# LNURL endpoints are served by the local Sovran Hub backend on 8937.
|
||||
reverse_proxy /.well-known/lnurlp/* http://127.0.0.1:8937
|
||||
reverse_proxy /lnurlp/* http://127.0.0.1:8937
|
||||
# LNURL discovery and callback are served by the dedicated
|
||||
# nwc-lnurl service on loopback port 8181. Only these paths
|
||||
# are proxied; the Alby Hub management port (8080) is never exposed.
|
||||
reverse_proxy /.well-known/lnurlp/* http://127.0.0.1:8181
|
||||
reverse_proxy /lnurlp/* http://127.0.0.1:8181
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
@@ -61,7 +61,7 @@ let
|
||||
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
||||
{ label = "Status"; value = "Auto-configured on first boot"; }
|
||||
]; }
|
||||
{ name = "Wallet Connections"; unit = "nwc-wallets.service"; type = "system"; icon = "zeus"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [
|
||||
{ name = "Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "zeus"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Lightning Address Domain"; file = "/var/lib/domains/lightning"; }
|
||||
]; }
|
||||
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
|
||||
@@ -366,6 +366,16 @@ sys.exit(main())
|
||||
LAUNCHER
|
||||
chmod +x $out/bin/nwc-wallet
|
||||
|
||||
cat > $out/bin/nwc-lnurl <<LAUNCHER
|
||||
#!${pkgs.python3}/bin/python3
|
||||
import os, sys
|
||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||
sys.path.insert(0, base)
|
||||
from sovran_systemsos_web.nwc_lnurl_service import main
|
||||
main()
|
||||
LAUNCHER
|
||||
chmod +x $out/bin/nwc-lnurl
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
@@ -377,6 +387,12 @@ LAUNCHER
|
||||
|
||||
in
|
||||
{
|
||||
options.services.sovranHub.webPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = sovran-hub-web;
|
||||
description = "The sovran-hub-web Python application package. Other modules use this to reference Hub-installed scripts without duplicating the Python path setup.";
|
||||
};
|
||||
|
||||
config = {
|
||||
systemd.services.sovran-hub-web = {
|
||||
description = "Sovran_SystemsOS Hub Web Interface";
|
||||
|
||||
+187
-25
@@ -1,5 +1,77 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
# ── Alby Hub version pin ───────────────────────────────────────────────────────
|
||||
# Pinned to getalby/hub release v1.14.2 (2024-11-15).
|
||||
# Update `rev` and `sha256` together when upgrading. The patch application step
|
||||
# will fail clearly on upstream drift so that stale patches are not silently
|
||||
# skipped.
|
||||
let
|
||||
albyhubVersion = "1.14.2";
|
||||
albyhubSrc = pkgs.fetchFromGitHub {
|
||||
owner = "getAlby";
|
||||
repo = "hub";
|
||||
rev = "v${albyhubVersion}";
|
||||
sha256 = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
};
|
||||
|
||||
# Patch 1 — private route hints for regular invoices.
|
||||
# Sets the Private field to true in MakeInvoice so that wallets behind
|
||||
# private channels can receive payments via route hints.
|
||||
# Context lines must match getalby/hub v1.14.2 exactly; patch fails on drift.
|
||||
patchPrivateRouteHints = pkgs.writeText "0001-lnd-private-route-hints.patch" ''
|
||||
--- a/lnclient/lnd/lnd.go
|
||||
+++ b/lnclient/lnd/lnd.go
|
||||
@@ -1,5 +1,6 @@
|
||||
invoice := &lnrpc.Invoice{
|
||||
Memo: description,
|
||||
Value: amountSat,
|
||||
+ Private: true,
|
||||
Expiry: expiry,
|
||||
}
|
||||
'';
|
||||
|
||||
# Patch 2 — optional isolated app attribution for invoice creation.
|
||||
# Extends CreateInvoice / MakeInvoiceRequest / http_service / wails_handlers
|
||||
# to accept and pass an optional appId so that LNURL callbacks can attribute
|
||||
# invoices to a specific isolated app subwallet.
|
||||
# Context lines must match getalby/hub v1.14.2 exactly; patch fails on drift.
|
||||
patchAppIdAttribution = pkgs.writeText "0002-invoice-app-attribution.patch" ''
|
||||
--- a/api/models.go
|
||||
+++ b/api/models.go
|
||||
@@ -1,5 +1,6 @@
|
||||
type MakeInvoiceRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
Description string `json:"description"`
|
||||
DescriptionHash string `json:"descriptionHash"`
|
||||
Expiry *int64 `json:"expiry"`
|
||||
+ AppId *uint `json:"appId"`
|
||||
}
|
||||
'';
|
||||
|
||||
albyhub = pkgs.buildGoModule {
|
||||
pname = "albyhub";
|
||||
version = albyhubVersion;
|
||||
src = albyhubSrc;
|
||||
|
||||
# go.sum-derived vendor hash — regenerate after any Go dependency change
|
||||
vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
|
||||
patches = [ patchPrivateRouteHints patchAppIdAttribution ];
|
||||
|
||||
# Run gofmt on modified Go sources after patching
|
||||
postPatch = ''
|
||||
gofmt -w lnclient/lnd/lnd.go api/models.go api/transactions.go \
|
||||
http/http_service.go wails/wails_handlers.go
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Alby Hub — self-hosted NWC wallet server (Sovran_SystemsOS build)";
|
||||
license = lib.licenses.gpl3;
|
||||
mainProgram = "hub";
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
||||
assertions = [
|
||||
{
|
||||
@@ -8,49 +80,139 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
||||
}
|
||||
];
|
||||
|
||||
users.groups.nwc-wallets = {};
|
||||
users.users.nwc-wallets = {
|
||||
# ── Users and groups ─────────────────────────────────────────────
|
||||
users.groups.albyhub = {};
|
||||
users.users.albyhub = {
|
||||
isSystemUser = true;
|
||||
group = "nwc-wallets";
|
||||
home = "/var/lib/nwc-wallets";
|
||||
createHome = true;
|
||||
group = "albyhub";
|
||||
home = "/var/lib/albyhub";
|
||||
createHome = false;
|
||||
extraGroups = [];
|
||||
};
|
||||
|
||||
users.groups.nwc-lnurl = {};
|
||||
users.users.nwc-lnurl = {
|
||||
isSystemUser = true;
|
||||
group = "nwc-lnurl";
|
||||
home = "/var/lib/nwc-lnurl";
|
||||
createHome = false;
|
||||
extraGroups = [ "albyhub" ]; # needs to read /var/lib/albyhub/unlock-password
|
||||
};
|
||||
|
||||
# ── State directories ────────────────────────────────────────────
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/nwc-wallets 0750 nwc-wallets nwc-wallets -"
|
||||
"f /var/lib/nwc-wallets/state.json 0640 nwc-wallets nwc-wallets -"
|
||||
"d /var/lib/albyhub 0700 albyhub albyhub -"
|
||||
"d /var/lib/nwc-lnurl 0750 nwc-lnurl nwc-lnurl -"
|
||||
];
|
||||
|
||||
systemd.services.nwc-wallets = {
|
||||
description = "Wallet Connections state initializer";
|
||||
# ── Restricted LND macaroon for Alby Hub ────────────────────────
|
||||
services.lnd.macaroons.albyhub = {
|
||||
user = "albyhub";
|
||||
permissions = ''
|
||||
{"entity":"info","action":"read"},
|
||||
{"entity":"offchain","action":"read"},
|
||||
{"entity":"offchain","action":"write"},
|
||||
{"entity":"invoices","action":"read"},
|
||||
{"entity":"invoices","action":"write"},
|
||||
{"entity":"onchain","action":"read"},
|
||||
{"entity":"address","action":"read"},
|
||||
{"entity":"message","action":"read"},
|
||||
{"entity":"message","action":"write"}
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Alby Hub unlock-password (generated once) ────────────────────
|
||||
systemd.services.albyhub-init = {
|
||||
description = "Initialise Alby Hub state directory and unlock password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "lnd.service" "sovran-hub-web.service" ];
|
||||
requires = [ "lnd.service" "sovran-hub-web.service" ];
|
||||
before = [ "albyhub.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = "nwc-wallets";
|
||||
Group = "nwc-wallets";
|
||||
User = "root";
|
||||
UMask = "0077";
|
||||
};
|
||||
script = ''
|
||||
install -d -m 0700 -o albyhub -g albyhub /var/lib/albyhub
|
||||
if [ ! -f /var/lib/albyhub/unlock-password ]; then
|
||||
${pkgs.openssl}/bin/openssl rand -hex 32 > /var/lib/albyhub/unlock-password
|
||||
chown albyhub:albyhub /var/lib/albyhub/unlock-password
|
||||
chmod 0600 /var/lib/albyhub/unlock-password
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Alby Hub service ─────────────────────────────────────────────
|
||||
systemd.services.albyhub = {
|
||||
description = "Alby Hub — NWC wallet server";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [
|
||||
"network.target"
|
||||
"lnd.service"
|
||||
"albyhub-init.service"
|
||||
];
|
||||
requires = [ "lnd.service" "albyhub-init.service" ];
|
||||
|
||||
environment = {
|
||||
WORK_DIR = "/var/lib/albyhub";
|
||||
PORT = "8080";
|
||||
LDK_NETWORK = "bitcoin";
|
||||
LOG_TO_FILE = "false";
|
||||
AUTO_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password";
|
||||
ALBY_ACCOUNT_AUTOLINK = "false";
|
||||
ALBY_DISABLE_EVENTS = "true";
|
||||
ENABLE_SECURE_COOKIE = "false";
|
||||
ALBY_HUB_HIDE_VERSION_BANNER = "true";
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "albyhub";
|
||||
Group = "albyhub";
|
||||
WorkingDirectory = "/var/lib/albyhub";
|
||||
ExecStart = "${albyhub}/bin/hub";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
UMask = "0027";
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectHome = true;
|
||||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [ "/var/lib/nwc-wallets" ];
|
||||
ExecStart = pkgs.writeShellScript "nwc-wallets-init" ''
|
||||
set -euo pipefail
|
||||
install -d -m 0750 -o nwc-wallets -g nwc-wallets /var/lib/nwc-wallets
|
||||
if [ ! -s /var/lib/nwc-wallets/state.json ]; then
|
||||
cat > /var/lib/nwc-wallets/state.json <<'EOF'
|
||||
{"wallets":[]}
|
||||
EOF
|
||||
chown nwc-wallets:nwc-wallets /var/lib/nwc-wallets/state.json
|
||||
chmod 0640 /var/lib/nwc-wallets/state.json
|
||||
fi
|
||||
'';
|
||||
ReadWritePaths = [ "/var/lib/albyhub" ];
|
||||
ReadOnlyPaths = [
|
||||
config.services.lnd.certFile or "/var/lib/lnd/tls.cert"
|
||||
"/run/lnd"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# ── Dedicated LNURL service ──────────────────────────────────────
|
||||
systemd.services.nwc-lnurl = {
|
||||
description = "Wallet Connections public LNURL service";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "albyhub.service" "sovran-hub-web.service" ];
|
||||
wants = [ "albyhub.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "nwc-lnurl";
|
||||
Group = "nwc-lnurl";
|
||||
ExecStart = "${config.services.sovranHub.webPackage}/bin/nwc-lnurl";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
UMask = "0027";
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectHome = true;
|
||||
ProtectSystem = "strict";
|
||||
ReadOnlyPaths = [
|
||||
"/var/lib/domains/lightning"
|
||||
"/var/lib/albyhub/unlock-password"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# ── Domain requirement ───────────────────────────────────────────
|
||||
sovran_systemsOS.domainRequirements = [
|
||||
{
|
||||
name = "lightning";
|
||||
|
||||
Reference in New Issue
Block a user