feat: NWC Funding source #2579 (#2631)

* feat: nwc funding source

* implement paid_invoices_stream, fix for unsettled invoices where settled_at is present but None

* cancel pending_payments_lookup_task on cleanup

* Rename subscription_timeout_task to timeout_task

* ensure preimage is not None

* Improve readability, return failed status on expiration in get_payment_status, ensure result_type is checked after error (some implementations might not set a result_type on error)

* fetch account info when possible

* workaround possible race condition on some nwc service providers, improve performance of fallback by using payment_hash from bolt11 invoice

* fundle

* make format

* fix formatting

* fix C901 `_on_message` is too complex (21 > 16)

* format

* fix lint

* format

* fix tests/wallets/test_nwc_wallets.py:80:11: C901 `run` is too complex (17 > 16)

* fix padding

* fix documentation for _verify_event method

* refactoring and fixes

* Split NWCWallet - NWCConnection

* refactor class methods into helpers

* update bundle

* format

* catch NWCError failure codes

* format and fix

* chore: bundle

* add example

* typos

---------

Co-authored-by: Riccardo Balbo <riccardo0blb@gmail.com>
Co-authored-by: benarc <ben@arc.wales>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
This commit is contained in:
dni ⚡ 2024-08-07 09:56:53 +02:00 committed by GitHub
parent daa4b92331
commit 27b9e8254c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2179 additions and 2 deletions

View File

@ -28,7 +28,7 @@ PORT=5000
######################################
# which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, BreezSdkWallet"
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
@ -112,6 +112,9 @@ LNBITS_DENOMINATION=sats
ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw
# NWCWalllet
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
# LnTipsWallet
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY

View File

@ -124,3 +124,12 @@ A Greenlight invite code or Greenlight partner certificate/key can be used to re
### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
### Nostr Wallet Connect (NWC)
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**

View File

@ -547,6 +547,20 @@
</div>
<div class="col q-pl-md"></div>
</div>
<div class="row">
<div class="col">
<a
href="https://nwc.dev/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/nwc.png') }}' : '{{ static_url_for('static', 'images/nwcl.png') }}'"
></q-img>
</a>
</div>
</div>
</div>
</div>
</div>

View File

@ -258,6 +258,10 @@ class LnTipsFundingSource(LNbitsSettings):
lntips_invoice_key: Optional[str] = Field(default=None)
class NWCFundingSource(LNbitsSettings):
nwc_pairing_url: Optional[str] = Field(default=None)
class BreezSdkFundingSource(LNbitsSettings):
breez_api_key: Optional[str] = Field(default=None)
breez_greenlight_seed: Optional[str] = Field(default=None)
@ -287,6 +291,7 @@ class FundingSourcesSettings(
OpenNodeFundingSource,
SparkFundingSource,
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
):
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
@ -446,6 +451,7 @@ class SuperUserSettings(LNbitsSettings):
"PhoenixdWallet",
"VoidWallet",
"ZBDWallet",
"NWCWallet",
]
)

File diff suppressed because one or more lines are too long

View File

@ -166,6 +166,13 @@ Vue.component('lnbits-funding-sources', {
spark_token: 'Token'
}
],
[
'NWCWallet',
'Nostr Wallet Connect',
{
nwc_pairing_url: 'Pairing URL'
}
],
[
'BreezSdkWallet',
'Breez SDK',

View File

@ -24,6 +24,7 @@ from .lndgrpc import LndWallet
from .lndrest import LndRestWallet
from .lnpay import LNPayWallet
from .lntips import LnTipsWallet
from .nwc import NWCWallet
from .opennode import OpenNodeWallet
from .phoenixd import PhoenixdWallet
from .spark import SparkWallet

953
lnbits/wallets/nwc.py Normal file
View File

@ -0,0 +1,953 @@
import asyncio
import base64
import hashlib
import json
import random
import time
from typing import AsyncGenerator, Dict, List, Optional, Union, cast
from urllib.parse import parse_qs, unquote, urlparse
import secp256k1
from bolt11 import decode as bolt11_decode
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
from loguru import logger
from websockets.client import connect as ws_connect
from lnbits.settings import settings
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}"
class NWCWallet(Wallet):
"""
A funding source that connects to a Nostr Wallet Connect (NWC) service provider.
https://nwc.dev/
"""
def __init__(self):
self.shutdown = False
nwc_data = parse_nwc(settings.nwc_pairing_url)
self.conn = NWCConnection(
nwc_data["pubkey"], nwc_data["secret"], nwc_data["relay"]
)
# pending payments for paid_invoices_stream.
# They are tracked until they expire or are settled
self.pending_payments = []
# interval in seconds between checks for pending payments
self.pending_payments_lookup_interval = 10
# track paid invoices for paid_invoices_stream
self.paid_invoices_queue: asyncio.Queue = asyncio.Queue(0)
# This task periodically checks if pending payments have been settled
self.pending_payments_lookup_task = asyncio.create_task(
self._handle_pending_payments()
)
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_pending_payments(self):
"""
Periodically checks if any pending payments have been settled.
"""
while not self._is_shutting_down():
await asyncio.sleep(self.pending_payments_lookup_interval)
# Check if any pending payments have been settled or timed out
now = time.time()
for payment in self.pending_payments:
try:
if not payment["settled"]:
payment_data = await self.conn.call(
"lookup_invoice", {"payment_hash": payment["checking_id"]}
)
settled = (
"settled_at" in payment_data
and payment_data["settled_at"]
and int(payment_data["settled_at"]) > 0
and "preimage" in payment_data
and payment_data["preimage"]
)
if settled:
logger.debug(
"Pending payment " + payment["checking_id"] + " settled"
)
payment["settled"] = True
self.paid_invoices_queue.put_nowait(payment["checking_id"])
except Exception as e:
logger.error("Error handling pending payment: " + str(e))
try:
if now > payment["expires_at"]:
logger.warning(
"Pending payment " + payment["checking_id"] + " timed out"
)
payment["expired"] = True
except Exception as e:
logger.error("Error handling pending payment: " + str(e))
# Remove all settled or expired payments
self.pending_payments = [
payment
for payment in self.pending_payments
if not payment["settled"] and not payment["expired"]
]
async def cleanup(self):
self.shutdown = True
try:
self.pending_payments_lookup_task.cancel()
except Exception as e:
logger.warning("Error cancelling pending payments lookup task: " + str(e))
await self.conn.close()
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> 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:
info = await self.conn.get_info()
if "make_invoice" not in info["supported_methods"]:
return InvoiceResponse(
False,
None,
None,
"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)
# if lookup_invoice is not supported, we can't track the payment
if "lookup_invoice" in info["supported_methods"]:
created_at = int(resp.get("created_at", time.time()))
expires_at = int(resp.get("expires_at", created_at + 3600))
self.pending_payments.append(
{ # Start tracking
"checking_id": checking_id,
"expires_at": expires_at,
"settled": False,
"expired": False,
}
)
return InvoiceResponse(True, checking_id, payment_request, None)
except Exception as e:
return InvoiceResponse(ok=False, error_message=str(e))
async def status(self) -> StatusResponse:
try:
info = await self.conn.get_info()
if "get_balance" not in info["supported_methods"]:
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)
info = await self.conn.get_info()
if "lookup_invoice" not in info["supported_methods"]:
# if not supported, we assume it succeeded
return PaymentResponse(True, payment_hash, None, preimage, None)
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(None, payment_hash, None, None, None)
else:
fee_msat = payment_data.get("fees_paid", None)
return PaymentResponse(True, payment_hash, fee_msat, preimage, None)
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(None, payment_hash, None, None, None)
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(
None if not failed else False,
error_message=e.message if failed else None,
)
except Exception as e:
logger.error("Error paying invoice: " + str(e))
# assume pending
return PaymentResponse(None)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
return await self.get_payment_status(checking_id)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
info = await self.conn.get_info()
if "lookup_invoice" in info["supported_methods"]:
payment_data = await self.conn.call(
"lookup_invoice", {"payment_hash": checking_id}
)
settled = payment_data.get("settled_at", None) and payment_data.get(
"preimage", None
)
fee_msat = payment_data.get("fees_paid", None)
preimage = payment_data.get("preimage", None)
created_at = int(payment_data.get("created_at", time.time()))
expires_at = int(payment_data.get("expires_at", created_at + 3600))
expired = expires_at and time.time() > expires_at
if expired and not settled:
return PaymentStatus(False, fee_msat=fee_msat, preimage=preimage)
else:
return PaymentStatus(
True if settled else None, fee_msat=fee_msat, preimage=preimage
)
else:
return 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():
value = await self.paid_invoices_queue.get()
yield value
class NWCConnection:
"""
A connection to a Nostr Wallet Connect (NWC) service provider.
"""
def __init__(self, pubkey, secret, relay):
# Parse pairing url (if invalid an exception is raised)
# Extract keys (used to sign nwc events+identify NWC user)
self.account_private_key = secp256k1.PrivateKey(bytes.fromhex(secret))
self.account_private_key_hex = secret
self.account_public_key = self.account_private_key.pubkey
self.account_public_key_hex = self.account_public_key.serialize().hex()[2:]
# Extract service key (used for encryption to identify the nwc service provider)
self.service_pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey), True)
self.service_pubkey_hex = pubkey
# Extract relay url
self.relay = relay
# Create temporary subscriptions, stored until the response is received/expires
self.subscriptions = {}
# 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 = None
# 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[Union[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)
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 = "lnbits" + 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)]
return subid
async def _close_subscription_by_subid(
self, sub_id: str, send_event: bool = True
) -> Optional[Dict]:
"""
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
# send CLOSE event to the relay if the subscription
# is not already closed and sendEvent is True
if not subscription["closed"] and send_event:
await self._send(["CLOSE", sub_id])
# mark as closed
subscription["closed"] = True
break
# remove the subscription from the list
if sub_to_close:
self.subscriptions.pop(sub_to_close["event_id"], None)
return sub_to_close
async def _close_subscription_by_eventid(
self, event_id, send_event=True
) -> Optional[Dict]:
"""
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:
# send CLOSE event to the relay if the subscription
# is not already closed and sendEvent is True
if not subscription["closed"] and send_event:
await self._send(["CLOSE", subscription["sub_id"]])
# mark as closed
subscription["closed"] = True
return subscription
async def _wait_for_connection(self):
"""
Waits until the connection is ready
"""
while not self.connected:
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():
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[Union[str, Dict]]):
"""
Handles EVENT messages from the relay.
"""
sub_id = cast(str, msg[1])
event = cast(Dict, msg[2])
if not verify_event(event): # Ensure the event is valid (do not trust relays)
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(
{"supported_methods": content.split(" ")}
)
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:
content = decrypt_content(
event["content"], self.service_pubkey, self.account_private_key_hex
)
content = json.loads(content)
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_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:
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
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 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,
}
)
# Encrypt
content = encrypt_content(
content, self.service_pubkey, self.account_private_key_hex
)
# Prepare the NWC event
event = {
"kind": 23194,
"content": content,
"created_at": int(time.time()),
"tags": [["p", self.service_pubkey_hex]],
}
# Sign
sign_event(event, self.account_public_key_hex, self.account_private_key)
# Subscribe for a response to this event
sub_filter = {
"kinds": [23195],
"#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()
# 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
# Get account info when possible
if "get_info" in service_info["supported_methods"]:
try:
account_info = await self.call("get_info", {})
# cache
self.info = service_info
self.info["alias"] = account_info.get("alias", "")
self.info["color"] = account_info.get("color", "")
self.info["pubkey"] = account_info.get("pubkey", "")
self.info["network"] = account_info.get("network", "")
self.info["block_height"] = account_info.get("block_height", 0)
self.info["block_hash"] = account_info.get("block_hash", "")
self.info["supported_methods"] = account_info.get(
"methods",
service_info.get("supported_methods", ["pay_invoice"]),
)
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 = {
"supported_methods": ["pay_invoice"],
}
return self.info
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))
# 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
def json_dumps(data: Union[Dict, list]) -> str:
"""
Converts a Python dictionary to a JSON string with compact encoding.
Args:
data (Dict): The dictionary to be converted.
Returns:
str: The compact JSON string.
"""
if isinstance(data, Dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def encrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Encrypts the content to be sent to the service.
Args:
content (str): The content to be encrypted.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The encrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# random iv (16B)
iv = Random.new().read(AES.block_size)
aes = AES.new(shared, AES.MODE_CBC, iv)
content_bytes = content.encode("utf-8")
# padding
content_bytes = pad(content_bytes, AES.block_size)
# Encrypt
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
iv_b64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
return encrypted_content
def decrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Decrypts the content coming from the service.
Args:
content (str): The encrypted content.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The decrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# extract iv and content
(encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii"))
# Decrypt
aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted_bytes = aes.decrypt(encrypted_content)
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
decrypted = decrypted_bytes.decode("utf-8")
return decrypted
def verify_event(event: Dict) -> bool:
"""
Verify the event signature
Args:
event (Dict): The event to verify.
Returns:
bool: True if the event signature is valid, False otherwise.
"""
signature_data = json_dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
if event_id != event["id"]: # Invalid event id
return False
pubkey_hex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
if not pubkey.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
):
return False
return True
def sign_event(
event: Dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey
) -> Dict:
"""
Signs the event (in place) with the service secret
Args:
event (Dict): The event to be signed.
account_public_key_hex (str): The account public key in hex format.
account_private_key (secp256k1.PrivateKey): The account private key.
Returns:
Dict: The input event with the signature added.
"""
signature_data = json_dumps(
[
0,
account_public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = account_public_key_hex
signature = (
account_private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
).hex()
event["sig"] = signature
return event

View File

@ -0,0 +1,571 @@
{
"funding_sources": {
"nwc": {
"wallet_class": "NWCWallet",
"settings": {
"nwc_pairing_url": "nostr+walletconnect://be927be01ce2b3ab0fc33ffec6c6ab590381d7fc883a392d163d3966fc5840b3?relay=ws://127.0.0.1:8555&secret=d1b1d3b0f4a1fcba4c15094d34ff0569cae3c8c7af939b3473ccd564cce3bfa3"
},
"mock_settings": {
"service_public_key": "be927be01ce2b3ab0fc33ffec6c6ab590381d7fc883a392d163d3966fc5840b3",
"service_private_key": "ad6a224c9c2f2a7ac7181092348d99671a94f28974e331e0f0afe3bcdab72bed",
"user_public_key": "bef38893a567e717110478cc5729f4bf881727f5a7d7859edad2b2b21ee81f7e",
"user_private_key": "d1b1d3b0f4a1fcba4c15094d34ff0569cae3c8c7af939b3473ccd564cce3bfa3",
"port": 8555,
"supported_methods": [
"get_balance",
"make_invoice",
"pay_invoice",
"lookup_invoice"
]
}
}
},
"functions": {
"status": {
"mocks": {
"nwc": {
"get_balance": {}
}
},
"tests": [
{
"description": "success",
"call_params": {},
"expect": {
"error_message": null,
"balance_msat": 55000
},
"mocks": {
"nwc": {
"get_balance": [
{
"request_type": "json",
"request_body": {
"method": "get_balance",
"params": {}
},
"response_type": "json",
"response": {
"result_type": "get_balance",
"result": {
"balance": 55000
}
}
}
]
}
}
},
{
"description": "error",
"call_params": {},
"expect": {
"error_message": "TEST ERROR test-error",
"balance_msat": 0
},
"mocks": {
"nwc": {
"get_balance": [
{
"request_type": "json",
"request_body": {
"method": "get_balance",
"params": {}
},
"response_type": "json",
"response": {
"result_type": "get_balance",
"error": {
"code": "TEST ERROR",
"message": "test-error"
}
}
}
]
}
}
}
]
},
"create_invoice": {
"mocks": {
"nwc": {
"make_invoice": {}
},
"nwc-bad": {
"make_invoice": {}
}
},
"tests": [
{
"description": "success",
"call_params": {
"amount": 555,
"memo": "Test Invoice",
"label": "test-label"
},
"expect": {
"error_message": null,
"success": true,
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"payment_request": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n"
},
"mocks": {
"nwc": {
"make_invoice": [
{
"request_type": "json",
"request_body": {
"method": "make_invoice",
"params": {
"amount": 555000,
"description": "Test Invoice"
}
},
"response_type": "json",
"response": {
"result_type": "make_invoice",
"result": {
"type": "incoming",
"invoice": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n",
"description": "Test Invoice",
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"amount": 555000,
"fees_paid": 0,
"created_at": 0,
"expires_at": 0
}
}
}
]
}
}
},
{
"description": "error",
"call_params": {
"amount": 555,
"memo": "Test Invoice",
"label": "test-label"
},
"expect": {
"error_message": "TEST ERROR test-error",
"success": false,
"checking_id": null,
"payment_request": null
},
"mocks": {
"nwc": {
"make_invoice": [
{
"request_type": "json",
"request_body": {
"method": "make_invoice",
"params": {
"amount": 555000,
"description": "Test Invoice"
}
},
"response_type": "json",
"response": {
"result_type": "make_invoice",
"error": {
"code": "TEST ERROR",
"message": "test-error"
}
}
}
]
}
}
}
]
},
"pay_invoice": {
"mocks": {
"nwc": {
"pay_invoice": {},
"lookup_invoice": {}
}
},
"tests": [
{
"description": "success",
"call_params": {
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit_msat": 25000
},
"expect": {
"error_message": null,
"success": true,
"pending": false,
"failed": false,
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"fee_msat": 50,
"preimage": "0000000000000000000000000000000000000000000000000000000000000000"
},
"mocks": {
"nwc": {
"pay_invoice": [
{
"request_type": "json",
"request_body": {
"method": "pay_invoice",
"params": {
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
}
},
"response_type": "json",
"response": {
"result_type": "pay_invoice",
"result": {
"preimage": "0000000000000000000000000000000000000000000000000000000000000000"
}
}
}
],
"lookup_invoice": [
{
"request_type": "json",
"request_body": {
"method": "lookup_invoice",
"params": {
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
}
},
"response_type": "json",
"response": {
"result_type": "lookup_invoice",
"result": {
"type": "outgoing",
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Test invoice",
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"amount": 1000,
"fees_paid": 50,
"created_at": 0,
"settled_at": 1
}
}
}
]
}
}
},
{
"description": "failed",
"call_params": {
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit_msat": 25000
},
"expect": {
"success": false,
"pending": false,
"failed": true,
"checking_id": null,
"fee_msat": null,
"preimage": null
},
"mocks": {
"nwc": {
"pay_invoice": [
{
"request_type": "json",
"request_body": {
"method": "pay_invoice",
"params": {
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
}
},
"response_type": "json",
"response": {
"result_type": "pay_invoice",
"error": {
"code": "TEST ERROR",
"message": "test-error"
}
}
}
]
}
}
},
{
"description": "error",
"call_params": {
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit_msat": 25000
},
"expect": {
"success": false,
"pending": true,
"failed": false,
"checking_id": null,
"fee_msat": null,
"preimage": null,
"error_message": "TEST ERROR test-error"
},
"mocks": {
"nwc": {
"pay_invoice": [
{
"request_type": "json",
"request_body": {
"method": "pay_invoice",
"params": {
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
}
},
"response_type": "json",
"response": {
"result_type": "pay_invoice",
"error": {
"code": "TEST ERROR",
"message": "test-error"
}
}
}
]
}
}
}
]
},
"get_invoice_status": {
"mocks": {
"nwc": {
"lookup_invoice": {}
},
"nwc-bad": {
"lookup_invoice": {}
}
},
"tests": [
{
"description": "paid",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"success": true,
"failed": false,
"pending": false
},
"mocks": {
"nwc": {
"lookup_invoice": [
{
"request_type": "json",
"request_body": {
"method": "lookup_invoice",
"params": {
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
}
},
"response_type": "json",
"response": {
"result_type": "lookup_invoice",
"result": {
"type": "incoming",
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Test Invoice",
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"amount": 123,
"fees_paid": 123,
"created_at": 0,
"expires_at": 0,
"settled_at": 1,
"metadata": {}
}
}
}
]
}
}
},
{
"description": "failed",
"description1": "pending should be false in the 'expect', this is a bug",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"success": false,
"failed": true,
"pending": true
},
"mocks": {
"nwc": [
{
"description": "nwc.py doesn't handle the 'failed' status for `get_invoice_status`"
}
]
}
},
{
"description": "pending",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"success": false,
"failed": false,
"pending": true
},
"mocks": {
"nwc": {
"lookup_invoice": [
{
"request_type": "json",
"request_body": {
"method": "lookup_invoice",
"params": {
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
}
},
"response_type": "json",
"response": {
"result_type": "lookup_invoice",
"result": {
"type": "incoming",
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Test Invoice",
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"amount": 123,
"fees_paid": 0,
"created_at": 0,
"expires_at": 0,
"metadata": {}
}
}
}
]
}
}
}
]
},
"get_payment_status": {
"mocks": {
"nwc": {
"lookup_invoice": {}
},
"nwc-bad": {
"lookup_invoice": {}
}
},
"tests": [
{
"description": "paid",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"fee_msat": 1000,
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
"success": true,
"failed": false,
"pending": false
},
"mocks": {
"nwc": {
"lookup_invoice": [
{
"request_type": "json",
"request_body": {
"method": "lookup_invoice",
"params": {
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
}
},
"response_type": "json",
"response": {
"result_type": "lookup_invoice",
"result": {
"type": "incoming",
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Test Invoice",
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"amount": 123,
"fees_paid": 1000,
"created_at": 0,
"expires_at": 0,
"settled_at": 1,
"metadata": {}
}
}
}
]
}
}
},
{
"description": "failed",
"description1": "pending should be false in the 'expect', this is a bug",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"preimage": null,
"success": false,
"failed": true,
"pending": true
},
"mocks": {
"nwc": [
{
"description": "nwc.py doesn't handle the 'failed' status for `get_payment_status`"
}
]
}
},
{
"description": "pending",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"preimage": null,
"success": false,
"failed": false,
"pending": true
},
"mocks": {
"nwc": {
"lookup_invoice": [
{
"request_type": "json",
"request_body": {
"method": "lookup_invoice",
"params": {
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
}
},
"response_type": "json",
"response": {
"result_type": "lookup_invoice",
"result": {
"type": "incoming",
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Test Invoice",
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"amount": 123,
"fees_paid": 0,
"created_at": 0,
"expires_at": 0,
"metadata": {}
}
}
}
]
}
}
}
]
}
}
}

View File

@ -0,0 +1,418 @@
{
"funding_sources": {
"nwc-bad": {
"description": "NWC service that supports only pay_invoice",
"wallet_class": "NWCWallet",
"settings": {
"nwc_pairing_url": "nostr+walletconnect://be927be01ce2b3ab0fc33ffec6c6ab590381d7fc883a392d163d3966fc5840b3?relay=ws://127.0.0.1:8555&secret=d1b1d3b0f4a1fcba4c15094d34ff0569cae3c8c7af939b3473ccd564cce3bfa3"
},
"mock_settings": {
"service_public_key": "be927be01ce2b3ab0fc33ffec6c6ab590381d7fc883a392d163d3966fc5840b3",
"service_private_key": "ad6a224c9c2f2a7ac7181092348d99671a94f28974e331e0f0afe3bcdab72bed",
"user_public_key": "bef38893a567e717110478cc5729f4bf881727f5a7d7859edad2b2b21ee81f7e",
"user_private_key": "d1b1d3b0f4a1fcba4c15094d34ff0569cae3c8c7af939b3473ccd564cce3bfa3",
"port": 8555,
"supported_methods": ["pay_invoice"]
}
}
},
"functions": {
"status": {
"mocks": {
"nwc-bad": {
"get_balance": {}
}
},
"tests": [
{
"description": "success",
"call_params": {},
"expect": {
"error_message": null,
"balance_msat": 0
},
"mocks": {
"nwc-bad": {}
}
}
]
},
"create_invoice": {
"mocks": {
"nwc-bad": {
"make_invoice": {}
}
},
"tests": [
{
"description": "success",
"call_params": {
"amount": 555,
"memo": "Test Invoice",
"label": "test-label"
},
"expect": {
"error_message": "make_invoice is not supported by this NWC service.",
"success": false
},
"mocks": {
"nwc-bad": {}
}
}
]
},
"pay_invoice": {
"mocks": {
"nwc-bad": {
"pay_invoice": {},
"lookup_invoice": {}
}
},
"tests": [
{
"description": "success",
"call_params": {
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit_msat": 25000
},
"expect": {
"error_message": null,
"success": true,
"pending": false,
"failed": false,
"checking_id": "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925",
"fee_msat": 50,
"preimage": "0000000000000000000000000000000000000000000000000000000000000000"
},
"mocks": {
"nwc-bad": {
"pay_invoice": [
{
"request_type": "json",
"request_body": {
"method": "pay_invoice",
"params": {
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
}
},
"response_type": "json",
"response": {
"result_type": "pay_invoice",
"result": {
"preimage": "0000000000000000000000000000000000000000000000000000000000000000"
}
}
}
]
}
}
},
{
"description": "failed",
"call_params": {
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit_msat": 25000
},
"expect": {
"success": false,
"pending": false,
"failed": true,
"checking_id": null,
"fee_msat": null,
"preimage": null
},
"mocks": {
"nwc-bad": {
"pay_invoice": [
{
"request_type": "json",
"request_body": {
"method": "pay_invoice",
"params": {
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
}
},
"response_type": "json",
"response": {
"result_type": "pay_invoice",
"error": {
"code": "TEST ERROR",
"message": "test-error"
}
}
}
]
}
}
},
{
"description": "error",
"call_params": {
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit_msat": 25000
},
"expect": {
"success": false,
"pending": true,
"failed": false,
"checking_id": null,
"fee_msat": null,
"preimage": null,
"error_message": "TEST ERROR test-error"
},
"mocks": {
"nwc-bad": {
"pay_invoice": [
{
"request_type": "json",
"request_body": {
"method": "pay_invoice",
"params": {
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
}
},
"response_type": "json",
"response": {
"result_type": "pay_invoice",
"error": {
"code": "TEST ERROR",
"message": "test-error"
}
}
}
]
}
}
}
]
},
"get_invoice_status": {
"mocks": {
"nwc-bad": {
"lookup_invoice": {}
}
},
"tests": [
{
"description": "paid",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"success": false,
"failed": false,
"pending": true
},
"mocks": {
"nwc-bad": {
"lookup_invoice": [
{
"request_type": "json",
"request_body": {
"method": "lookup_invoice",
"params": {
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
}
},
"response_type": "json",
"response": {
"result_type": "lookup_invoice",
"result": {
"type": "incoming",
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Test Invoice",
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"amount": 123,
"fees_paid": 123,
"created_at": 0,
"expires_at": 0,
"settled_at": 1,
"metadata": {}
}
}
}
]
}
}
},
{
"description": "failed",
"description1": "pending should be false in the 'expect', this is a bug",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"success": false,
"failed": false,
"pending": true
},
"mocks": {
"nwc-bad": [
{
"description": "nwc.py doesn't handle the 'failed' status for `get_invoice_status`"
}
]
}
},
{
"description": "pending",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"success": false,
"failed": false,
"pending": true
},
"mocks": {
"nwc-bad": {
"lookup_invoice": [
{
"request_type": "json",
"request_body": {
"method": "lookup_invoice",
"params": {
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
}
},
"response_type": "json",
"response": {
"result_type": "lookup_invoice",
"result": {
"type": "incoming",
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Test Invoice",
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"amount": 123,
"fees_paid": 0,
"created_at": 0,
"expires_at": 0,
"metadata": {}
}
}
}
]
}
}
}
]
},
"get_payment_status": {
"mocks": {
"nwc-bad": {
"lookup_invoice": {}
}
},
"tests": [
{
"description": "paid",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"fee_msat": null,
"preimage": null,
"success": false,
"failed": false,
"pending": true
},
"mocks": {
"nwc-bad": {
"lookup_invoice": [
{
"request_type": "json",
"request_body": {
"method": "lookup_invoice",
"params": {
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
}
},
"response_type": "json",
"response": {
"result_type": "lookup_invoice",
"result": {
"type": "incoming",
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Test Invoice",
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"amount": 123,
"fees_paid": 1000,
"created_at": 0,
"expires_at": 0,
"settled_at": 1,
"metadata": {}
}
}
}
]
}
}
},
{
"description": "failed",
"description1": "pending should be false in the 'expect', this is a bug",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"preimage": null,
"success": false,
"failed": true,
"pending": true
},
"mocks": {
"nwc-bad": [
{
"description": "nwc.py doesn't handle the 'failed' status for `get_payment_status`"
}
]
}
},
{
"description": "pending",
"call_params": {
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
},
"expect": {
"preimage": null,
"success": false,
"failed": false,
"pending": true
},
"mocks": {
"nwc-bad": {
"lookup_invoice": [
{
"request_type": "json",
"request_body": {
"method": "lookup_invoice",
"params": {
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
}
},
"response_type": "json",
"response": {
"result_type": "lookup_invoice",
"result": {
"type": "incoming",
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Test Invoice",
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"amount": 123,
"fees_paid": 0,
"created_at": 0,
"expires_at": 0,
"metadata": {}
}
}
}
]
}
}
}
]
}
}
}

View File

@ -8,6 +8,7 @@ class FundingSourceConfig(BaseModel):
skip: Optional[bool]
wallet_class: str
settings: dict
mock_settings: Optional[dict]
class FunctionMock(BaseModel):

View File

@ -0,0 +1,194 @@
import base64
import hashlib
import json
import time
from typing import Dict, cast
import pytest
import secp256k1
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
from websockets.server import serve as ws_serve
from lnbits.wallets.nwc import NWCWallet
from tests.wallets.helpers import (
WalletTest,
build_test_id,
check_assertions,
load_funding_source,
wallet_fixtures_from_json,
)
def encrypt_content(priv_key, dest_pub_key, content):
p = secp256k1.PublicKey(bytes.fromhex("02" + dest_pub_key), True)
shared = p.tweak_mul(bytes.fromhex(priv_key)).serialize()[1:]
iv = Random.new().read(AES.block_size)
aes = AES.new(shared, AES.MODE_CBC, iv)
content_bytes = content.encode("utf-8")
content_bytes = pad(content_bytes, AES.block_size)
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
iv_b64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
return encrypted_content
def decrypt_content(priv_key, source_pub_key, content):
p = secp256k1.PublicKey(bytes.fromhex("02" + source_pub_key), True)
shared = p.tweak_mul(bytes.fromhex(priv_key)).serialize()[1:]
(encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii"))
aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted_bytes = aes.decrypt(encrypted_content)
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
return decrypted_bytes.decode("utf-8")
def json_dumps(data):
if isinstance(data, Dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def sign_event(pub_key, priv_key, event):
signature_data = json_dumps(
[
0,
pub_key,
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = pub_key
s = secp256k1.PrivateKey(bytes.fromhex(priv_key))
signature = (s.schnorr_sign(bytes.fromhex(event_id), None, raw=True)).hex()
event["sig"] = signature
return event
async def handle(wallet, mock_settings, data, websocket, path):
async for message in websocket:
if not wallet:
continue
msg = json.loads(message)
if msg[0] == "REQ":
sub_id = msg[1]
sub_filter = msg[2]
kinds = sub_filter["kinds"]
if 13194 in kinds: # Send info event
event = {
"kind": 13194,
"content": " ".join(mock_settings["supported_methods"]),
"created_at": int(time.time()),
"tags": [],
}
sign_event(
mock_settings["service_public_key"],
mock_settings["service_private_key"],
event,
)
await websocket.send(json.dumps(["EVENT", sub_id, event]))
elif msg[0] == "EVENT":
event = msg[1]
decrypted_content = decrypt_content(
mock_settings["service_private_key"],
mock_settings["user_public_key"],
event["content"],
)
content = json.loads(decrypted_content)
mock = None
for m in data.mocks:
rb = m.request_body
if rb and rb["method"] == content["method"]:
p1 = rb["params"]
p2 = content["params"]
p1 = json_dumps({k: v for k, v in p1.items() if v is not None})
p2 = json_dumps({k: v for k, v in p2.items() if v is not None})
if p1 == p2:
mock = m
break
if mock:
sub_id = None
nwcwallet = cast(NWCWallet, wallet)
for subscription in nwcwallet.conn.subscriptions.values():
if subscription["event_id"] == event["id"]:
sub_id = subscription["sub_id"]
break
if sub_id:
response = mock.response
encrypted_content = encrypt_content(
mock_settings["service_private_key"],
mock_settings["user_public_key"],
json_dumps(response),
)
response_event = {
"kind": 23195,
"content": encrypted_content,
"created_at": int(time.time()),
"tags": [
["e", event["id"]],
["p", mock_settings["user_public_key"]],
],
}
sign_event(
mock_settings["service_public_key"],
mock_settings["service_private_key"],
response_event,
)
await websocket.send(json.dumps(["EVENT", sub_id, response_event]))
else:
raise Exception(
"No mock found for "
+ content["method"]
+ " "
+ json_dumps(content["params"])
)
async def run(data: WalletTest):
if data.skip:
pytest.skip()
wallet = None
mock_settings = data.funding_source.mock_settings
if mock_settings is None:
return
async def handler(websocket, path):
return await handle(wallet, mock_settings, data, websocket, path)
if mock_settings is not None:
async with ws_serve(handler, "localhost", mock_settings["port"]) as server:
await server.start_serving()
wallet = load_funding_source(data.funding_source)
await check_assertions(wallet, data)
nwcwallet = cast(NWCWallet, wallet)
await nwcwallet.cleanup()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_data",
wallet_fixtures_from_json("tests/wallets/fixtures/json/fixtures_nwc.json"),
ids=build_test_id,
)
async def test_nwc_wallet(test_data: WalletTest):
await run(test_data)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_data",
wallet_fixtures_from_json("tests/wallets/fixtures/json/fixtures_nwc_bad.json"),
ids=build_test_id,
)
async def test_nwc_wallet_bad(test_data: WalletTest):
await run(test_data)