Merge pull request #335 from naturallaw777/copilot/implement-hub-managed-wallet-connections
Add Hub-managed Wallet Connections (`nwc-wallets`) scaffold across Hub, Nix, domain, and LNURL paths
This commit is contained in:
@@ -786,6 +786,7 @@ sudo nixos-rebuild switch --rollback
|
|||||||
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features |
|
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features |
|
||||||
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
||||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||||
|
| `docs/wallet-connections.md` | Wallet Connections architecture, API, CLI, and security/operations notes |
|
||||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||||
| `assets/` | Repository documentation images |
|
| `assets/` | Repository documentation images |
|
||||||
| `custom.template.nix` | Template for local features and service overrides |
|
| `custom.template.nix` | Template for local features and service overrides |
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import server
|
||||||
|
|
||||||
|
|
||||||
|
def _print(data) -> None:
|
||||||
|
print(json.dumps(data, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="nwc-wallet")
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
create = sub.add_parser("create")
|
||||||
|
create.add_argument("name")
|
||||||
|
create.add_argument("alias")
|
||||||
|
preset_group = create.add_mutually_exclusive_group()
|
||||||
|
preset_group.add_argument("--receive-only", action="store_true")
|
||||||
|
preset_group.add_argument("--limit-sats", type=int)
|
||||||
|
|
||||||
|
sub.add_parser("list")
|
||||||
|
|
||||||
|
drain = sub.add_parser("drain")
|
||||||
|
drain.add_argument("wallet")
|
||||||
|
|
||||||
|
delete = sub.add_parser("delete")
|
||||||
|
delete.add_argument("wallet")
|
||||||
|
|
||||||
|
addr = sub.add_parser("address")
|
||||||
|
addr_sub = addr.add_subparsers(dest="address_cmd", required=True)
|
||||||
|
addr_show = addr_sub.add_parser("show")
|
||||||
|
addr_show.add_argument("alias")
|
||||||
|
|
||||||
|
sub.add_parser("health")
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
state = server._nwc_load_state()
|
||||||
|
domain = server._nwc_domain()
|
||||||
|
|
||||||
|
if args.cmd == "list":
|
||||||
|
_print({"wallets": [server._nwc_wallet_meta(w, domain) for w in state.get("wallets", [])]})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.cmd == "health":
|
||||||
|
_print({"ok": True, "domain": domain, "wallet_count": len(state.get("wallets", []))})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.cmd == "address" and args.address_cmd == "show":
|
||||||
|
test = server._nwc_test_address(args.alias.strip().lower())
|
||||||
|
_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)
|
||||||
|
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))})
|
||||||
|
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)
|
||||||
|
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})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.cmd == "create":
|
||||||
|
alias = args.alias.strip().lower()
|
||||||
|
if not server._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)
|
||||||
|
_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),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -87,7 +87,7 @@ SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
|
|||||||
# Domain keys that produce a public HTTPS virtual host via Caddy
|
# Domain keys that produce a public HTTPS virtual host via Caddy
|
||||||
_SERVICE_DOMAIN_KEYS = frozenset([
|
_SERVICE_DOMAIN_KEYS = frozenset([
|
||||||
"matrix", "wordpress", "nextcloud", "btcpayserver",
|
"matrix", "wordpress", "nextcloud", "btcpayserver",
|
||||||
"vaultwarden", "haven", "element-calling",
|
"vaultwarden", "haven", "element-calling", "lightning",
|
||||||
])
|
])
|
||||||
|
|
||||||
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
||||||
@@ -116,7 +116,12 @@ LOGIN_FAIL_MAX = 10 # max failures in window before extra delay
|
|||||||
# Public paths that are accessible without a valid session
|
# Public paths that are accessible without a valid session
|
||||||
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/api/updates/status", "/api/rebuild/status", "/auto-login", "/api/ping", "/api/reboot"}
|
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/api/updates/status", "/api/rebuild/status", "/auto-login", "/api/ping", "/api/reboot"}
|
||||||
# Prefixes for static assets required by the login page
|
# Prefixes for static assets required by the login page
|
||||||
_AUTH_EXEMPT_PREFIXES = ("/static/css/", "/static/sovran-hub-icon.svg")
|
_AUTH_EXEMPT_PREFIXES = (
|
||||||
|
"/static/css/",
|
||||||
|
"/static/sovran-hub-icon.svg",
|
||||||
|
"/.well-known/lnurlp/",
|
||||||
|
"/lnurlp/",
|
||||||
|
)
|
||||||
|
|
||||||
# ── Security constants ────────────────────────────────────────────
|
# ── Security constants ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -220,6 +225,21 @@ FEATURE_REGISTRY = [
|
|||||||
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
|
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "nwc-wallets",
|
||||||
|
"name": "Wallet Connections",
|
||||||
|
"description": "Connect apps to isolated wallets on your Lightning node and create reusable Lightning Addresses.",
|
||||||
|
"category": "bitcoin",
|
||||||
|
"needs_domain": True,
|
||||||
|
"domain_name": "lightning",
|
||||||
|
"needs_ddns": True,
|
||||||
|
"extra_fields": [],
|
||||||
|
"conflicts_with": [],
|
||||||
|
"port_requirements": [
|
||||||
|
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
|
||||||
|
{"port": "443", "protocol": "TCP", "description": "HTTPS"},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "mempool",
|
"id": "mempool",
|
||||||
"name": "Mempool Explorer",
|
"name": "Mempool Explorer",
|
||||||
@@ -287,6 +307,7 @@ FEATURE_SERVICE_MAP = {
|
|||||||
"mempool": "mempool.service",
|
"mempool": "mempool.service",
|
||||||
"bitcoin-core": None,
|
"bitcoin-core": None,
|
||||||
"btcpay-web": "btcpayserver.service",
|
"btcpay-web": "btcpayserver.service",
|
||||||
|
"nwc-wallets": "nwc-wallets.service",
|
||||||
"sshd": "sshd.service",
|
"sshd": "sshd.service",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +332,7 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
|
|||||||
"phpfpm-nextcloud.service": [],
|
"phpfpm-nextcloud.service": [],
|
||||||
"phpfpm-wordpress.service": [],
|
"phpfpm-wordpress.service": [],
|
||||||
"haven-relay.service": [],
|
"haven-relay.service": [],
|
||||||
|
"nwc-wallets.service": [],
|
||||||
# SSH (only open when feature is enabled)
|
# SSH (only open when feature is enabled)
|
||||||
"sshd.service": [{"port": "22", "protocol": "TCP", "description": "SSH"}],
|
"sshd.service": [{"port": "22", "protocol": "TCP", "description": "SSH"}],
|
||||||
}
|
}
|
||||||
@@ -325,6 +347,7 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
|
|||||||
"phpfpm-wordpress.service": "wordpress",
|
"phpfpm-wordpress.service": "wordpress",
|
||||||
"haven-relay.service": "haven",
|
"haven-relay.service": "haven",
|
||||||
"livekit.service": "element-calling",
|
"livekit.service": "element-calling",
|
||||||
|
"nwc-wallets.service": "lightning",
|
||||||
}
|
}
|
||||||
|
|
||||||
# For features that share a unit, disambiguate by icon field
|
# For features that share a unit, disambiguate by icon field
|
||||||
@@ -349,7 +372,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
|||||||
ROLE_FEATURES: dict[str, set[str] | None] = {
|
ROLE_FEATURES: dict[str, set[str] | None] = {
|
||||||
"server_plus_desktop": None,
|
"server_plus_desktop": None,
|
||||||
"desktop": {"rdp", "sshd"},
|
"desktop": {"rdp", "sshd"},
|
||||||
"node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "sshd"},
|
"node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "nwc-wallets", "sshd"},
|
||||||
}
|
}
|
||||||
|
|
||||||
SERVICE_DESCRIPTIONS: dict[str, str] = {
|
SERVICE_DESCRIPTIONS: dict[str, str] = {
|
||||||
@@ -428,6 +451,10 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"wallet, and apps from anywhere in the world — privately and without port forwarding. "
|
"wallet, and apps from anywhere in the world — privately and without port forwarding. "
|
||||||
"Sovran_SystemsOS integrates Tor natively across your entire stack."
|
"Sovran_SystemsOS integrates Tor natively across your entire stack."
|
||||||
),
|
),
|
||||||
|
"nwc-wallets.service": (
|
||||||
|
"Create isolated Wallet Connections for Lightning apps and attach reusable Lightning "
|
||||||
|
"Addresses on your Sovran_SystemsOS node."
|
||||||
|
),
|
||||||
"gnome-remote-desktop.service": (
|
"gnome-remote-desktop.service": (
|
||||||
"Access your server's full desktop environment from anywhere using any RDP client. "
|
"Access your server's full desktop environment from anywhere using any RDP client. "
|
||||||
"Manage your system visually without being physically present. "
|
"Manage your system visually without being physically present. "
|
||||||
@@ -4131,7 +4158,7 @@ async def api_domains_status():
|
|||||||
"""Return the value of each known domain file (or null if missing)."""
|
"""Return the value of each known domain file (or null if missing)."""
|
||||||
known = [
|
known = [
|
||||||
"matrix", "haven", "element-calling", "sslemail",
|
"matrix", "haven", "element-calling", "sslemail",
|
||||||
"vaultwarden", "btcpayserver", "nextcloud", "wordpress",
|
"vaultwarden", "btcpayserver", "nextcloud", "wordpress", "lightning",
|
||||||
]
|
]
|
||||||
domains: dict[str, str | None] = {}
|
domains: dict[str, str | None] = {}
|
||||||
for name in known:
|
for name in known:
|
||||||
@@ -4191,6 +4218,322 @@ async def api_domains_check(req: DomainCheckRequest):
|
|||||||
return {"domains": list(check_results)}
|
return {"domains": list(check_results)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wallet Connections (NWC/LNURL) 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
|
||||||
|
|
||||||
|
|
||||||
|
def _nwc_error(status_code: int, error: str, message: str, **extra) -> JSONResponse:
|
||||||
|
payload = {"error": error, "message": message}
|
||||||
|
payload.update(extra)
|
||||||
|
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:
|
||||||
|
domain = f.read(256).strip().lower()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not _validate_domain_value(domain):
|
||||||
|
return None
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
return {"ok": False, "error": "domain_not_configured", "message": "Lightning domain is not configured."}
|
||||||
|
url = f"https://{domain}/.well-known/lnurlp/{alias}"
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
|
if int(resp.status) >= 400:
|
||||||
|
return {"ok": False, "error": "public_endpoint_unreachable", "message": f"Public LNURL discovery endpoint returned HTTP {resp.status}."}
|
||||||
|
payload = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError):
|
||||||
|
return {"ok": False, "error": "public_endpoint_unreachable", "message": "Public LNURL endpoint verification failed."}
|
||||||
|
if payload.get("tag") != "payRequest":
|
||||||
|
return {"ok": False, "error": "public_endpoint_unreachable", "message": "Discovery endpoint returned an invalid LNURL response."}
|
||||||
|
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
|
||||||
|
access_preset: str
|
||||||
|
spending_limit_sats: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@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", [])]
|
||||||
|
return {"wallets": wallets, "domain": domain}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/nwc/wallets")
|
||||||
|
async def api_nwc_create_wallet(req: NwcWalletCreateRequest):
|
||||||
|
name = req.name.strip()
|
||||||
|
alias = req.alias.strip().lower()
|
||||||
|
if not name:
|
||||||
|
return _nwc_error(400, "wallet_name_invalid", "Wallet connection name is required.")
|
||||||
|
if not _nwc_validate_alias(alias):
|
||||||
|
return _nwc_error(400, "alias_invalid", "Alias must start with a letter or number and use only lowercase letters, digits, '_' or '-'.")
|
||||||
|
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),
|
||||||
|
"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),
|
||||||
|
"public_endpoint_verification": verify,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if pairing_qrcode:
|
||||||
|
response["pairing_qrcode"] = pairing_qrcode
|
||||||
|
return JSONResponse(status_code=201, content=response)
|
||||||
|
|
||||||
|
|
||||||
|
@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}
|
||||||
|
|
||||||
|
|
||||||
|
@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)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/nwc/addresses/{alias}/test")
|
||||||
|
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", [])):
|
||||||
|
return _nwc_error(404, "wallet_not_found", "No wallet connection exists for this alias.")
|
||||||
|
result = _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 ────────────────────────────────────────────
|
# ── Security endpoints ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -478,3 +478,46 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nwc-secret-warning {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-card-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-actions .matrix-form-back,
|
||||||
|
.nwc-wallet-actions .btn-close-modal {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,359 @@ function _attachCopyHandlers(container) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _nwcModalState = null;
|
||||||
|
|
||||||
|
function _nwcStateMessageHtml() {
|
||||||
|
if (!_nwcModalState || !_nwcModalState.message) return "";
|
||||||
|
var msgClass = _nwcModalState.messageKind === "success" ? "success" : "error";
|
||||||
|
return '<div class="matrix-form-result ' + msgClass + '">' + escHtml(_nwcModalState.message) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _nwcSetMessage(kind, text) {
|
||||||
|
if (!_nwcModalState) return;
|
||||||
|
_nwcModalState.messageKind = kind || "error";
|
||||||
|
_nwcModalState.message = text || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _nwcClearMessage() {
|
||||||
|
if (!_nwcModalState) return;
|
||||||
|
_nwcModalState.messageKind = "";
|
||||||
|
_nwcModalState.message = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _nwcWalletSectionEl() {
|
||||||
|
return document.getElementById("nwc-wallets-body");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _nwcRenderWalletState() {
|
||||||
|
var host = _nwcWalletSectionEl();
|
||||||
|
if (!host || !_nwcModalState) return;
|
||||||
|
var state = _nwcModalState;
|
||||||
|
var html = "";
|
||||||
|
html += _nwcStateMessageHtml();
|
||||||
|
|
||||||
|
if (state.view === "create") {
|
||||||
|
var selectedLimited = state.createForm.access_preset === "send_receive_limited";
|
||||||
|
html +=
|
||||||
|
'<p class="svc-detail-desc">Create a new isolated wallet connection for your app.</p>' +
|
||||||
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Connection Name</label>' +
|
||||||
|
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
|
||||||
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
|
||||||
|
'<input class="matrix-form-input" id="nwc-wallet-alias" type="text" placeholder="my-wallet" value="' + escHtml(state.createForm.alias || "") + '" autocomplete="off">' +
|
||||||
|
'<div class="creds-qr-hint">Lowercase letters, numbers, "_" and "-" only.</div></div>' +
|
||||||
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-preset">Access Preset</label>' +
|
||||||
|
'<select class="matrix-form-input" id="nwc-wallet-preset">' +
|
||||||
|
'<option value="receive_only"' + (state.createForm.access_preset === "receive_only" ? " selected" : "") + '>Receive only</option>' +
|
||||||
|
'<option value="send_receive_limited"' + (selectedLimited ? " selected" : "") + '>Send + receive (limited)</option>' +
|
||||||
|
'</select></div>' +
|
||||||
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-limit">Spending Limit (sats)</label>' +
|
||||||
|
'<input class="matrix-form-input" id="nwc-wallet-limit" type="number" min="1" step="1" placeholder="50000" value="' + escHtml(state.createForm.spending_limit_sats || "") + '"' + (selectedLimited ? "" : " disabled") + "></div>" +
|
||||||
|
'<div class="matrix-form-actions">' +
|
||||||
|
'<button class="matrix-form-back" id="nwc-create-cancel-btn"' + (state.busy ? " disabled" : "") + '>← Back</button>' +
|
||||||
|
'<button class="matrix-form-submit" id="nwc-create-submit-btn"' + (state.busy ? " disabled" : "") + '>' + (state.busy ? "Creating…" : "Create Wallet") + '</button>' +
|
||||||
|
'</div>';
|
||||||
|
host.innerHTML = html;
|
||||||
|
var presetSel = document.getElementById("nwc-wallet-preset");
|
||||||
|
if (presetSel) {
|
||||||
|
presetSel.addEventListener("change", function() {
|
||||||
|
state.createForm.access_preset = presetSel.value;
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var cancelBtn = document.getElementById("nwc-create-cancel-btn");
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener("click", function() {
|
||||||
|
if (state.busy) return;
|
||||||
|
state.view = state.wallets.length > 0 ? "list" : "empty";
|
||||||
|
_nwcClearMessage();
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var submitBtn = document.getElementById("nwc-create-submit-btn");
|
||||||
|
if (submitBtn) submitBtn.addEventListener("click", _nwcCreateWallet);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.view === "created" && state.lastCreated) {
|
||||||
|
var created = state.lastCreated;
|
||||||
|
var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8);
|
||||||
|
html += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>';
|
||||||
|
if (created.pairing_qrcode) {
|
||||||
|
html += '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Wallet Connections pairing secret"><div class="creds-qr-hint">Scan now in Zeus or copy the URI below.</div></div>';
|
||||||
|
}
|
||||||
|
html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' +
|
||||||
|
'<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>';
|
||||||
|
if (created.wallet && created.wallet.lightning_address) {
|
||||||
|
html += '<div class="creds-row"><div class="creds-label">Lightning Address</div>' +
|
||||||
|
'<div class="creds-value-wrap"><div class="creds-value">' + escHtml(created.wallet.lightning_address) + '</div></div></div>';
|
||||||
|
}
|
||||||
|
html += '<div class="matrix-form-actions">' +
|
||||||
|
'<button class="matrix-form-back" id="nwc-created-another-btn"' + (state.busy ? " disabled" : "") + '>Create Another Wallet</button>' +
|
||||||
|
'<button class="matrix-form-submit" id="nwc-created-continue-btn"' + (state.busy ? " disabled" : "") + '>I Saved This Secret</button>' +
|
||||||
|
'</div>';
|
||||||
|
host.innerHTML = html;
|
||||||
|
_attachCopyHandlers(host);
|
||||||
|
var continueBtn = document.getElementById("nwc-created-continue-btn");
|
||||||
|
if (continueBtn) {
|
||||||
|
continueBtn.addEventListener("click", async function() {
|
||||||
|
if (state.busy) return;
|
||||||
|
state.lastCreated = null;
|
||||||
|
state.view = "list";
|
||||||
|
await _nwcRefreshWallets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var anotherBtn = document.getElementById("nwc-created-another-btn");
|
||||||
|
if (anotherBtn) {
|
||||||
|
anotherBtn.addEventListener("click", function() {
|
||||||
|
if (state.busy) return;
|
||||||
|
state.view = "create";
|
||||||
|
_nwcClearMessage();
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="matrix-actions-row">' +
|
||||||
|
'<button class="matrix-action-btn" id="nwc-open-create-btn"' + (state.busy ? " disabled" : "") + '>➕ Create Wallet Connection</button>' +
|
||||||
|
'<button class="matrix-form-back" id="nwc-refresh-btn"' + (state.busy ? " disabled" : "") + '>Refresh</button>' +
|
||||||
|
'</div>';
|
||||||
|
if (state.domain) {
|
||||||
|
html += '<p class="svc-detail-desc">Lightning Address domain: <strong>' + escHtml(state.domain) + '</strong></p>';
|
||||||
|
} else {
|
||||||
|
html += '<p class="svc-detail-desc">Lightning Address domain is not configured yet. Configure your domain first, then create wallet connections.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.wallets || state.wallets.length === 0) {
|
||||||
|
html += '<p class="creds-empty">No wallet connections yet. Create your first wallet connection to generate a one-time pairing secret.</p>';
|
||||||
|
host.innerHTML = html;
|
||||||
|
var openCreateBtn = document.getElementById("nwc-open-create-btn");
|
||||||
|
if (openCreateBtn) {
|
||||||
|
openCreateBtn.addEventListener("click", function() {
|
||||||
|
if (state.busy) return;
|
||||||
|
_nwcClearMessage();
|
||||||
|
state.view = "create";
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var refreshBtnEmpty = document.getElementById("nwc-refresh-btn");
|
||||||
|
if (refreshBtnEmpty) refreshBtnEmpty.addEventListener("click", _nwcRefreshWallets);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="nwc-wallet-list">';
|
||||||
|
state.wallets.forEach(function(wallet) {
|
||||||
|
var id = wallet.id || wallet.pubkey || "";
|
||||||
|
var addressId = "nwc-wallet-addr-" + Math.random().toString(36).substring(2, 8);
|
||||||
|
html += '<div class="nwc-wallet-card">' +
|
||||||
|
'<div class="nwc-wallet-card-title">' + escHtml(wallet.name || "Wallet") + '</div>' +
|
||||||
|
'<div class="creds-row"><div class="creds-label">Alias</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(wallet.alias || "") + '</div></div></div>' +
|
||||||
|
(wallet.lightning_address
|
||||||
|
? '<div class="creds-row"><div class="creds-label">Lightning Address</div><div class="creds-value-wrap"><div class="creds-value" id="' + addressId + '">' + escHtml(wallet.lightning_address) + '</div><button class="creds-copy-btn" data-target="' + addressId + '">Copy</button></div></div>'
|
||||||
|
: "") +
|
||||||
|
'<div class="creds-row"><div class="creds-label">Balance</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(String(wallet.balance_sats || 0)) + ' sats</div></div></div>' +
|
||||||
|
'<div class="creds-row"><div class="creds-label">Pending TX</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(String(wallet.pending_transactions || 0)) + '</div></div></div>' +
|
||||||
|
'<div class="nwc-wallet-actions">' +
|
||||||
|
'<button class="matrix-form-back nwc-wallet-action-btn" data-action="test" data-wallet-alias="' + escHtml(wallet.alias || "") + '"' + (state.busy ? " disabled" : "") + '>Verify Address</button>' +
|
||||||
|
'<button class="matrix-form-back nwc-wallet-action-btn" data-action="drain" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Drain</button>' +
|
||||||
|
'<button class="btn btn-close-modal nwc-wallet-action-btn" data-action="delete" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Delete</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
html += "</div>";
|
||||||
|
host.innerHTML = html;
|
||||||
|
_attachCopyHandlers(host);
|
||||||
|
|
||||||
|
var createBtn = document.getElementById("nwc-open-create-btn");
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.addEventListener("click", function() {
|
||||||
|
if (state.busy) return;
|
||||||
|
_nwcClearMessage();
|
||||||
|
state.view = "create";
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshBtn = document.getElementById("nwc-refresh-btn");
|
||||||
|
if (refreshBtn) refreshBtn.addEventListener("click", _nwcRefreshWallets);
|
||||||
|
|
||||||
|
host.querySelectorAll(".nwc-wallet-action-btn").forEach(function(btn) {
|
||||||
|
btn.addEventListener("click", function() {
|
||||||
|
var action = btn.getAttribute("data-action");
|
||||||
|
if (action === "test") _nwcVerifyWallet(btn.getAttribute("data-wallet-alias"));
|
||||||
|
else if (action === "drain") _nwcDrainWallet(btn.getAttribute("data-wallet-id"));
|
||||||
|
else if (action === "delete") _nwcDeleteWallet(btn.getAttribute("data-wallet-id"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _nwcRefreshWallets() {
|
||||||
|
if (!_nwcModalState) return;
|
||||||
|
var host = _nwcWalletSectionEl();
|
||||||
|
if (host) host.innerHTML = '<p class="creds-loading">Loading wallet connections…</p>';
|
||||||
|
try {
|
||||||
|
var payload = await apiFetch("/api/nwc/wallets");
|
||||||
|
_nwcModalState.wallets = Array.isArray(payload.wallets) ? payload.wallets : [];
|
||||||
|
_nwcModalState.domain = payload.domain || null;
|
||||||
|
if (_nwcModalState.view !== "create" && _nwcModalState.view !== "created") {
|
||||||
|
_nwcModalState.view = _nwcModalState.wallets.length > 0 ? "list" : "empty";
|
||||||
|
}
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
} catch (err) {
|
||||||
|
_nwcSetMessage("error", (err && err.message) ? err.message : "Could not load wallet connections.");
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _nwcBusy(on) {
|
||||||
|
if (!_nwcModalState) return;
|
||||||
|
_nwcModalState.busy = !!on;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _nwcCreateWallet() {
|
||||||
|
if (!_nwcModalState || _nwcModalState.busy) return;
|
||||||
|
var nameEl = document.getElementById("nwc-wallet-name");
|
||||||
|
var aliasEl = document.getElementById("nwc-wallet-alias");
|
||||||
|
var presetEl = document.getElementById("nwc-wallet-preset");
|
||||||
|
var limitEl = document.getElementById("nwc-wallet-limit");
|
||||||
|
if (!nameEl || !aliasEl || !presetEl || !limitEl) return;
|
||||||
|
|
||||||
|
var name = (nameEl.value || "").trim();
|
||||||
|
var alias = (aliasEl.value || "").trim().toLowerCase();
|
||||||
|
var preset = presetEl.value || "receive_only";
|
||||||
|
var limitRaw = (limitEl.value || "").trim();
|
||||||
|
var limit = null;
|
||||||
|
|
||||||
|
_nwcModalState.createForm = {
|
||||||
|
name: name,
|
||||||
|
alias: alias,
|
||||||
|
access_preset: preset,
|
||||||
|
spending_limit_sats: limitRaw
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
_nwcSetMessage("error", "Wallet Connection name is required.");
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(alias)) {
|
||||||
|
_nwcSetMessage("error", 'Alias must start with a letter or number and use only lowercase letters, numbers, "_" or "-".');
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (preset === "send_receive_limited") {
|
||||||
|
limit = parseInt(limitRaw, 10);
|
||||||
|
if (!Number.isFinite(limit) || limit <= 0) {
|
||||||
|
_nwcSetMessage("error", "A positive spending limit is required for limited send access.");
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_nwcBusy(true);
|
||||||
|
_nwcClearMessage();
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
try {
|
||||||
|
var payload = await apiFetch("/api/nwc/wallets", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
alias: alias,
|
||||||
|
access_preset: preset,
|
||||||
|
spending_limit_sats: preset === "send_receive_limited" ? limit : null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
_nwcModalState.lastCreated = {
|
||||||
|
wallet: payload.wallet || null,
|
||||||
|
pairing_uri: payload.pairing_uri || "",
|
||||||
|
pairing_qrcode: payload.pairing_qrcode || "",
|
||||||
|
lightning_address: payload.lightning_address || null
|
||||||
|
};
|
||||||
|
_nwcModalState.view = "created";
|
||||||
|
_nwcSetMessage("success", "Wallet created. Save the one-time secret before you continue.");
|
||||||
|
_nwcBusy(false);
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
} catch (err) {
|
||||||
|
_nwcBusy(false);
|
||||||
|
_nwcSetMessage("error", (err && err.message) ? err.message : "Failed to create wallet connection.");
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _nwcVerifyWallet(alias) {
|
||||||
|
if (!_nwcModalState || _nwcModalState.busy || !alias) return;
|
||||||
|
_nwcBusy(true);
|
||||||
|
_nwcClearMessage();
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/nwc/addresses/" + encodeURIComponent(alias) + "/test", { method: "POST" });
|
||||||
|
_nwcSetMessage("success", "Lightning Address verification succeeded for " + alias + ".");
|
||||||
|
} catch (err) {
|
||||||
|
_nwcSetMessage("error", (err && err.message) ? err.message : "Lightning Address verification failed.");
|
||||||
|
}
|
||||||
|
_nwcBusy(false);
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _nwcDrainWallet(walletId) {
|
||||||
|
if (!_nwcModalState || _nwcModalState.busy || !walletId) return;
|
||||||
|
if (!window.confirm("Drain this wallet connection now? This cannot be undone.")) return;
|
||||||
|
_nwcBusy(true);
|
||||||
|
_nwcClearMessage();
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
try {
|
||||||
|
var resp = await apiFetch("/api/nwc/wallets/" + encodeURIComponent(walletId) + "/drain", { method: "POST" });
|
||||||
|
_nwcSetMessage("success", "Wallet drained (" + String(resp.drained_sats || 0) + " sats).");
|
||||||
|
_nwcBusy(false);
|
||||||
|
await _nwcRefreshWallets();
|
||||||
|
} catch (err) {
|
||||||
|
_nwcBusy(false);
|
||||||
|
_nwcSetMessage("error", (err && err.message) ? err.message : "Failed to drain wallet.");
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _nwcDeleteWallet(walletId) {
|
||||||
|
if (!_nwcModalState || _nwcModalState.busy || !walletId) return;
|
||||||
|
if (!window.confirm("Delete this wallet connection? This removes the wallet alias from Wallet Connections.")) return;
|
||||||
|
_nwcBusy(true);
|
||||||
|
_nwcClearMessage();
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/nwc/wallets/" + encodeURIComponent(walletId), { method: "DELETE" });
|
||||||
|
_nwcSetMessage("success", "Wallet connection deleted.");
|
||||||
|
_nwcBusy(false);
|
||||||
|
await _nwcRefreshWallets();
|
||||||
|
} catch (err) {
|
||||||
|
_nwcBusy(false);
|
||||||
|
_nwcSetMessage("error", (err && err.message) ? err.message : "Failed to delete wallet connection.");
|
||||||
|
_nwcRenderWalletState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _nwcInitWalletFlow(unit, name, icon) {
|
||||||
|
_nwcModalState = {
|
||||||
|
unit: unit,
|
||||||
|
name: name,
|
||||||
|
icon: icon,
|
||||||
|
view: "empty",
|
||||||
|
wallets: [],
|
||||||
|
domain: null,
|
||||||
|
busy: false,
|
||||||
|
message: "",
|
||||||
|
messageKind: "",
|
||||||
|
lastCreated: null,
|
||||||
|
createForm: {
|
||||||
|
name: "",
|
||||||
|
alias: "",
|
||||||
|
access_preset: "receive_only",
|
||||||
|
spending_limit_sats: ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await _nwcRefreshWallets();
|
||||||
|
}
|
||||||
|
|
||||||
async function openServiceDetailModal(unit, name, icon) {
|
async function openServiceDetailModal(unit, name, icon) {
|
||||||
if (!$credsModal) return;
|
if (!$credsModal) return;
|
||||||
if ($credsTitle) {
|
if ($credsTitle) {
|
||||||
@@ -242,7 +595,12 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Section E: Credentials & Links
|
// Section E: Credentials & Links
|
||||||
if (data.has_credentials && data.credentials && data.credentials.length > 0) {
|
if (unit === "nwc-wallets.service") {
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<div class="svc-detail-section-title">Wallet Connections</div>' +
|
||||||
|
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallet connections…</p></div>' +
|
||||||
|
'</div>';
|
||||||
|
} else if (data.has_credentials && data.credentials && data.credentials.length > 0) {
|
||||||
html += '<div class="svc-detail-section">' +
|
html += '<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
||||||
_renderCredsHtml(data.credentials, unit) +
|
_renderCredsHtml(data.credentials, unit) +
|
||||||
@@ -323,6 +681,9 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
|
|
||||||
$credsBody.innerHTML = html;
|
$credsBody.innerHTML = html;
|
||||||
_attachCopyHandlers($credsBody);
|
_attachCopyHandlers($credsBody);
|
||||||
|
if (unit === "nwc-wallets.service") {
|
||||||
|
await _nwcInitWalletFlow(unit, name, icon);
|
||||||
|
}
|
||||||
|
|
||||||
if (unit === "matrix-synapse.service") {
|
if (unit === "matrix-synapse.service") {
|
||||||
var addBtn = document.getElementById("matrix-add-user-btn");
|
var addBtn = document.getElementById("matrix-add-user-btn");
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_web_stubs():
|
||||||
|
if "fastapi" in sys.modules:
|
||||||
|
return
|
||||||
|
|
||||||
|
class _HTTPException(Exception):
|
||||||
|
def __init__(self, status_code=None, detail=None):
|
||||||
|
super().__init__(detail)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
class _FastAPI:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def mount(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_middleware(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __getattr__(self, _name):
|
||||||
|
def _decorator_factory(*args, **kwargs):
|
||||||
|
def _decorator(func):
|
||||||
|
return func
|
||||||
|
|
||||||
|
return _decorator
|
||||||
|
|
||||||
|
return _decorator_factory
|
||||||
|
|
||||||
|
class _BaseModel:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _StaticFiles:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _Jinja2Templates:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _BaseHTTPMiddleware:
|
||||||
|
pass
|
||||||
|
|
||||||
|
fastapi_module = types.ModuleType("fastapi")
|
||||||
|
fastapi_module.FastAPI = _FastAPI
|
||||||
|
fastapi_module.HTTPException = _HTTPException
|
||||||
|
sys.modules["fastapi"] = fastapi_module
|
||||||
|
|
||||||
|
responses_module = types.ModuleType("fastapi.responses")
|
||||||
|
responses_module.HTMLResponse = object
|
||||||
|
responses_module.RedirectResponse = object
|
||||||
|
|
||||||
|
class _JSONResponse:
|
||||||
|
def __init__(self, content=None, status_code=200):
|
||||||
|
self.content = content
|
||||||
|
self.status_code = status_code
|
||||||
|
self.body = json.dumps(content or {}).encode("utf-8")
|
||||||
|
|
||||||
|
responses_module.JSONResponse = _JSONResponse
|
||||||
|
sys.modules["fastapi.responses"] = responses_module
|
||||||
|
|
||||||
|
staticfiles_module = types.ModuleType("fastapi.staticfiles")
|
||||||
|
staticfiles_module.StaticFiles = _StaticFiles
|
||||||
|
sys.modules["fastapi.staticfiles"] = staticfiles_module
|
||||||
|
|
||||||
|
templating_module = types.ModuleType("fastapi.templating")
|
||||||
|
templating_module.Jinja2Templates = _Jinja2Templates
|
||||||
|
sys.modules["fastapi.templating"] = templating_module
|
||||||
|
|
||||||
|
requests_module = types.ModuleType("fastapi.requests")
|
||||||
|
requests_module.Request = object
|
||||||
|
sys.modules["fastapi.requests"] = requests_module
|
||||||
|
|
||||||
|
pydantic_module = types.ModuleType("pydantic")
|
||||||
|
pydantic_module.BaseModel = _BaseModel
|
||||||
|
sys.modules["pydantic"] = pydantic_module
|
||||||
|
|
||||||
|
starlette_base_module = types.ModuleType("starlette.middleware.base")
|
||||||
|
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
|
||||||
|
sys.modules["starlette.middleware.base"] = starlette_base_module
|
||||||
|
|
||||||
|
starlette_middleware_module = types.ModuleType("starlette.middleware")
|
||||||
|
starlette_middleware_module.base = starlette_base_module
|
||||||
|
sys.modules["starlette.middleware"] = starlette_middleware_module
|
||||||
|
|
||||||
|
starlette_module = types.ModuleType("starlette")
|
||||||
|
starlette_module.middleware = starlette_middleware_module
|
||||||
|
sys.modules["starlette"] = starlette_module
|
||||||
|
|
||||||
|
|
||||||
|
_install_web_stubs()
|
||||||
|
from sovran_systemsos_web import server
|
||||||
|
|
||||||
|
|
||||||
|
class FakeJSONResponse:
|
||||||
|
def __init__(self, content=None, status_code=200):
|
||||||
|
self.content = content
|
||||||
|
self.status_code = status_code
|
||||||
|
self.body = json.dumps(content or {}).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class WalletConnectionsRegistryTests(unittest.TestCase):
|
||||||
|
def test_node_role_feature_allow_list_includes_nwc_wallets(self):
|
||||||
|
self.assertIn("nwc-wallets", server.ROLE_FEATURES["node"])
|
||||||
|
self.assertNotIn("nwc-wallets", server.ROLE_FEATURES["desktop"])
|
||||||
|
|
||||||
|
def test_wallet_connections_feature_metadata(self):
|
||||||
|
feat = next(f for f in server.FEATURE_REGISTRY if f["id"] == "nwc-wallets")
|
||||||
|
self.assertEqual(feat["name"], "Wallet Connections")
|
||||||
|
self.assertTrue(feat["needs_domain"])
|
||||||
|
self.assertEqual(feat["domain_name"], "lightning")
|
||||||
|
self.assertEqual(
|
||||||
|
[(p["port"], p["protocol"]) for p in feat["port_requirements"]],
|
||||||
|
[("80", "TCP"), ("443", "TCP")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WalletConnectionsBehaviorTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_alias_validation_rules(self):
|
||||||
|
self.assertTrue(server._nwc_validate_alias("app_1"))
|
||||||
|
self.assertTrue(server._nwc_validate_alias("a-1"))
|
||||||
|
self.assertFalse(server._nwc_validate_alias("_bad"))
|
||||||
|
self.assertFalse(server._nwc_validate_alias("Upper"))
|
||||||
|
self.assertFalse(server._nwc_validate_alias("a" * 33))
|
||||||
|
|
||||||
|
async def test_pairing_uri_returned_only_on_create(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
state_file = Path(td) / "state.json"
|
||||||
|
domain_file = Path(td) / "lightning"
|
||||||
|
domain_file.write_text("pay.example.com\n")
|
||||||
|
with (
|
||||||
|
patch.object(server, "JSONResponse", FakeJSONResponse),
|
||||||
|
patch.object(server, "NWC_STATE_FILE", str(state_file)),
|
||||||
|
patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)),
|
||||||
|
patch.object(server, "_nwc_test_address", return_value={"ok": False, "error": "public_endpoint_unreachable"}),
|
||||||
|
patch.object(server, "_generate_qr_base64", return_value="data:image/png;base64,abc"),
|
||||||
|
):
|
||||||
|
req = types.SimpleNamespace(
|
||||||
|
name="My Wallet",
|
||||||
|
alias="my-wallet",
|
||||||
|
access_preset="receive_only",
|
||||||
|
spending_limit_sats=None,
|
||||||
|
)
|
||||||
|
create_resp = await server.api_nwc_create_wallet(req)
|
||||||
|
create_body = json.loads(create_resp.body.decode("utf-8"))
|
||||||
|
self.assertIn("pairing_uri", create_body)
|
||||||
|
self.assertEqual(create_body.get("pairing_qrcode"), "data:image/png;base64,abc")
|
||||||
|
|
||||||
|
list_resp = await server.api_nwc_wallets()
|
||||||
|
self.assertEqual(len(list_resp["wallets"]), 1)
|
||||||
|
self.assertNotIn("pairing_uri", list_resp["wallets"][0])
|
||||||
|
self.assertNotIn("pairing_qrcode", list_resp["wallets"][0])
|
||||||
|
|
||||||
|
async def test_create_reports_public_verification_success(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
state_file = Path(td) / "state.json"
|
||||||
|
domain_file = Path(td) / "lightning"
|
||||||
|
domain_file.write_text("pay.example.com\n")
|
||||||
|
with (
|
||||||
|
patch.object(server, "JSONResponse", FakeJSONResponse),
|
||||||
|
patch.object(server, "NWC_STATE_FILE", str(state_file)),
|
||||||
|
patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)),
|
||||||
|
patch.object(server, "_nwc_test_address", return_value={"ok": True}),
|
||||||
|
):
|
||||||
|
req = types.SimpleNamespace(
|
||||||
|
name="Wallet Success",
|
||||||
|
alias="wallet-success",
|
||||||
|
access_preset="receive_only",
|
||||||
|
spending_limit_sats=None,
|
||||||
|
)
|
||||||
|
create_resp = await server.api_nwc_create_wallet(req)
|
||||||
|
create_body = json.loads(create_resp.body.decode("utf-8"))
|
||||||
|
self.assertTrue(create_body["result"]["wallet_created"])
|
||||||
|
self.assertTrue(create_body["result"]["public_endpoint_verification"]["ok"])
|
||||||
|
|
||||||
|
async def test_lnurl_callback_rejects_appid_mismatch(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
state_file = Path(td) / "state.json"
|
||||||
|
domain_file = Path(td) / "lightning"
|
||||||
|
domain_file.write_text("pay.example.com\n")
|
||||||
|
state_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"wallets": [
|
||||||
|
{
|
||||||
|
"id": "wallet-1",
|
||||||
|
"pubkey": "pubkey-1",
|
||||||
|
"name": "Wallet 1",
|
||||||
|
"alias": "wallet1",
|
||||||
|
"access_preset": "receive_only",
|
||||||
|
"spending_limit_sats": None,
|
||||||
|
"remaining_budget_sats": None,
|
||||||
|
"balance_sats": 0,
|
||||||
|
"dust_msat": 0,
|
||||||
|
"pending_transactions": 0,
|
||||||
|
"min_sendable_msat": 1000,
|
||||||
|
"max_sendable_msat": 1000000,
|
||||||
|
"created_at": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(server, "JSONResponse", FakeJSONResponse),
|
||||||
|
patch.object(server, "NWC_STATE_FILE", str(state_file)),
|
||||||
|
patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)),
|
||||||
|
patch.object(server, "_nwc_issue_invoice", return_value={"appId": "wrong", "pr": "lnbc1..."}),
|
||||||
|
):
|
||||||
|
resp = await server.api_lnurl_callback("wallet1", amount="1000")
|
||||||
|
body = json.loads(resp.body.decode("utf-8"))
|
||||||
|
self.assertEqual(resp.status_code, 502)
|
||||||
|
self.assertEqual(body["error"], "invoice_attribution_failed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Wallet Connections
|
||||||
|
|
||||||
|
Wallet Connections is a Hub-managed Sovran_SystemsOS feature that lets members create isolated Lightning app connections and reusable Lightning Addresses.
|
||||||
|
|
||||||
|
## Enablement flow
|
||||||
|
|
||||||
|
Use the existing Hub service tile flow:
|
||||||
|
|
||||||
|
1. Open **Wallet Connections** in Bitcoin Apps.
|
||||||
|
2. Enable feature.
|
||||||
|
3. Complete existing port/domain/DDNS/rebuild flow (80/TCP and 443/TCP).
|
||||||
|
4. Reopen tile and manage connections.
|
||||||
|
|
||||||
|
## Service-detail modal UX (implemented)
|
||||||
|
|
||||||
|
Wallet management now 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.
|
||||||
|
4. **Wallet list state**: per-wallet actions for verify/test, drain, and delete.
|
||||||
|
|
||||||
|
Guardrails in modal flow:
|
||||||
|
|
||||||
|
- 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:
|
||||||
|
|
||||||
|
`Internet -> DNS/DDNS -> router 80/443 -> Caddy -> LNURL endpoints -> Hub NWC backend -> local LND stack`
|
||||||
|
|
||||||
|
Security invariants:
|
||||||
|
|
||||||
|
- Alby/NWC management remains local to the host.
|
||||||
|
- LNURL callback/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 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.
|
||||||
|
|
||||||
|
## Domain and runtime files
|
||||||
|
|
||||||
|
- Domain key: `lightning`
|
||||||
|
- Runtime domain file: `/var/lib/domains/lightning`
|
||||||
|
- Wallet state: `/var/lib/nwc-wallets/state.json`
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
Public LNURL:
|
||||||
|
|
||||||
|
- `GET /.well-known/lnurlp/{alias}`
|
||||||
|
- `GET /lnurlp/{alias}/callback?amount=<msat>`
|
||||||
|
|
||||||
|
## Recovery CLI
|
||||||
|
|
||||||
|
`nwc-wallet` is included with Hub package:
|
||||||
|
|
||||||
|
- `nwc-wallet create <name> <alias> --receive-only`
|
||||||
|
- `nwc-wallet create <name> <alias> --limit-sats <amount>`
|
||||||
|
- `nwc-wallet list`
|
||||||
|
- `nwc-wallet drain <wallet>`
|
||||||
|
- `nwc-wallet delete <wallet>`
|
||||||
|
- `nwc-wallet address show <alias>`
|
||||||
|
- `nwc-wallet health`
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -12,6 +12,7 @@ let
|
|||||||
|| config.sovran_systemsOS.services.nextcloud
|
|| config.sovran_systemsOS.services.nextcloud
|
||||||
|| config.sovran_systemsOS.services.vaultwarden
|
|| config.sovran_systemsOS.services.vaultwarden
|
||||||
|| config.sovran_systemsOS.features.haven
|
|| config.sovran_systemsOS.features.haven
|
||||||
|
|| config.sovran_systemsOS.features."nwc-wallets"
|
||||||
|| config.sovran_systemsOS.features.element-calling;
|
|| config.sovran_systemsOS.features.element-calling;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
@@ -70,6 +71,7 @@ in
|
|||||||
BTCPAY=$(read_domain btcpayserver)
|
BTCPAY=$(read_domain btcpayserver)
|
||||||
VAULTWARDEN=$(read_domain vaultwarden)
|
VAULTWARDEN=$(read_domain vaultwarden)
|
||||||
HAVEN=$(read_domain haven)
|
HAVEN=$(read_domain haven)
|
||||||
|
LIGHTNING=$(read_domain lightning)
|
||||||
ACME_EMAIL=$(read_domain sslemail)
|
ACME_EMAIL=$(read_domain sslemail)
|
||||||
|
|
||||||
# Start with global config — use ACME only when domain-based services are active
|
# Start with global config — use ACME only when domain-based services are active
|
||||||
@@ -186,6 +188,18 @@ $HAVEN {
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Wallet Connections LNURL ──────────────────────
|
||||||
|
if [ -n "$LIGHTNING" ]; then
|
||||||
|
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
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Sovran Hub (LAN access via mDNS) ────────────
|
# ── Sovran Hub (LAN access via mDNS) ────────────
|
||||||
cat >> /run/caddy/Caddyfile <<EOF
|
cat >> /run/caddy/Caddyfile <<EOF
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ let
|
|||||||
# NOTE: The hostname validation regex below must stay in sync with
|
# NOTE: The hostname validation regex below must stay in sync with
|
||||||
# _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py.
|
# _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py.
|
||||||
ENTRIES=""
|
ENTRIES=""
|
||||||
for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling; do
|
for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling lightning; do
|
||||||
FILE="$DOMAINS_DIR/$KEY"
|
FILE="$DOMAINS_DIR/$KEY"
|
||||||
[ -f "$FILE" ] || continue
|
[ -f "$FILE" ] || continue
|
||||||
# Read the domain value (strip all whitespace, limit to 253 chars)
|
# Read the domain value (strip all whitespace, limit to 253 chars)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
mempool = lib.mkForce false;
|
mempool = lib.mkForce false;
|
||||||
element-calling = lib.mkForce false;
|
element-calling = lib.mkForce false;
|
||||||
bitcoin-core = lib.mkForce false;
|
bitcoin-core = lib.mkForce false;
|
||||||
|
"nwc-wallets" = lib.mkForce false;
|
||||||
};
|
};
|
||||||
|
|
||||||
sovran_systemsOS.web.btcpayserver = lib.mkForce false;
|
sovran_systemsOS.web.btcpayserver = lib.mkForce false;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||||
|
"nwc-wallets" = lib.mkEnableOption "Wallet Connections";
|
||||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||||
sshd = lib.mkEnableOption "SSH remote access";
|
sshd = lib.mkEnableOption "SSH remote access";
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ let
|
|||||||
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
||||||
{ label = "Status"; value = "Auto-configured on first boot"; }
|
{ label = "Status"; value = "Auto-configured on first boot"; }
|
||||||
]; }
|
]; }
|
||||||
|
{ name = "Wallet Connections"; unit = "nwc-wallets.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 = [
|
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
|
||||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
|
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
|
||||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
|
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
|
||||||
@@ -353,6 +356,16 @@ uvicorn.run(
|
|||||||
LAUNCHER
|
LAUNCHER
|
||||||
chmod +x $out/bin/sovran-hub-web
|
chmod +x $out/bin/sovran-hub-web
|
||||||
|
|
||||||
|
cat > $out/bin/nwc-wallet <<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_wallet_cli import main
|
||||||
|
sys.exit(main())
|
||||||
|
LAUNCHER
|
||||||
|
chmod +x $out/bin/nwc-wallet
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||||
./haven.nix
|
./haven.nix
|
||||||
|
./nwc-wallets.nix
|
||||||
./element-calling.nix
|
./element-calling.nix
|
||||||
./mempool.nix
|
./mempool.nix
|
||||||
./bitcoin-core.nix
|
./bitcoin-core.nix
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = config.services.lnd.enable;
|
||||||
|
message = "Wallet Connections requires services.lnd.enable = true.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
users.groups.nwc-wallets = {};
|
||||||
|
users.users.nwc-wallets = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "nwc-wallets";
|
||||||
|
home = "/var/lib/nwc-wallets";
|
||||||
|
createHome = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.nwc-wallets = {
|
||||||
|
description = "Wallet Connections state initializer";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "lnd.service" "sovran-hub-web.service" ];
|
||||||
|
requires = [ "lnd.service" "sovran-hub-web.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
User = "nwc-wallets";
|
||||||
|
Group = "nwc-wallets";
|
||||||
|
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
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
sovran_systemsOS.domainRequirements = [
|
||||||
|
{
|
||||||
|
name = "lightning";
|
||||||
|
label = "Lightning Address Domain";
|
||||||
|
example = "pay.yourdomain.com";
|
||||||
|
needsDDNS = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user