1617 lines
64 KiB
Python
1617 lines
64 KiB
Python
import asyncio
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import random
|
|
import secrets
|
|
import time
|
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
from typing import Any, cast
|
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
|
|
from bolt11 import decode as bolt11_decode
|
|
from coincurve import PrivateKey, PublicKey
|
|
from Cryptodome.Cipher import ChaCha20
|
|
from Cryptodome.Hash import HMAC, SHA256
|
|
from loguru import logger
|
|
from websockets import connect as ws_connect
|
|
|
|
from lnbits.settings import settings
|
|
from lnbits.utils.nostr import (
|
|
decrypt_content,
|
|
encrypt_content,
|
|
json_dumps,
|
|
sign_event,
|
|
verify_event,
|
|
)
|
|
|
|
from .base import (
|
|
InvoiceResponse,
|
|
PaymentResponse,
|
|
PaymentStatus,
|
|
StatusResponse,
|
|
Wallet,
|
|
)
|
|
|
|
|
|
class NWCError(Exception):
|
|
"""
|
|
An exception from NWC
|
|
"""
|
|
|
|
def __init__(self, code: str, message: str):
|
|
self.code = code
|
|
self.message = message
|
|
super().__init__(self.__str__())
|
|
|
|
def __str__(self):
|
|
return f"{self.code} {self.message}"
|
|
|
|
|
|
NWC_ENCRYPTION_NIP04 = "nip04"
|
|
NWC_ENCRYPTION_NIP44_V2 = "nip44_v2"
|
|
NWC_SUPPORTED_ENCRYPTIONS = [NWC_ENCRYPTION_NIP44_V2, NWC_ENCRYPTION_NIP04]
|
|
NWC_NOTIFICATION_KIND_NIP04 = 23196
|
|
NWC_NOTIFICATION_KIND_NIP44 = 23197
|
|
|
|
|
|
def _normalize_supported_encryptions(encryptions: list[str]) -> list[str]:
|
|
normalized = [enc for enc in encryptions if enc in NWC_SUPPORTED_ENCRYPTIONS]
|
|
return normalized or [NWC_ENCRYPTION_NIP04]
|
|
|
|
|
|
def _choose_preferred_encryption(encryptions: list[str]) -> str:
|
|
supported = set(_normalize_supported_encryptions(encryptions))
|
|
for encryption in NWC_SUPPORTED_ENCRYPTIONS:
|
|
if encryption in supported:
|
|
return encryption
|
|
return NWC_ENCRYPTION_NIP04
|
|
|
|
|
|
class NWCWallet(Wallet):
|
|
"""
|
|
A funding source that connects to a Nostr Wallet Connect (NWC) service provider.
|
|
https://nwc.dev/
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.shutdown = False
|
|
nwc_data = parse_nwc(settings.nwc_pairing_url)
|
|
self.conn = NWCConnection(
|
|
nwc_data["pubkey"],
|
|
nwc_data["secret"],
|
|
nwc_data["relay"],
|
|
notification_handler=self._handle_notification,
|
|
)
|
|
self.pending_invoice_details: dict[str, dict[str, Any]] = {}
|
|
self.payment_status_cache: dict[str, dict[str, Any]] = {}
|
|
self.payment_status_cache_pending_ttl = 30
|
|
self.payment_status_cache_terminal_ttl = 60 * 60 * 24
|
|
self.transactions_refresh_interval = 30
|
|
self.transactions_refresh_max_age = 60 * 60 * 24 * 15
|
|
self.transactions_refresh_max_pages = 20
|
|
self.transactions_refresh_lock = asyncio.Lock()
|
|
self.last_transactions_refresh_at: dict[bool, float] = {}
|
|
self.pending_invoices_maintenance_interval = 5
|
|
self.notification_lookup_schedule = [60, 120, 300, 600, 1200, 1800]
|
|
self.lookup_only_schedule = [15, 30, 60, 120, 300, 600, 1200, 1800]
|
|
self.pending_invoices_lookup_cooldown = 1.0
|
|
self.pending_invoices_reconcile_interval = 180
|
|
self.next_reconcile_at = 0.0
|
|
self.last_connection_generation = -1
|
|
self.paid_invoices_queue = asyncio.Queue(0)
|
|
|
|
def _is_shutting_down(self) -> bool:
|
|
"""
|
|
Returns True if the wallet is shutting down.
|
|
"""
|
|
return self.shutdown or not settings.lnbits_running
|
|
|
|
async def _handle_notification(self, notification: dict[str, Any]):
|
|
notification_type = notification.get("notification_type")
|
|
notification_payload = notification.get("notification") or {}
|
|
if not isinstance(notification_payload, dict):
|
|
logger.warning(
|
|
"Ignoring malformed NWC notification payload: "
|
|
+ str(notification_payload)
|
|
)
|
|
return
|
|
|
|
if notification_type == "payment_received":
|
|
checking_id = str(notification_payload.get("payment_hash") or "")
|
|
if checking_id:
|
|
logger.debug(
|
|
"Received NWC payment_received notification for " + checking_id
|
|
)
|
|
self._cache_payment_data(checking_id, notification_payload)
|
|
self._mark_invoice_settled(checking_id, source="notification")
|
|
elif notification_type == "payment_sent":
|
|
checking_id = str(notification_payload.get("payment_hash") or "")
|
|
if checking_id:
|
|
logger.debug(
|
|
"Received NWC payment_sent notification for " + checking_id
|
|
)
|
|
payment_data = dict(notification_payload)
|
|
payment_data.setdefault("state", "settled")
|
|
payment_data.setdefault("settled_at", int(time.time()))
|
|
self._cache_payment_data(checking_id, payment_data)
|
|
elif notification_type == "hold_invoice_accepted":
|
|
logger.debug(
|
|
"Received NWC hold_invoice_accepted notification for "
|
|
+ str(notification_payload.get("payment_hash") or "")
|
|
)
|
|
elif notification_type:
|
|
logger.debug(
|
|
"Ignoring unsupported NWC notification type " + notification_type
|
|
)
|
|
|
|
def _get_lookup_schedule(self) -> list[int]:
|
|
if self.conn.supports_notification_type("payment_received"):
|
|
return self.notification_lookup_schedule
|
|
return self.lookup_only_schedule
|
|
|
|
def _schedule_next_lookup(self, invoice: dict[str, Any], now: float | None = None):
|
|
now = now or time.time()
|
|
schedule = self._get_lookup_schedule()
|
|
attempt = int(invoice.get("lookup_attempts", 0))
|
|
delay = schedule[min(attempt, len(schedule) - 1)]
|
|
jitter = random.uniform(0, min(15, max(1, delay * 0.1))) # noqa: S311
|
|
invoice["next_lookup_at"] = now + delay + jitter
|
|
|
|
def _track_pending_invoice(
|
|
self, checking_id: str, created_at: int, expires_at: int
|
|
) -> None:
|
|
invoice = self.pending_invoice_details.get(checking_id, {})
|
|
invoice["checking_id"] = checking_id
|
|
invoice["created_at"] = created_at
|
|
invoice["expires_at"] = expires_at
|
|
invoice.setdefault("lookup_attempts", 0)
|
|
invoice.setdefault("last_lookup_at", 0.0)
|
|
self.pending_invoice_details[checking_id] = invoice
|
|
if checking_id not in self.pending_invoices:
|
|
self.pending_invoices.append(checking_id)
|
|
if "next_lookup_at" not in invoice:
|
|
self._schedule_next_lookup(invoice, created_at)
|
|
self.next_reconcile_at = 0.0
|
|
|
|
def _remove_pending_invoice(self, checking_id: str) -> bool:
|
|
self.pending_invoice_details.pop(checking_id, None)
|
|
if checking_id in self.pending_invoices:
|
|
self.pending_invoices.remove(checking_id)
|
|
return True
|
|
return False
|
|
|
|
def _mark_invoice_settled(self, checking_id: str, source: str):
|
|
was_pending = self._remove_pending_invoice(checking_id)
|
|
if was_pending:
|
|
logger.debug("Pending invoice " + checking_id + " settled via " + source)
|
|
self.paid_invoices_queue.put_nowait(checking_id)
|
|
|
|
def _expire_pending_invoices(self, now: float):
|
|
expired_ids: list[str] = []
|
|
for checking_id in list(self.pending_invoices):
|
|
invoice = self.pending_invoice_details.get(checking_id, {})
|
|
expires_at = int(invoice.get("expires_at", 0) or 0)
|
|
if expires_at and now > expires_at:
|
|
logger.warning("Pending invoice " + checking_id + " timed out")
|
|
expired_ids.append(checking_id)
|
|
for checking_id in expired_ids:
|
|
self._remove_pending_invoice(checking_id)
|
|
|
|
async def _should_run_reconciliation(self, now: float) -> bool:
|
|
if now < self.next_reconcile_at:
|
|
return False
|
|
await self.conn.get_info()
|
|
if not self.conn.supports_method("list_transactions"):
|
|
self.next_reconcile_at = now + self.pending_invoices_reconcile_interval
|
|
return False
|
|
return True
|
|
|
|
def _cache_ids(self, *extra_ids: str) -> set[str]:
|
|
ids = {checking_id for checking_id in self.pending_invoices if checking_id}
|
|
ids.update(checking_id for checking_id in extra_ids if checking_id)
|
|
return ids
|
|
|
|
async def _fetch_incoming_transactions(
|
|
self,
|
|
*,
|
|
from_ts: int,
|
|
now: float | None = None,
|
|
cache_ids: set[str] | None = None,
|
|
stop_when_found_ids: set[str] | None = None,
|
|
unpaid: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
now = now or time.time()
|
|
await self.conn.get_info()
|
|
if not self.conn.supports_method("list_transactions"):
|
|
return []
|
|
|
|
offset = 0
|
|
limit = 20
|
|
transactions: list[dict[str, Any]] = []
|
|
remaining_ids = {
|
|
checking_id for checking_id in (stop_when_found_ids or set()) if checking_id
|
|
}
|
|
|
|
while offset < limit * self.transactions_refresh_max_pages:
|
|
result = await self.conn.call(
|
|
"list_transactions",
|
|
{
|
|
"from": from_ts,
|
|
"until": int(now),
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"type": "incoming",
|
|
"unpaid": unpaid,
|
|
},
|
|
)
|
|
page = result.get("transactions", [])
|
|
tx_summary = [
|
|
{
|
|
"payment_hash": tx.get("payment_hash"),
|
|
"state": tx.get("state"),
|
|
"settled_at": tx.get("settled_at"),
|
|
"expires_at": tx.get("expires_at"),
|
|
}
|
|
for tx in page
|
|
if isinstance(tx, dict)
|
|
]
|
|
logger.debug(
|
|
"NWC list_transactions response. "
|
|
f"from={from_ts} until={int(now)} offset={offset} "
|
|
f"limit={limit} unpaid={unpaid} "
|
|
f"count={len(page) if isinstance(page, list) else 'malformed'} "
|
|
f"transactions={tx_summary} raw={result}"
|
|
)
|
|
if not isinstance(page, list) or not page:
|
|
break
|
|
|
|
for tx in page:
|
|
if not isinstance(tx, dict):
|
|
continue
|
|
checking_id = str(tx.get("payment_hash") or "")
|
|
if checking_id and (cache_ids is None or checking_id in cache_ids):
|
|
self._cache_payment_data(checking_id, tx, cached_at=now)
|
|
if checking_id:
|
|
remaining_ids.discard(checking_id)
|
|
transactions.append(tx)
|
|
|
|
if len(page) < limit:
|
|
break
|
|
if not remaining_ids and stop_when_found_ids:
|
|
break
|
|
offset += limit
|
|
|
|
return transactions
|
|
|
|
async def _reconcile_pending_invoices(self, now: float):
|
|
try:
|
|
await self.conn.get_info()
|
|
if not self.conn.supports_method("list_transactions"):
|
|
self.next_reconcile_at = now + self.pending_invoices_reconcile_interval
|
|
return
|
|
|
|
created_from = min(
|
|
int(
|
|
self.pending_invoice_details.get(checking_id, {}).get(
|
|
"created_at", now
|
|
)
|
|
)
|
|
for checking_id in self.pending_invoices
|
|
)
|
|
from_ts = max(0, created_from - 60)
|
|
matched = 0
|
|
pending_ids = self._cache_ids()
|
|
|
|
logger.debug(
|
|
"Reconciling pending NWC invoices with list_transactions. "
|
|
f"pending_count={len(self.pending_invoices)} from={from_ts}"
|
|
)
|
|
|
|
transactions = await self._fetch_incoming_transactions(
|
|
from_ts=from_ts,
|
|
now=now,
|
|
cache_ids=pending_ids,
|
|
stop_when_found_ids=pending_ids,
|
|
)
|
|
for tx in transactions:
|
|
checking_id = str(tx.get("payment_hash") or "")
|
|
if checking_id not in self.pending_invoices:
|
|
continue
|
|
if self._payment_data_is_settled(tx):
|
|
self._mark_invoice_settled(checking_id, source="reconciliation")
|
|
matched += 1
|
|
|
|
logger.debug(
|
|
"NWC reconciliation complete. "
|
|
f"matched={matched} remaining_pending={len(self.pending_invoices)}"
|
|
)
|
|
except Exception as e:
|
|
logger.error("Error reconciling pending NWC invoices: " + str(e))
|
|
finally:
|
|
self.next_reconcile_at = now + self.pending_invoices_reconcile_interval
|
|
|
|
async def _run_fallback_lookups(self, now: float):
|
|
await self.conn.get_info()
|
|
if not self.conn.supports_method("lookup_invoice"):
|
|
return
|
|
|
|
due_invoices = [
|
|
self.pending_invoice_details[checking_id]
|
|
for checking_id in self.pending_invoices
|
|
if checking_id in self.pending_invoice_details
|
|
and float(
|
|
self.pending_invoice_details[checking_id].get("next_lookup_at", 0.0)
|
|
or 0.0
|
|
)
|
|
<= now
|
|
]
|
|
due_invoices.sort(key=lambda invoice: float(invoice.get("next_lookup_at", 0.0)))
|
|
|
|
for index, invoice in enumerate(due_invoices):
|
|
checking_id = str(invoice["checking_id"])
|
|
if checking_id not in self.pending_invoices:
|
|
continue
|
|
try:
|
|
payment_data = await self.conn.call(
|
|
"lookup_invoice", {"payment_hash": checking_id}
|
|
)
|
|
self._cache_payment_data(checking_id, payment_data, cached_at=now)
|
|
invoice["last_lookup_at"] = now
|
|
invoice["lookup_attempts"] = int(invoice.get("lookup_attempts", 0)) + 1
|
|
if self._payment_data_is_settled(payment_data):
|
|
self._mark_invoice_settled(checking_id, source="lookup")
|
|
continue
|
|
self._schedule_next_lookup(invoice, now)
|
|
except NWCError as e:
|
|
logger.warning(
|
|
"Error handling pending invoice via lookup. "
|
|
f"checking_id={checking_id} code={e.code} message={e.message}"
|
|
)
|
|
invoice["lookup_attempts"] = int(invoice.get("lookup_attempts", 0)) + 1
|
|
if e.code == "RATE_LIMITED":
|
|
self.next_reconcile_at = max(
|
|
self.next_reconcile_at,
|
|
now + self.pending_invoices_reconcile_interval,
|
|
)
|
|
self._schedule_next_lookup(invoice, now)
|
|
except Exception as e:
|
|
logger.error("Error handling pending invoice: " + str(e))
|
|
invoice["lookup_attempts"] = int(invoice.get("lookup_attempts", 0)) + 1
|
|
self._schedule_next_lookup(invoice, now)
|
|
if (
|
|
index < len(due_invoices) - 1
|
|
and self.pending_invoices_lookup_cooldown > 0
|
|
and not self._is_shutting_down()
|
|
):
|
|
await asyncio.sleep(self.pending_invoices_lookup_cooldown)
|
|
|
|
async def _maintain_pending_invoices(self):
|
|
if not self.pending_invoices:
|
|
return
|
|
|
|
now = time.time()
|
|
if self.conn.connection_generation != self.last_connection_generation:
|
|
self.last_connection_generation = self.conn.connection_generation
|
|
self.next_reconcile_at = 0.0
|
|
|
|
self._expire_pending_invoices(now)
|
|
if not self.pending_invoices:
|
|
return
|
|
|
|
if await self._should_run_reconciliation(now):
|
|
await self._reconcile_pending_invoices(now)
|
|
|
|
await self._run_fallback_lookups(now)
|
|
self._prune_payment_status_cache(self._cache_ids())
|
|
|
|
def _payment_data_is_settled(self, payment_data: dict[str, Any]) -> bool:
|
|
state = payment_data.get("state")
|
|
settled_at = payment_data.get("settled_at")
|
|
preimage = payment_data.get("preimage")
|
|
if state == "settled":
|
|
return True
|
|
return bool(settled_at and int(settled_at) > 0 and preimage)
|
|
|
|
def _payment_data_is_failed(self, payment_data: dict[str, Any]) -> bool:
|
|
state = payment_data.get("state")
|
|
if state in {"expired", "failed"}:
|
|
return True
|
|
created_at = int(payment_data.get("created_at", time.time()))
|
|
expires_at = int(payment_data.get("expires_at", created_at + 3600))
|
|
return bool(
|
|
expires_at
|
|
and time.time() > expires_at
|
|
and not self._payment_data_is_settled(payment_data)
|
|
)
|
|
|
|
def _payment_data_to_status(self, payment_data: dict[str, Any]) -> PaymentStatus:
|
|
fee_msat = payment_data.get("fees_paid", None)
|
|
preimage = payment_data.get("preimage", None)
|
|
if self._payment_data_is_settled(payment_data):
|
|
return PaymentStatus(True, fee_msat=fee_msat, preimage=preimage)
|
|
if self._payment_data_is_failed(payment_data):
|
|
return PaymentStatus(False, fee_msat=fee_msat, preimage=preimage)
|
|
return PaymentStatus(None, fee_msat=fee_msat, preimage=preimage)
|
|
|
|
def _cache_payment_data(
|
|
self,
|
|
checking_id: str,
|
|
payment_data: dict[str, Any],
|
|
cached_at: float | None = None,
|
|
) -> None:
|
|
cached_at = cached_at or time.time()
|
|
ttl = (
|
|
self.payment_status_cache_terminal_ttl
|
|
if self._payment_data_is_settled(payment_data)
|
|
or self._payment_data_is_failed(payment_data)
|
|
else self.payment_status_cache_pending_ttl
|
|
)
|
|
self.payment_status_cache[checking_id] = {
|
|
"payment_data": dict(payment_data),
|
|
"expires_at": cached_at + ttl,
|
|
}
|
|
|
|
def _prune_payment_status_cache(self, keep_ids: set[str] | None = None) -> None:
|
|
now = time.time()
|
|
for checking_id in list(self.payment_status_cache.keys()):
|
|
cached = self.payment_status_cache.get(checking_id) or {}
|
|
expires_at = float(cached.get("expires_at", 0.0) or 0.0)
|
|
if expires_at <= now or (
|
|
keep_ids is not None and checking_id not in keep_ids
|
|
):
|
|
self.payment_status_cache.pop(checking_id, None)
|
|
|
|
def _get_cached_payment_data(self, checking_id: str) -> dict[str, Any] | None:
|
|
cached = self.payment_status_cache.get(checking_id)
|
|
if not cached:
|
|
return None
|
|
if float(cached.get("expires_at", 0.0) or 0.0) <= time.time():
|
|
self.payment_status_cache.pop(checking_id, None)
|
|
return None
|
|
payment_data = cached.get("payment_data")
|
|
if isinstance(payment_data, dict):
|
|
return payment_data
|
|
return None
|
|
|
|
async def _refresh_recent_incoming_transactions(
|
|
self,
|
|
*,
|
|
now: float | None = None,
|
|
from_ts: int | None = None,
|
|
cache_ids: set[str] | None = None,
|
|
stop_when_found_ids: set[str] | None = None,
|
|
unpaid: bool = True,
|
|
force: bool = False,
|
|
) -> None:
|
|
now = now or time.time()
|
|
last_refresh_at = self.last_transactions_refresh_at.get(unpaid, 0.0)
|
|
if not force and now - last_refresh_at < self.transactions_refresh_interval:
|
|
return
|
|
|
|
async with self.transactions_refresh_lock:
|
|
now = time.time()
|
|
last_refresh_at = self.last_transactions_refresh_at.get(unpaid, 0.0)
|
|
if not force and now - last_refresh_at < self.transactions_refresh_interval:
|
|
return
|
|
|
|
from_ts = from_ts or max(0, int(now - self.transactions_refresh_max_age))
|
|
|
|
logger.debug(
|
|
"Refreshing recent NWC incoming transactions cache. "
|
|
f"from={from_ts} max_pages={self.transactions_refresh_max_pages}"
|
|
)
|
|
|
|
await self._fetch_incoming_transactions(
|
|
from_ts=from_ts,
|
|
now=now,
|
|
cache_ids=cache_ids,
|
|
stop_when_found_ids=stop_when_found_ids,
|
|
unpaid=unpaid,
|
|
)
|
|
self.last_transactions_refresh_at[unpaid] = now
|
|
|
|
async def cleanup(self):
|
|
self.shutdown = True
|
|
await self.conn.close()
|
|
|
|
async def create_invoice(
|
|
self,
|
|
amount: int,
|
|
memo: str | None = None,
|
|
description_hash: bytes | None = None,
|
|
unhashed_description: bytes | None = None,
|
|
**_,
|
|
) -> InvoiceResponse:
|
|
desc = ""
|
|
desc_hash = None
|
|
if description_hash:
|
|
desc_hash = description_hash.hex()
|
|
desc = (unhashed_description or b"").decode()
|
|
elif unhashed_description:
|
|
desc = unhashed_description.decode()
|
|
desc_hash = hashlib.sha256(desc.encode()).hexdigest()
|
|
else:
|
|
desc = memo or ""
|
|
try:
|
|
await self.conn.get_info()
|
|
if not self.conn.supports_method("make_invoice"):
|
|
return InvoiceResponse(
|
|
ok=False,
|
|
error_message="make_invoice is not supported by this NWC service.",
|
|
)
|
|
resp = await self.conn.call(
|
|
"make_invoice",
|
|
{
|
|
"amount": int(amount * 1000), # nwc uses msats denominations
|
|
"description_hash": desc_hash,
|
|
"description": desc,
|
|
},
|
|
)
|
|
checking_id = str(resp["payment_hash"])
|
|
payment_request = resp.get("invoice", None)
|
|
created_at = int(resp.get("created_at", time.time()))
|
|
expires_at = int(resp.get("expires_at", created_at + 3600))
|
|
if (
|
|
self.conn.supports_method("lookup_invoice")
|
|
or self.conn.supports_method("list_transactions")
|
|
or self.conn.supports_notification_type("payment_received")
|
|
):
|
|
self._track_pending_invoice(checking_id, created_at, expires_at)
|
|
return InvoiceResponse(
|
|
ok=True, checking_id=checking_id, payment_request=payment_request
|
|
)
|
|
except Exception as e:
|
|
return InvoiceResponse(ok=False, error_message=str(e))
|
|
|
|
async def status(self) -> StatusResponse:
|
|
try:
|
|
await self.conn.get_info()
|
|
if not self.conn.supports_method("get_balance"):
|
|
logger.debug("get_balance is not supported by this NWC service.")
|
|
return StatusResponse(None, 0)
|
|
resp = await self.conn.call("get_balance", {})
|
|
balance = int(resp["balance"])
|
|
return StatusResponse(None, balance)
|
|
except Exception as e:
|
|
return StatusResponse(str(e), 0)
|
|
|
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
|
try:
|
|
resp = await self.conn.call("pay_invoice", {"invoice": bolt11})
|
|
preimage = resp.get("preimage", None)
|
|
invoice_data = bolt11_decode(bolt11)
|
|
payment_hash = invoice_data.payment_hash
|
|
# pay_invoice doesn't return payment data, so we need
|
|
# to call lookup_invoice too (if supported)
|
|
await self.conn.get_info()
|
|
|
|
if not self.conn.supports_method("lookup_invoice"):
|
|
# if not supported, we assume it succeeded
|
|
return PaymentResponse(
|
|
ok=True, checking_id=payment_hash, preimage=preimage, fee_msat=0
|
|
)
|
|
|
|
try:
|
|
payment_data = await self.conn.call(
|
|
"lookup_invoice", {"invoice": bolt11}
|
|
)
|
|
settled = payment_data.get("settled_at", None) and payment_data.get(
|
|
"preimage", None
|
|
)
|
|
if not settled:
|
|
return PaymentResponse(checking_id=payment_hash)
|
|
else:
|
|
fee_msat = payment_data.get("fees_paid", None)
|
|
return PaymentResponse(
|
|
ok=True,
|
|
checking_id=payment_hash,
|
|
fee_msat=fee_msat,
|
|
preimage=preimage,
|
|
)
|
|
except Exception:
|
|
# Workaround: some nwc service providers might not store the invoice
|
|
# right away, so this call may raise an exception.
|
|
# We will assume the payment is pending anyway
|
|
return PaymentResponse(checking_id=payment_hash)
|
|
except NWCError as e:
|
|
logger.error("Error paying invoice: " + str(e))
|
|
failure_codes = [
|
|
"RATE_LIMITED",
|
|
"NOT_IMPLEMENTED",
|
|
"INSUFFICIENT_BALANCE",
|
|
"QUOTA_EXCEEDED",
|
|
"RESTRICTED",
|
|
"UNAUTHORIZED",
|
|
"INTERNAL",
|
|
"OTHER",
|
|
"PAYMENT_FAILED",
|
|
]
|
|
failed = e.code in failure_codes
|
|
return PaymentResponse(
|
|
ok=None if not failed else False,
|
|
error_message=e.message if failed else None,
|
|
)
|
|
except Exception as e:
|
|
msg = "Error paying invoice: " + str(e)
|
|
logger.error(msg)
|
|
# assume pending
|
|
return PaymentResponse(error_message=msg)
|
|
|
|
async def _get_status_via_transactions(
|
|
self, checking_id: str, unpaid_filters: list[bool]
|
|
) -> PaymentStatus | None:
|
|
keep_ids = self._cache_ids(checking_id)
|
|
self._prune_payment_status_cache()
|
|
payment_data = self._get_cached_payment_data(checking_id)
|
|
if payment_data:
|
|
return self._payment_data_to_status(payment_data)
|
|
|
|
if self.conn.supports_method("list_transactions"):
|
|
invoice_details = self.pending_invoice_details.get(checking_id, {})
|
|
created_at_hint = int(
|
|
invoice_details.get(
|
|
"created_at", time.time() - self.transactions_refresh_max_age
|
|
)
|
|
)
|
|
from_ts = max(0, created_at_hint - 60)
|
|
|
|
for unpaid in unpaid_filters:
|
|
await self._refresh_recent_incoming_transactions(
|
|
from_ts=from_ts,
|
|
cache_ids=None,
|
|
stop_when_found_ids=keep_ids,
|
|
unpaid=unpaid,
|
|
)
|
|
payment_data = self._get_cached_payment_data(checking_id)
|
|
if payment_data:
|
|
return self._payment_data_to_status(payment_data)
|
|
|
|
if self.conn.supports_method("lookup_invoice"):
|
|
payment_data = await self.conn.call(
|
|
"lookup_invoice", {"payment_hash": checking_id}
|
|
)
|
|
self._cache_payment_data(checking_id, payment_data)
|
|
return self._payment_data_to_status(payment_data)
|
|
|
|
return None
|
|
|
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
|
try:
|
|
await self.conn.get_info()
|
|
status = await self._get_status_via_transactions(checking_id, [True, False])
|
|
return status or PaymentStatus(None, fee_msat=None, preimage=None)
|
|
except NWCError as e:
|
|
logger.error("Error getting invoice status: " + str(e))
|
|
failed = e.code == "NOT_FOUND"
|
|
return PaymentStatus(
|
|
None if not failed else False, fee_msat=None, preimage=None
|
|
)
|
|
except Exception as e:
|
|
logger.error("Error getting invoice status: " + str(e))
|
|
return PaymentStatus(None, fee_msat=None, preimage=None)
|
|
|
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
|
try:
|
|
await self.conn.get_info()
|
|
status = await self._get_status_via_transactions(checking_id, [False])
|
|
return status or PaymentStatus(None, fee_msat=None, preimage=None)
|
|
except NWCError as e:
|
|
logger.error("Error getting payment status: " + str(e))
|
|
failed = e.code == "NOT_FOUND"
|
|
return PaymentStatus(
|
|
None if not failed else False, fee_msat=None, preimage=None
|
|
)
|
|
except Exception as e:
|
|
logger.error("Error getting payment status: " + str(e))
|
|
# assume pending (eg. exception due to network error)
|
|
return PaymentStatus(None, fee_msat=None, preimage=None)
|
|
|
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
|
while not self._is_shutting_down():
|
|
try:
|
|
value = await asyncio.wait_for(
|
|
self.paid_invoices_queue.get(),
|
|
timeout=self.pending_invoices_maintenance_interval,
|
|
)
|
|
yield value
|
|
except asyncio.TimeoutError:
|
|
await self._maintain_pending_invoices()
|
|
|
|
|
|
class NWCConnection:
|
|
"""
|
|
A connection to a Nostr Wallet Connect (NWC) service provider.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
pubkey,
|
|
secret,
|
|
relay,
|
|
notification_handler: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
|
):
|
|
# Parse pairing url (if invalid an exception is raised)
|
|
|
|
# Extract keys (used to sign nwc events+identify NWC user)
|
|
self.account_private_key = PrivateKey(bytes.fromhex(secret))
|
|
self.account_private_key_hex = secret
|
|
self.account_public_key = self.account_private_key.public_key
|
|
if not self.account_public_key:
|
|
raise ValueError("Missing account public key")
|
|
self.account_public_key_hex = self.account_public_key.format().hex()[2:]
|
|
|
|
# Extract service key (used for encryption to identify the nwc service provider)
|
|
self.service_pubkey = PublicKey(bytes.fromhex("02" + pubkey))
|
|
self.service_pubkey_hex = pubkey
|
|
|
|
# Extract relay url
|
|
self.relay = relay
|
|
|
|
# Create temporary subscriptions, stored until the response is received/expires
|
|
self.subscriptions: dict[str, dict[str, Any]] = {}
|
|
# Timeout in seconds after which a subscription is closed
|
|
# if no response is received
|
|
self.subscription_timeout = 10
|
|
# Incremental counter to generate unique subscription ids for the connection
|
|
self.subscriptions_count = 0
|
|
|
|
# websocket connection
|
|
self.ws = None
|
|
# if True the websocket is connected
|
|
self.connected = False
|
|
# if True the connection is shutting down
|
|
self.shutdown = False
|
|
|
|
# cached info about the service provider
|
|
self.info: dict[str, Any] | None = None
|
|
self.supported_methods: set[str] = set()
|
|
self.notification_types: set[str] = set()
|
|
self.supported_encryptions = [NWC_ENCRYPTION_NIP04]
|
|
self.selected_encryption = NWC_ENCRYPTION_NIP04
|
|
self.advertises_encryption_tag = False
|
|
self.notification_handler = notification_handler
|
|
self.notification_subscription_ids: set[str] = set()
|
|
self.connection_generation = 0
|
|
|
|
# This task handles connection and reconnection to the relay
|
|
self.connection_task = asyncio.create_task(self._connect_to_relay())
|
|
|
|
# This task periodically checks and removes subscriptions
|
|
# and pending payments that have timed out
|
|
self.timeout_task = asyncio.create_task(self._handle_timeouts())
|
|
|
|
logger.info(
|
|
"NWCConnection is ready. relay: "
|
|
+ self.relay
|
|
+ " account: "
|
|
+ self.account_public_key_hex
|
|
+ " service: "
|
|
+ self.service_pubkey_hex
|
|
)
|
|
|
|
def _is_shutting_down(self) -> bool:
|
|
"""
|
|
Returns True if the connection is shutting down.
|
|
"""
|
|
return self.shutdown or not settings.lnbits_running
|
|
|
|
async def _send(self, data: list[str | dict]):
|
|
"""
|
|
Sends data to the NWC relay.
|
|
|
|
Args:
|
|
data (Dict): The data to be sent.
|
|
"""
|
|
if self._is_shutting_down():
|
|
logger.warning("Trying to send data while shutting down")
|
|
return
|
|
if not self.ws:
|
|
logger.warning("Trying to send data without a connection")
|
|
return
|
|
await self._wait_for_connection() # ensure the connection is established
|
|
tx = json_dumps(data)
|
|
logger.debug("Sending raw NWC relay message: " + tx)
|
|
await self.ws.send(tx)
|
|
|
|
def _get_new_subid(self) -> str:
|
|
"""
|
|
Generates a unique subscription id.
|
|
|
|
Returns:
|
|
str: The generated 64 characters long subscription id (eg. lnbits0abc...)
|
|
"""
|
|
subid = str(self.subscriptions_count)
|
|
self.subscriptions_count += 1
|
|
max_length = 64
|
|
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
n = max_length - len(subid)
|
|
if n > 0:
|
|
for _ in range(n):
|
|
subid += chars[random.randint(0, len(chars) - 1)] # noqa: S311
|
|
return subid
|
|
|
|
async def _close_subscription_by_subid(
|
|
self, sub_id: str, send_event: bool = True
|
|
) -> dict | None:
|
|
"""
|
|
Closes a subscription by its sub_id.
|
|
|
|
Args:
|
|
sub_id (str): The subscription id.
|
|
sendEvent (bool): If True, sends a CLOSE event to the relay.
|
|
|
|
Returns:
|
|
Dict: The subscription that was closed.
|
|
"""
|
|
logger.debug("Closing subscription " + sub_id)
|
|
sub_to_close = None
|
|
for subscription in self.subscriptions.values():
|
|
if subscription["sub_id"] == sub_id:
|
|
sub_to_close = subscription
|
|
break
|
|
# remove the subscription from the list
|
|
if sub_to_close:
|
|
self.subscriptions.pop(sub_to_close["event_id"], None)
|
|
self.notification_subscription_ids.discard(sub_id)
|
|
if not sub_to_close["closed"]:
|
|
sub_to_close["closed"] = True
|
|
if send_event:
|
|
try:
|
|
await self._send(["CLOSE", sub_id])
|
|
except Exception as e:
|
|
logger.error("Error closing subscription: " + str(e))
|
|
return sub_to_close
|
|
|
|
async def _close_subscription_by_eventid(
|
|
self, event_id, send_event=True
|
|
) -> dict | None:
|
|
"""
|
|
Closes a subscription associated to an event_id.
|
|
|
|
Args:
|
|
event_id (str): The event id associated to the subscription.
|
|
sendEvent (bool): If True, sends a CLOSE event to the relay.
|
|
|
|
Returns:
|
|
Dict: The subscription that was closed.
|
|
"""
|
|
logger.debug("Closing subscription for event " + event_id)
|
|
# find and remove the subscription
|
|
subscription = self.subscriptions.pop(event_id, None)
|
|
if subscription:
|
|
if not subscription["closed"]:
|
|
subscription["closed"] = True
|
|
self.notification_subscription_ids.discard(subscription["sub_id"])
|
|
if send_event:
|
|
try:
|
|
await self._send(["CLOSE", subscription["sub_id"]])
|
|
except Exception as e:
|
|
logger.error("Error closing subscription: " + str(e))
|
|
return subscription
|
|
|
|
async def _wait_for_connection(self, timeout: int = 60 * 2):
|
|
"""
|
|
Waits until the connection is ready
|
|
"""
|
|
t = time.time()
|
|
while not self.connected:
|
|
if time.time() - t > timeout:
|
|
raise Exception("Connection timeout, cannot connect to NWC service")
|
|
if self._is_shutting_down():
|
|
raise Exception("Connection is closing")
|
|
logger.debug("Waiting for connection...")
|
|
await asyncio.sleep(1)
|
|
|
|
async def _handle_timeouts(self):
|
|
"""
|
|
Periodically checks if any subscriptions and pending
|
|
payments have timed out, and removes them.
|
|
"""
|
|
try:
|
|
while not self._is_shutting_down():
|
|
try:
|
|
await asyncio.sleep(int(self.subscription_timeout * 0.5))
|
|
# skip if connection is not established
|
|
if not self.connected:
|
|
continue
|
|
# Find all subscriptions that have timed out
|
|
now = time.time()
|
|
subscriptions_to_close = []
|
|
for subscription in self.subscriptions.values():
|
|
if subscription["method"] == "notification_sub":
|
|
continue
|
|
t = now - subscription["timestamp"]
|
|
if t > self.subscription_timeout:
|
|
logger.warning(
|
|
"Subscription " + subscription["sub_id"] + " timed out"
|
|
)
|
|
subscriptions_to_close.append(subscription["sub_id"])
|
|
# if not already closed, pass the "time out"
|
|
# exception to the future
|
|
if not subscription["closed"]:
|
|
subscription["future"].set_exception(
|
|
Exception("timed out")
|
|
)
|
|
# Close all timed out subscriptions
|
|
for sub_id in subscriptions_to_close:
|
|
await self._close_subscription_by_subid(sub_id)
|
|
except Exception as e:
|
|
logger.error("Error handling subscription timeout: " + str(e))
|
|
except Exception as e:
|
|
logger.error("Error handling subscription timeout: " + str(e))
|
|
|
|
async def _on_ok_message(self, msg: list[str]):
|
|
"""
|
|
Handles OK messages from the relay.
|
|
"""
|
|
event_id = msg[1]
|
|
status = msg[2]
|
|
info = (msg[3] or "") if len(msg) > 3 else ""
|
|
if not status:
|
|
# close subscription and pass an exception
|
|
# if the event was rejected by the relay
|
|
subscription = await self._close_subscription_by_eventid(event_id)
|
|
if subscription: # Check if the subscription exists first
|
|
subscription["future"].set_exception(Exception(info))
|
|
|
|
async def _on_event_message(self, msg: list[str | dict]): # noqa: C901
|
|
"""
|
|
Handles EVENT messages from the relay.
|
|
"""
|
|
sub_id = cast(str, msg[1])
|
|
event = cast(dict, msg[2])
|
|
# Ensure the event is valid and comes from the configured service
|
|
# provider (do not trust relays).
|
|
if not verify_event(event) or event.get("pubkey") != self.service_pubkey_hex:
|
|
raise Exception("Invalid event signature")
|
|
tags = event["tags"]
|
|
if event["kind"] == 13194: # An info event
|
|
# info events are handled specially,
|
|
# they are stored in the subscriptions list
|
|
# using the subscription id for both sub_id and event_id
|
|
subscription = await self._close_subscription_by_eventid(
|
|
sub_id
|
|
) # sub_id is the event_id for info events
|
|
if subscription: # Check if the subscription exists first
|
|
if (
|
|
subscription["method"] != "info_sub"
|
|
): # Ensure the subscription is for an info event
|
|
raise Exception("Unexpected info event")
|
|
# create an info dictionary with the supported
|
|
# methods that is passed to the future
|
|
content = event["content"]
|
|
subscription["future"].set_result(
|
|
self._normalize_info(
|
|
{
|
|
"supported_methods": content.split(" "),
|
|
"notification_types": self._get_tag_values(
|
|
tags, "notifications"
|
|
),
|
|
"supported_encryptions": self._get_tag_values(
|
|
tags, "encryption"
|
|
),
|
|
}
|
|
)
|
|
)
|
|
elif event["kind"] in (23196, 23197):
|
|
await self._on_notification_event(event)
|
|
else: # A response event
|
|
subscription = None
|
|
# find the first "e" tag that is handled by
|
|
# a registered subscription
|
|
# Note: usually we expect only one "e" tag, but we are
|
|
# handling multiple "e" tags just in case
|
|
for tag in tags:
|
|
if tag[0] == "e":
|
|
subscription = await self._close_subscription_by_eventid(tag[1])
|
|
if subscription:
|
|
break
|
|
# if a subscription was found, pass the result to the future
|
|
if subscription:
|
|
try:
|
|
content = self._decrypt_event_content(event)
|
|
content = json.loads(content)
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to decode NWC response event. "
|
|
f"kind={event.get('kind')} id={event.get('id')} "
|
|
f"tags={event.get('tags', [])} "
|
|
f"ciphertext={event.get('content')} error={e}"
|
|
)
|
|
raise
|
|
result_type = content.get("result_type", "")
|
|
error = content.get("error", None)
|
|
result = content.get("result", None)
|
|
if error: # if an error occurred, pass the error to the future
|
|
nwc_exception = NWCError(error["code"], error["message"])
|
|
subscription["future"].set_exception(nwc_exception)
|
|
else:
|
|
# ensure the result is for the expected method
|
|
if result_type != subscription["method"]:
|
|
raise Exception("Unexpected result type")
|
|
if not result:
|
|
raise Exception("Malformed response")
|
|
else:
|
|
subscription["future"].set_result(result)
|
|
|
|
async def _on_notification_event(self, event: dict[str, Any]):
|
|
if event.get("pubkey") != self.service_pubkey_hex:
|
|
logger.warning(
|
|
"Ignoring NWC notification from unexpected pubkey "
|
|
+ str(event.get("pubkey"))
|
|
)
|
|
return
|
|
|
|
if not self.notification_handler:
|
|
return
|
|
|
|
try:
|
|
content = self._decrypt_event_content(event)
|
|
notification = json.loads(content)
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to decode NWC notification event. "
|
|
f"kind={event.get('kind')} id={event.get('id')} "
|
|
f"tags={event.get('tags', [])} "
|
|
f"ciphertext={event.get('content')} error={e}"
|
|
)
|
|
raise
|
|
await self.notification_handler(notification)
|
|
|
|
async def _on_closed_message(self, msg: list[str]):
|
|
"""
|
|
Handles CLOSED messages from the relay.
|
|
"""
|
|
# The change is reflected in the subscriptions list.
|
|
sub_id = msg[1]
|
|
info = msg[2] or ""
|
|
if info:
|
|
logger.warning("Subscription " + sub_id + " closed remotely: " + info)
|
|
# Note: sendEvent=false because the action was initiated by the relay
|
|
await self._close_subscription_by_subid(sub_id, send_event=False)
|
|
|
|
async def _on_message(self, ws, message: str):
|
|
"""
|
|
Handle incoming messages from the relay.
|
|
"""
|
|
try:
|
|
logger.debug("Received raw NWC relay message: " + message)
|
|
msg = json.loads(message)
|
|
if msg[0] == "OK": # Event status message
|
|
await self._on_ok_message(msg)
|
|
elif msg[0] == "EVENT": # Event message
|
|
await self._on_event_message(msg)
|
|
elif msg[0] == "EOSE":
|
|
# Do nothing. No need to handle this message type for NWC
|
|
pass
|
|
elif msg[0] == "CLOSED":
|
|
# Subscription was closed remotely.
|
|
await self._on_closed_message(msg)
|
|
elif msg[0] == "NOTICE":
|
|
# A message from the relay, mostly useless, but we log it anyway
|
|
logger.info("Notice from relay " + self.relay + ": " + str(msg[1]))
|
|
else:
|
|
raise Exception("Unknown message type")
|
|
except Exception as e:
|
|
logger.error("Error parsing event: " + str(e))
|
|
|
|
async def _connect_to_relay(self):
|
|
"""
|
|
Initiate websocket connection to the relay.
|
|
"""
|
|
logger.debug("Connecting to NWC relay " + self.relay)
|
|
while (
|
|
not self._is_shutting_down()
|
|
): # Reconnect until the connection is shutting down
|
|
logger.debug("Creating new connection...")
|
|
try:
|
|
async with ws_connect(self.relay) as ws:
|
|
self.ws = ws
|
|
self.connected = True
|
|
self.connection_generation += 1
|
|
self.notification_subscription_ids = set()
|
|
await self._subscribe_to_notifications()
|
|
while (
|
|
not self._is_shutting_down()
|
|
): # receive messages until the connection is shutting down
|
|
try:
|
|
reply = await ws.recv()
|
|
reply_str = ""
|
|
if isinstance(reply, bytes):
|
|
reply_str = reply.decode("utf-8")
|
|
else:
|
|
reply_str = reply
|
|
await self._on_message(ws, reply_str)
|
|
except Exception as e:
|
|
logger.debug("Error receiving message: " + str(e))
|
|
break
|
|
logger.debug("Connection to NWC relay closed")
|
|
except Exception as e:
|
|
logger.error("Error connecting to NWC relay: " + str(e))
|
|
# the connection was closed, so we set the connected flag to False
|
|
# this will make the methods calling _wait_for_connection()
|
|
# to wait until the connection is re-established
|
|
self.connected = False
|
|
if not self._is_shutting_down():
|
|
# Wait some time before reconnecting
|
|
logger.debug("Reconnecting to NWC relay in 5 seconds...")
|
|
await asyncio.sleep(5)
|
|
|
|
async def _subscribe_to_notifications(self):
|
|
for kind in (23197, 23196):
|
|
sub_id = self._get_new_subid()
|
|
sub_filter = {
|
|
"kinds": [kind],
|
|
"authors": [self.service_pubkey_hex],
|
|
"#p": [self.account_public_key_hex],
|
|
"since": int(time.time()),
|
|
}
|
|
future = asyncio.get_event_loop().create_future()
|
|
self.subscriptions[sub_id] = {
|
|
"method": "notification_sub",
|
|
"future": future,
|
|
"sub_id": sub_id,
|
|
"event_id": sub_id,
|
|
"timestamp": time.time(),
|
|
"closed": False,
|
|
}
|
|
self.notification_subscription_ids.add(sub_id)
|
|
await self._send(["REQ", sub_id, sub_filter])
|
|
|
|
def _get_tag_values(self, tags: list[list[str]], tag_name: str) -> list[str]:
|
|
for tag in tags:
|
|
if tag and tag[0] == tag_name and len(tag) > 1:
|
|
return [value for value in tag[1].split(" ") if value]
|
|
return []
|
|
|
|
def supports_notification_type(self, notification_type: str) -> bool:
|
|
return notification_type in self.notification_types
|
|
|
|
def supports_method(self, method: str) -> bool:
|
|
return method in self.supported_methods
|
|
|
|
def _normalize_info(self, info: dict[str, Any]) -> dict[str, Any]:
|
|
methods = info.get("supported_methods", []) or []
|
|
notifications = info.get("notification_types", []) or []
|
|
encryptions = _normalize_supported_encryptions(
|
|
info.get("supported_encryptions", []) or []
|
|
)
|
|
normalized = {
|
|
"supported_methods": [method for method in methods if method],
|
|
"notification_types": [
|
|
notification for notification in notifications if notification
|
|
],
|
|
"supported_encryptions": encryptions,
|
|
}
|
|
return normalized
|
|
|
|
def _apply_capabilities(self, info: dict[str, Any]) -> dict[str, Any]:
|
|
normalized = self._normalize_info(info)
|
|
self.supported_methods = set(normalized["supported_methods"])
|
|
self.notification_types = set(normalized["notification_types"])
|
|
self.supported_encryptions = normalized["supported_encryptions"]
|
|
self.advertises_encryption_tag = bool(info.get("supported_encryptions"))
|
|
self.selected_encryption = _choose_preferred_encryption(
|
|
normalized["supported_encryptions"]
|
|
)
|
|
logger.debug(
|
|
"Negotiated NWC provider capabilities. "
|
|
f"supported_encryptions={self.supported_encryptions} "
|
|
f"selected_encryption={self.selected_encryption} "
|
|
f"advertises_encryption_tag={self.advertises_encryption_tag} "
|
|
f"supported_methods={sorted(self.supported_methods)} "
|
|
f"notification_types={sorted(self.notification_types)}"
|
|
)
|
|
return normalized
|
|
|
|
def _get_event_encryption(self, event: dict[str, Any]) -> str:
|
|
encryption_tag = self._get_tag_values(event.get("tags", []), "encryption")
|
|
if encryption_tag:
|
|
return _choose_preferred_encryption(encryption_tag)
|
|
if event.get("kind") == NWC_NOTIFICATION_KIND_NIP44:
|
|
return NWC_ENCRYPTION_NIP44_V2
|
|
if event.get("kind") == NWC_NOTIFICATION_KIND_NIP04:
|
|
return NWC_ENCRYPTION_NIP04
|
|
return (
|
|
self.selected_encryption
|
|
if self.selected_encryption
|
|
else NWC_ENCRYPTION_NIP04
|
|
)
|
|
|
|
def _encrypt_payload(self, content: str) -> tuple[str, str]:
|
|
encryption = self.selected_encryption or NWC_ENCRYPTION_NIP04
|
|
logger.debug(
|
|
"Encrypting NWC payload. " f"encryption={encryption} plaintext={content}"
|
|
)
|
|
if encryption == NWC_ENCRYPTION_NIP44_V2:
|
|
encrypted = NIP44Encryption.encrypt(
|
|
content, self.service_pubkey, self.account_private_key_hex
|
|
)
|
|
else:
|
|
encrypted = encrypt_content(
|
|
content,
|
|
self.service_pubkey,
|
|
self.account_private_key_hex,
|
|
)
|
|
encryption = NWC_ENCRYPTION_NIP04
|
|
logger.debug(
|
|
"Encrypted NWC payload. " f"encryption={encryption} ciphertext={encrypted}"
|
|
)
|
|
return encrypted, encryption
|
|
|
|
def _decrypt_event_content(self, event: dict[str, Any]) -> str:
|
|
encryption = self._get_event_encryption(event)
|
|
logger.debug(
|
|
"Decrypting NWC event. "
|
|
f"kind={event.get('kind')} id={event.get('id')} "
|
|
f"encryption={encryption} tags={event.get('tags', [])} "
|
|
f"ciphertext={event.get('content')}"
|
|
)
|
|
if encryption == NWC_ENCRYPTION_NIP44_V2:
|
|
plaintext = NIP44Encryption.decrypt(
|
|
event["content"], self.service_pubkey, self.account_private_key_hex
|
|
)
|
|
else:
|
|
plaintext = decrypt_content(
|
|
event["content"],
|
|
self.service_pubkey,
|
|
self.account_private_key_hex,
|
|
)
|
|
logger.debug(
|
|
"Decrypted NWC event. "
|
|
f"kind={event.get('kind')} id={event.get('id')} "
|
|
f"encryption={encryption} plaintext={plaintext}"
|
|
)
|
|
return plaintext
|
|
|
|
async def call(self, method: str, params: dict) -> dict:
|
|
"""
|
|
Call a NWC method.
|
|
|
|
Args:
|
|
method (str): The method name.
|
|
params (Dict): The method parameters.
|
|
|
|
Returns:
|
|
Dict: The result of the method call.
|
|
"""
|
|
await self._wait_for_connection()
|
|
logger.debug("Calling " + method + " with params: " + str(params))
|
|
# Prepare the content
|
|
content = json_dumps(
|
|
{
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
)
|
|
content, encryption = self._encrypt_payload(content)
|
|
# Prepare the NWC event
|
|
tags = [["p", self.service_pubkey_hex]]
|
|
if encryption != NWC_ENCRYPTION_NIP04 or self.advertises_encryption_tag:
|
|
tags.append(["encryption", encryption])
|
|
logger.debug(
|
|
"Using NWC provider encryption for request. "
|
|
f"method={method} encryption={encryption} tags={tags}"
|
|
)
|
|
event = {
|
|
"kind": 23194,
|
|
"content": content,
|
|
"created_at": int(time.time()),
|
|
"tags": tags,
|
|
}
|
|
# Sign
|
|
sign_event(event, self.account_public_key_hex, self.account_private_key)
|
|
# Subscribe for a response to this event
|
|
sub_filter = {
|
|
"kinds": [23195],
|
|
"authors": [self.service_pubkey_hex],
|
|
"#p": [self.account_public_key_hex],
|
|
"#e": [event["id"]],
|
|
"since": event["created_at"],
|
|
}
|
|
sub_id = self._get_new_subid()
|
|
# register a future to receive the response asynchronously
|
|
future = asyncio.get_event_loop().create_future()
|
|
event_id = cast(str, event["id"])
|
|
# Check if the subscription already exists
|
|
# (this means there is a bug somewhere, should not happen)
|
|
if event_id in self.subscriptions:
|
|
raise Exception("Subscription for this event id already exists?")
|
|
# Store the subscription in the list
|
|
self.subscriptions[event_id] = {
|
|
"method": method,
|
|
"future": future,
|
|
"sub_id": sub_id,
|
|
"event_id": event_id,
|
|
"timestamp": time.time(),
|
|
"closed": False,
|
|
}
|
|
# Send the events
|
|
await self._send(["REQ", sub_id, sub_filter])
|
|
await self._send(["EVENT", event])
|
|
# Wait for the response
|
|
return await future
|
|
|
|
async def get_info(self) -> dict:
|
|
"""
|
|
Get the info about the service provider and cache it.
|
|
|
|
Returns:
|
|
Dict: The info about the service provider.
|
|
"""
|
|
if not self.info: # if not cached
|
|
try:
|
|
await self._wait_for_connection()
|
|
# Prepare filter to request the info note
|
|
sub_filter = {"kinds": [13194], "authors": [self.service_pubkey_hex]}
|
|
# We register a special subscription using the sub_id as the event_id
|
|
sub_id = self._get_new_subid()
|
|
future = asyncio.get_event_loop().create_future()
|
|
self.subscriptions[sub_id] = {
|
|
"method": "info_sub",
|
|
"future": future,
|
|
"sub_id": sub_id,
|
|
"event_id": sub_id,
|
|
"timestamp": time.time(),
|
|
"closed": False,
|
|
}
|
|
# Send the request
|
|
await self._send(["REQ", sub_id, sub_filter])
|
|
# Wait for the response
|
|
service_info = await future
|
|
service_info = self._apply_capabilities(service_info)
|
|
# Get account info when possible
|
|
if self.supports_method("get_info"):
|
|
try:
|
|
account_info = await self.call("get_info", {})
|
|
# cache
|
|
info: dict[str, Any] = dict(service_info)
|
|
info["alias"] = account_info.get("alias", "")
|
|
info["color"] = account_info.get("color", "")
|
|
info["pubkey"] = account_info.get("pubkey", "")
|
|
info["network"] = account_info.get("network", "")
|
|
info["block_height"] = account_info.get("block_height", 0)
|
|
info["block_hash"] = account_info.get("block_hash", "")
|
|
info["supported_methods"] = account_info.get(
|
|
"methods",
|
|
service_info.get("supported_methods", ["pay_invoice"]),
|
|
)
|
|
info["notification_types"] = account_info.get(
|
|
"notifications",
|
|
service_info.get("notification_types", []),
|
|
)
|
|
info["supported_encryptions"] = service_info.get(
|
|
"supported_encryptions",
|
|
[NWC_ENCRYPTION_NIP04],
|
|
)
|
|
self.info = self._apply_capabilities(info)
|
|
except Exception as e:
|
|
# If there is an error, fallback to using service info
|
|
logger.error(
|
|
"Error getting account info: "
|
|
+ str(e)
|
|
+ " Using service info only"
|
|
)
|
|
self.info = service_info
|
|
else:
|
|
# get_info is not supported,
|
|
# so we will make do with the service info
|
|
self.info = service_info # cache
|
|
except Exception as e:
|
|
logger.error("Error getting info: " + str(e))
|
|
# The error could mean that the service provider does
|
|
# not provide an info note
|
|
# So we just assume it supports the bare minimum to be Nip47 compliant
|
|
self.info = self._apply_capabilities(
|
|
{
|
|
"supported_methods": ["pay_invoice"],
|
|
"notification_types": [],
|
|
"supported_encryptions": [NWC_ENCRYPTION_NIP04],
|
|
}
|
|
)
|
|
return self.info or {}
|
|
|
|
async def close(self):
|
|
logger.debug("Closing NWCConnection")
|
|
self.shutdown = True # Mark for shutdown
|
|
# cancel all tasks
|
|
try:
|
|
self.timeout_task.cancel()
|
|
except Exception as e:
|
|
logger.warning("Error cancelling subscription timeout task: " + str(e))
|
|
try:
|
|
self.connection_task.cancel()
|
|
except Exception as e:
|
|
logger.warning("Error cancelling connection task: " + str(e))
|
|
for sub_id in list(self.notification_subscription_ids):
|
|
try:
|
|
await self._send(["CLOSE", sub_id])
|
|
except Exception as e:
|
|
logger.warning("Error closing notification subscription: " + str(e))
|
|
# close the websocket
|
|
try:
|
|
if self.ws:
|
|
await self.ws.close()
|
|
except Exception as e:
|
|
logger.warning("Error closing connection: " + str(e))
|
|
|
|
|
|
def parse_nwc(nwc) -> dict:
|
|
"""
|
|
Parses a NWC URL (nostr+walletconnect://...) and extracts relevant information.
|
|
|
|
Args:
|
|
nwc (str): The Nostr Wallet Connect URL to be parsed.
|
|
|
|
Returns:
|
|
Dict[str, str]: A dict containing:'pubkey', 'relay', and 'secret'.
|
|
If the URL is invalid, an exception is raised.
|
|
|
|
Example:
|
|
>>> parse_nwc("nostr+walletconnect://000000...000000?relay=example.com&secret=123")
|
|
{'pubkey': '000000...000000', 'relay': 'example.com', 'secret': '123'}
|
|
"""
|
|
data = {}
|
|
prefix = "nostr+walletconnect://"
|
|
if nwc and nwc.startswith(prefix):
|
|
nwc = nwc[len(prefix) :]
|
|
parsed_url = urlparse(nwc)
|
|
data["pubkey"] = parsed_url.path
|
|
query_params = parse_qs(parsed_url.query)
|
|
for key, value in query_params.items():
|
|
if key in ["relay", "secret"] and value:
|
|
data[key] = unquote(value[0])
|
|
if "pubkey" not in data or "relay" not in data or "secret" not in data:
|
|
raise ValueError("Invalid NWC pairing url")
|
|
else:
|
|
raise ValueError("Invalid NWC pairing url")
|
|
return data
|
|
|
|
|
|
class NIP44Encryption:
|
|
@staticmethod
|
|
def encrypt(
|
|
content: str,
|
|
service_pubkey: PublicKey,
|
|
account_private_key_hex: str,
|
|
) -> str:
|
|
conversation_key = NIP44Encryption._get_conversation_key(
|
|
service_pubkey,
|
|
account_private_key_hex,
|
|
)
|
|
nonce = secrets.token_bytes(32)
|
|
chacha_key, chacha_nonce, hmac_key = NIP44Encryption._get_message_keys(
|
|
conversation_key,
|
|
nonce,
|
|
)
|
|
padded = NIP44Encryption._pad(content)
|
|
ciphertext = ChaCha20.new(key=chacha_key, nonce=chacha_nonce).encrypt(padded)
|
|
mac = HMAC.new(hmac_key, digestmod=SHA256)
|
|
mac.update(nonce + ciphertext)
|
|
payload = bytes([2]) + nonce + ciphertext + mac.digest()
|
|
return base64.b64encode(payload).decode("ascii")
|
|
|
|
@staticmethod
|
|
def decrypt(
|
|
content: str,
|
|
service_pubkey: PublicKey,
|
|
account_private_key_hex: str,
|
|
) -> str:
|
|
if not content or content[0] == "#":
|
|
raise ValueError("unknown encryption version")
|
|
raw = base64.b64decode(content.encode("ascii"))
|
|
if len(raw) < 99 or len(raw) > 65603:
|
|
raise ValueError("invalid data size")
|
|
version = raw[0]
|
|
if version != 2:
|
|
raise ValueError(f"unknown version {version}")
|
|
nonce = raw[1:33]
|
|
ciphertext = raw[33:-32]
|
|
mac = raw[-32:]
|
|
conversation_key = NIP44Encryption._get_conversation_key(
|
|
service_pubkey,
|
|
account_private_key_hex,
|
|
)
|
|
chacha_key, chacha_nonce, hmac_key = NIP44Encryption._get_message_keys(
|
|
conversation_key,
|
|
nonce,
|
|
)
|
|
expected_mac = HMAC.new(hmac_key, digestmod=SHA256)
|
|
expected_mac.update(nonce + ciphertext)
|
|
if not hmac.compare_digest(expected_mac.digest(), mac):
|
|
raise ValueError("invalid MAC")
|
|
padded = ChaCha20.new(key=chacha_key, nonce=chacha_nonce).decrypt(ciphertext)
|
|
return NIP44Encryption._unpad(padded)
|
|
|
|
@staticmethod
|
|
def _get_shared_x(
|
|
service_pubkey: PublicKey,
|
|
account_private_key_hex: str,
|
|
) -> bytes:
|
|
return service_pubkey.multiply(bytes.fromhex(account_private_key_hex)).format()[
|
|
1:
|
|
]
|
|
|
|
@staticmethod
|
|
def _hkdf_extract(*, ikm: bytes, salt: bytes) -> bytes:
|
|
return hmac.new(salt, ikm, hashlib.sha256).digest()
|
|
|
|
@staticmethod
|
|
def _hkdf_expand(*, prk: bytes, info: bytes, length: int) -> bytes:
|
|
output = bytearray()
|
|
previous = b""
|
|
counter = 1
|
|
while len(output) < length:
|
|
previous = hmac.new(
|
|
prk,
|
|
previous + info + bytes([counter]),
|
|
hashlib.sha256,
|
|
).digest()
|
|
output.extend(previous)
|
|
counter += 1
|
|
return bytes(output[:length])
|
|
|
|
@staticmethod
|
|
def _get_conversation_key(
|
|
service_pubkey: PublicKey,
|
|
account_private_key_hex: str,
|
|
) -> bytes:
|
|
return NIP44Encryption._hkdf_extract(
|
|
ikm=NIP44Encryption._get_shared_x(service_pubkey, account_private_key_hex),
|
|
salt=b"nip44-v2",
|
|
)
|
|
|
|
@staticmethod
|
|
def _calc_padded_len(unpadded_len: int) -> int:
|
|
if unpadded_len <= 32:
|
|
return 32
|
|
next_power = 1 << ((unpadded_len - 1).bit_length())
|
|
chunk = 32 if next_power <= 256 else next_power // 8
|
|
return chunk * (((unpadded_len - 1) // chunk) + 1)
|
|
|
|
@staticmethod
|
|
def _pad(content: str) -> bytes:
|
|
plaintext = content.encode("utf-8")
|
|
plaintext_len = len(plaintext)
|
|
if plaintext_len < 1 or plaintext_len > 65535:
|
|
raise ValueError("invalid plaintext length")
|
|
padded_len = NIP44Encryption._calc_padded_len(plaintext_len)
|
|
return (
|
|
plaintext_len.to_bytes(2, "big")
|
|
+ plaintext
|
|
+ bytes(padded_len - plaintext_len)
|
|
)
|
|
|
|
@staticmethod
|
|
def _unpad(padded: bytes) -> str:
|
|
if len(padded) < 34:
|
|
raise ValueError("invalid padded payload size")
|
|
plaintext_len = int.from_bytes(padded[:2], "big")
|
|
plaintext = padded[2 : 2 + plaintext_len]
|
|
expected_len = 2 + NIP44Encryption._calc_padded_len(plaintext_len)
|
|
if (
|
|
plaintext_len < 1
|
|
or len(plaintext) != plaintext_len
|
|
or len(padded) != expected_len
|
|
):
|
|
raise ValueError("invalid padding")
|
|
return plaintext.decode("utf-8")
|
|
|
|
@staticmethod
|
|
def _get_message_keys(
|
|
conversation_key: bytes, nonce: bytes
|
|
) -> tuple[bytes, bytes, bytes]:
|
|
if len(conversation_key) != 32:
|
|
raise ValueError("invalid conversation_key length")
|
|
if len(nonce) != 32:
|
|
raise ValueError("invalid nonce length")
|
|
keys = NIP44Encryption._hkdf_expand(
|
|
prk=conversation_key,
|
|
info=nonce,
|
|
length=76,
|
|
)
|
|
return keys[:32], keys[32:44], keys[44:76]
|