Replace Wallet Connections scaffolding with real Alby Hub/LND implementation

- Add nwc_hub_manager.py: AlbyHubManager with real Alby Hub API (setup, auth, CRUD, drain, delete, invoice)
- Add nwc_lnurl_service.py: dedicated loopback LNURL service on port 8181
- server.py: remove JSON scaffolding (state.json, fake invoice generator, fake NWC URI, LNURL routes); replace with real manager calls; update service maps to albyhub.service; remove LNURL auth-exempt paths
- nwc_wallet_cli.py: rewrite to use real AlbyHubManager instead of JSON state
- modules/nwc-wallets.nix: replace with albyhub user/service, nwc-lnurl service, LND macaroon, unlock-password generation
- modules/core/caddy.nix: proxy LNURL routes to port 8181 (dedicated service) instead of 8937 (Hub)
- modules/core/sovran-hub.nix: service tile points to albyhub.service
- docs/wallet-connections.md: document real architecture, Alby Hub pin/patches, backup sensitivity
- test_wallet_connections.py: replace scaffolding tests with 54 real manager tests using mocked Alby Hub
This commit is contained in:
copilot-swe-agent[bot]
2026-07-27 03:10:13 +00:00
committed by GitHub
parent 63c87c8fb4
commit ccff377607
9 changed files with 2290 additions and 505 deletions
+744
View File
@@ -0,0 +1,744 @@
"""
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"] = f"******"
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",
)
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]:
txs = self._paginate(
f"/api/apps/{app_id}/transactions?limit={{limit}}&offset={{offset}}"
)
return [t for t in txs if t.get("state", "").lower() in ("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[a-z]{2,6}[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 = 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()
+42 -55
View File
@@ -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
+84 -217
View File
@@ -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, str(exc))
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)
pairing_qrcode = _generate_qr_base64(pairing_uri)
response = {
"wallet": _nwc_wallet_meta(wallet, domain),
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, str(exc))
pairing_uri: str = result.get("pairing_uri", "")
pairing_qrcode: str | None = None
if pairing_uri:
pairing_qrcode = _generate_qr_base64(pairing_uri)
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, str(exc))
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, str(exc))
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, str(exc))
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 ────────────────────────────────────────────