diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7fa81f8..054d6ec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,7 @@ on: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout code @@ -50,3 +51,16 @@ jobs: pip install pytest-asyncio cd $cdir pytest tests/integration/*.py -s + + - name: Show integration service logs + if: failure() + run: | + docker ps -a + docker logs --tail 200 lnbits_nwcprovider_ext_nostr_test || true + docker exec lnbits_nwcprovider_ext_lnbits_test tail -n 200 /tmp/lnbits.log || true + + - name: Stop integration services + if: always() + run: | + docker rm -f lnbits_nwcprovider_ext_lnbits_test lnbits_nwcprovider_ext_nostr_test || true + docker network rm lnbits_nwcprovider_ext_test_network || true diff --git a/README.md b/README.md index b222469..1265fed 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ + + + + LNbits + + + +[![License: MIT](https://img.shields.io/badge/License-MIT-success?logo=open-source-initiative&logoColor=white)](./LICENSE) +[![Built for LNbits](https://img.shields.io/badge/Built%20for-LNbits-4D4DFF?logo=lightning&logoColor=white)](https://github.com/lnbits/lnbits) + # NWC Service Provider Extension for [LNbits](https://github.com/lnbits/lnbits) Easily connect your LNbits wallets via [NWC](https://nwc.dev/). @@ -23,7 +33,7 @@ Before you can start using the extension, you need to configure a Nostr relay. This is the easiest option for most users. It allows you to run LNbits on a private network while connecting to NWC apps through a public Nostr relay. 1. Choose a Nostr relay that supports NWC connections. -2. Open the **NWC Service Provider settings** (gear icon in the top-right corner). +2. Navigate to the **NWC Service Provider admin page** at `/nwcprovider/admin` (requires LNbits admin privileges). 1. Enter your chosen relay URL in the **Nostr Relay URL** field (e.g. `wss://relay.nostrconnect.com`). 2. Click **Save**. @@ -50,7 +60,7 @@ This is the easiest option for most users. It allows you to run LNbits on a priv # Extension Configuration -The "Configuration" page of the NWC Service Provider extension can be accessed by clicking the gear icon in the top-right corner of the extension page. +The configuration page of the NWC Service Provider extension is available at `/nwcprovider/admin` and requires LNbits admin privileges. ### Configuration Options: @@ -68,3 +78,10 @@ The "Configuration" page of the NWC Service Provider extension can be accessed b > For example, in shared or community lnbits instances, where users are unaware of this functionality, they might assume a payment has failed and attempt to pay a new invoice with a different wallet, only for the instance to come back online and process the original payment request, potentially leading to duplicate payments. > > For this reason, unless you are trying to tackle this specific issue, it is recommended to leave this setting at `0`. + +## Powered by LNbits + +[LNbits](https://lnbits.com) is a free and open-source lightning accounts system. + +[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) +[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) diff --git a/config.json b/config.json index e74cc86..187cb5d 100644 --- a/config.json +++ b/config.json @@ -1,8 +1,11 @@ { + "id": "nwcprovider", "name": "NWC Service Provider", + "repo": "https://github.com/lnbits/nwcprovider", "short_description": "A NWC service provider for LNbits", + "description": "", "tile": "/nwcprovider/static/image/nwcprovider.png", - "version": "1.1.2", + "version": "1.1.3-aio.1", "min_lnbits_version": "1.4.0", "contributors": [ { @@ -22,6 +25,10 @@ } ], "images": [ + { + "uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/nwc_thumbnail.png", + "link": "https://www.youtube.com/watch?v=0c77d2q-_PQ" + }, { "uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/1.png" }, @@ -34,5 +41,9 @@ ], "description_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/description.md", "terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/toc.md", - "license": "MIT" + "license": "MIT", + "paid_features": "", + "tags": ["Nostr", "Wallet"], + "donate": "", + "hidden": false } diff --git a/description.md b/description.md index bc65726..edb14d1 100644 --- a/description.md +++ b/description.md @@ -1,3 +1,10 @@ -NWC Service Provider Extension for https://github.com/lnbits/lnbits +Connect your LNbits wallet to apps using the Nostr Wallet Connect protocol. -Easily connect your LNbits wallets via https://nwc.dev/ +Its functions include: + +- Exposing your wallet via the NWC protocol +- Connecting to NWC-compatible applications +- Managing wallet connection permissions +- Supporting the nwc.dev standard + +Enables seamless integration with NWC-compatible apps like Alby, Amethyst, Jumble, Buho and other Nostr clients that support wallet connections. diff --git a/nwcp.py b/nwcp.py index efa5279..bb3afa6 100644 --- a/nwcp.py +++ b/nwcp.py @@ -27,6 +27,7 @@ class MainSubscription: self.responses_eose = False self.events: dict[str, dict] = {} self.responses: list[str] = [] + self.seen_requests: dict[str, int] = {} def get_stale(self) -> list[dict]: """ @@ -49,6 +50,7 @@ class MainSubscription: """ Garbage collection, remove all the events that have a response older than expire seconds (defaults to 1 hour if 0 or None) + and all seen requests that are expired """ expire = expire or 1 * 60 * 60 now = int(time.time()) @@ -65,6 +67,11 @@ class MainSubscription: if len(deleted_ids) > 0: logger.debug("Garbage collected " + str(len(deleted_ids)) + " events") + # Clean seen requests + for event_id, expiry in list(self.seen_requests.items()): + if expiry < now: + del self.seen_requests[event_id] + class Config: arbitrary_types_allowed = True @@ -124,8 +131,11 @@ class NWCServiceProvider: # Periodic info event resend loop self.info_event_task = None + # Requests are handled independently from the relay receive loop. + self.request_tasks: set[asyncio.Task[list[dict]]] = set() + # Subscription - self.sub = None + self.sub: MainSubscription | None = None self.rate_limit: dict[str, RateLimit] = {} # websocket connection @@ -142,6 +152,8 @@ class NWCServiceProvider: # (handles reboots) self.handle_missed_events = handle_missed_events + self.event_max_age = self.handle_missed_events or 5 * 60 + logger.info( "NWC Service is ready. relay: " + str(self.relay) @@ -270,11 +282,16 @@ class NWCServiceProvider: await asyncio.sleep(limit.backoff) limit.last_attempt_time = int(time.time()) + def _create_subscription(self) -> MainSubscription: + sub = MainSubscription() + self.sub = sub + return sub + async def _subscribe(self): """ [Re]Subscribe to receive nip 47 requests and responses from the relay """ - self.sub = MainSubscription() + sub = self._create_subscription() # Create requests subscription req_filter = { "kinds": [23194], @@ -282,17 +299,17 @@ class NWCServiceProvider: # Since the last handle_missed_events seconds (handles reboots) "since": int(time.time()) - self.handle_missed_events, } - self.sub.requests_sub_id = self._get_new_subid() + sub.requests_sub_id = self._get_new_subid() # Create responses subscription (needed to track previosly responded requests) res_filter = { "kinds": [23195], "authors": [self.public_key_hex], "since": int(time.time()) - self.handle_missed_events, } - self.sub.responses_sub_id = self._get_new_subid() + sub.responses_sub_id = self._get_new_subid() # Subscribe - await self._send(["REQ", self.sub.requests_sub_id, req_filter]) - await self._send(["REQ", self.sub.responses_sub_id, res_filter]) + await self._send(["REQ", sub.requests_sub_id, req_filter]) + await self._send(["REQ", sub.responses_sub_id, res_filter]) async def _on_connection(self, _): """ @@ -335,6 +352,19 @@ class NWCServiceProvider: """ Handle a nwc request """ + if not self.sub: + raise Exception("Subscription is not established") + sub = self.sub + + expire = sub.seen_requests.get(event["id"]) + if expire or event["created_at"] < int(time.time() - self.event_max_age): + raise Exception("Event is too old or already handled") + + expiration = self._extract_expiration_from_tags(event["tags"]) + if expiration <= 0: + expiration = int(time.time() + self.event_max_age) + sub.seen_requests[event["id"]] = expiration + nwc_pubkey = event["pubkey"] content = event["content"] # Decrypt the content @@ -404,6 +434,19 @@ class NWCServiceProvider: sent_events.append(res) return sent_events + def _log_request_task_exception(self, task: asyncio.Future[list[dict]]) -> None: + if task.cancelled(): + return + exception = task.exception() + if exception: + logger.error("Error handling request: " + str(exception)) + + def _dispatch_request(self, event: dict) -> None: + task = asyncio.create_task(self._handle_request(event)) + self.request_tasks.add(task) + task.add_done_callback(self.request_tasks.discard) + task.add_done_callback(self._log_request_task_exception) + def _extract_expiration_from_tags(self, tags: list) -> int: expiration = -1 for tag in tags: @@ -443,7 +486,7 @@ class NWCServiceProvider: # already handled or stale, all stale requests will be handled # later when eose is received if self.sub.requests_eose and self.sub.responses_eose: - await self._handle_request(event) + self._dispatch_request(event) elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id: # Ensure the response is from this service provider if event["pubkey"] != self.public_key_hex: @@ -471,7 +514,7 @@ class NWCServiceProvider: if self.sub.requests_eose and self.sub.responses_eose: stales = self.sub.get_stale() for stale in stales: - await self._handle_request(stale) + self._dispatch_request(stale) async def _on_closed_message(self, msg): if not self.sub: @@ -629,6 +672,12 @@ class NWCServiceProvider: self.info_event_task.cancel() except Exception as e: logger.warning("Error closing info event loop: " + str(e)) + request_tasks = list(self.request_tasks) + for task in request_tasks: + task.cancel() + if request_tasks: + await asyncio.gather(*request_tasks, return_exceptions=True) + self.request_tasks.clear() # close the websocket try: if self.ws: diff --git a/static/image/nwc_thumbnail.png b/static/image/nwc_thumbnail.png new file mode 100644 index 0000000..31b1c7d Binary files /dev/null and b/static/image/nwc_thumbnail.png differ diff --git a/tasks.py b/tasks.py index 70f3205..76aab58 100644 --- a/tasks.py +++ b/tasks.py @@ -34,6 +34,10 @@ from .paranoia import ( ) from .permission import nwc_permissions +PAYMENT_STATUS_POLL_INITIAL_INTERVAL_SECONDS = 1.0 +PAYMENT_STATUS_POLL_MAX_INTERVAL_SECONDS = 60.0 +PAYMENT_STATUS_POLL_BACKOFF_MULTIPLIER = 2.0 + async def _check(nwc: NWCKey | None, method: str) -> dict | None: # check @@ -110,6 +114,7 @@ async def _process_invoice( True # currently required by nip 47 specs, might change in future ) payment_status: PaymentStatus | None = None + poll_interval = PAYMENT_STATUS_POLL_INITIAL_INTERVAL_SECONDS while wait_for_preimage: payment_status = await check_transaction_status(wallet_id, payment_hash) if payment_status.success: @@ -122,7 +127,11 @@ async def _process_invoice( }, "in_budget": in_budget, } - await asyncio.sleep(0.05) + await asyncio.sleep(poll_interval) + poll_interval = min( + poll_interval * PAYMENT_STATUS_POLL_BACKOFF_MULTIPLIER, + PAYMENT_STATUS_POLL_MAX_INTERVAL_SECONDS, + ) if not payment_status: raise Exception("Payment status not found") return { @@ -231,6 +240,7 @@ async def _on_multi_pay_invoice( results.append(r) except Exception as e: results.append((None, {"code": "INTERNAL", "message": str(e)}, [])) + await asyncio.sleep(0) # await log_nwc(pubkey, payload) return results @@ -254,9 +264,11 @@ async def _on_make_invoice( # Ensures amount is provided if not amount_msats: raise Exception("Missing amount") - description = params.get("description", "") - description_hash = params.get("description_hash", None) - expiry = params.get("expiry", None) + # Optional params may arrive as explicit JSON null, which dict.get does + # not default, so coerce here rather than trusting the fallback. + description = params.get("description") or "" + description_hash = params.get("description_hash") or None + expiry = params.get("expiry") or None # hardening # assert_valid_msats(amount_msats) @@ -274,7 +286,7 @@ async def _on_make_invoice( currency="sat", memo=description, description_hash=bytes.fromhex(description_hash) if description_hash else None, - unhashed_description=description.encode("utf-8"), + unhashed_description=description.encode("utf-8") if description else None, expiry=expiry, ) payment_hash = payment.payment_hash @@ -384,12 +396,12 @@ async def _on_list_transactions( if not nwc: raise Exception("Pubkey has no associated wallet") params = payload.get("params", 0) - tfrom = params.get("from", 0) - tuntil = params.get("until", int(time.time())) - limit = params.get("limit", 10) - offset = params.get("offset", 0) - unpaid = params.get("unpaid", False) - tx_type = params.get("type", "") + tfrom = params.get("from") or 0 + tuntil = params.get("until") or int(time.time()) + limit = params.get("limit") or 10 + offset = params.get("offset") or 0 + unpaid = params.get("unpaid") or False + tx_type = params.get("type") or "" # hardening # assert_valid_positive_int(tfrom) @@ -425,7 +437,11 @@ async def _on_list_transactions( { "type": "outgoing" if p.is_out else "incoming", "invoice": p.bolt11, - "description": invoice_data.description, + # Fallback chain so a human-readable description reaches + # the NWC client. Mirror of `_on_lookup_invoice` + "description": ( + (p.extra or {}).get("comment") or invoice_data.description or p.memo + ), "description_hash": invoice_data.description_hash, "preimage": p.preimage if is_settled or p.is_in else None, "payment_hash": p.payment_hash, @@ -436,6 +452,7 @@ async def _on_list_transactions( "metadata": {}, } ) + await asyncio.sleep(0) # await log_nwc(pubkey, payload) return [({"transactions": transactions}, None, [])] diff --git a/tests/integration/.env b/tests/integration/.env index 05905b6..1fe75bd 100644 --- a/tests/integration/.env +++ b/tests/integration/.env @@ -199,7 +199,9 @@ LNBITS_HIDE_API=false # Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart. # The extension must be removed from this list in order to not be re-installed. -LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos" +# The tpos extension is no longer shipped with the LNbits dev tree. Keep the +# integration fixture focused on the extension under test. +LNBITS_EXTENSIONS_DEFAULT_INSTALL="" # Database: to use SQLite, specify LNBITS_DATA_FOLDER # to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://... @@ -249,4 +251,4 @@ LOG_ROTATION="100 MB" LOG_RETENTION="3 months" # for database cleanup commands -# CLEANUP_WALLETS_DAYS=90 \ No newline at end of file +# CLEANUP_WALLETS_DAYS=90 diff --git a/tests/integration/start.sh b/tests/integration/start.sh index 825b90e..2a669c1 100644 --- a/tests/integration/start.sh +++ b/tests/integration/start.sh @@ -15,10 +15,16 @@ if [ "`cat .v039fk_lnbits_integration_test_folder`" != "yes v039fk_lnbits_integr exit 1 fi -# Start nostr Relay +# Start nostr Relay. The image defaults to the `strfry` user (UID 1000), +# which is not necessarily the user running the CI job. Create the bind mount +# first and run the relay as the current user so LMDB can initialize its files. +id=$(id -u) +gid=$(id -g) +mkdir -p strfry-data docker run --name=lnbits_nwcprovider_ext_nostr_test \ -d \ --rm \ +--user $id:$gid \ -v $PWD/strfry.conf:/etc/strfry.conf:Z \ -v $PWD/strfry-data:/app/strfry-db:Z \ -p 7777:7777 \ @@ -28,9 +34,18 @@ ghcr.io/hoytech/strfry:latest rm -Rf lnbits_itest_data unzip data.zip -id=$(id -u) -gid=$(id -g) +# The fixture was created with the standalone tpos extension installed. tpos +# is no longer part of the LNbits dev tree, so leaving its database metadata in +# the fixture makes current LNbits attempt to import a module that is absent. +# The integration suite only exercises nwcprovider. +python3 - <<'PY' +import sqlite3 +with sqlite3.connect("lnbits_itest_data/database.sqlite3") as conn: + conn.execute("DELETE FROM installed_extensions WHERE id = 'tpos'") + conn.execute("DELETE FROM dbversions WHERE db = 'tpos'") +PY +rm -f lnbits_itest_data/ext_tpos.sqlite3 lnbits_itest_data/zips/tpos.zip docker run --name=lnbits_nwcprovider_ext_lnbits_test \ -d \ @@ -45,24 +60,50 @@ docker run --name=lnbits_nwcprovider_ext_lnbits_test \ -v ${PWD}/../../.devcontainer/pre-setup.sh:/pre-setup.sh:ro \ mcr.microsoft.com/devcontainers/python:1-3.12 bash -c "while true; do sleep 1000; done" -docker network create lnbits_nwcprovider_ext_test_network || true -docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr|| true -docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits|| true +if ! docker network inspect lnbits_nwcprovider_ext_test_network >/dev/null 2>&1; then + docker network create lnbits_nwcprovider_ext_test_network +fi +docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr +docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "id -u $id &>/dev/null || useradd -m -u $id tester" docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "bash /pre-setup.sh" docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "curl -sSL https://install.python-poetry.org | python3 -" -set +e docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && bash /setup.sh /nwcprovider" docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "ln -s /app/.env \$HOME/lnbits/.env" -ARGS="" if [ "$HEADLESS" != "" ]; then - ARGS="-d" -fi + # Keep the server log inside the container so a failed health check can + # show the actual startup error instead of silently swallowing it. + docker exec --user $id:$gid -d lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits > /tmp/lnbits.log 2>&1" -docker exec --user $id:$gid $ARGS lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits" - + wait_for_http() { + local service="$1" + local url="$2" + local timeout_seconds="$3" + local deadline=$((SECONDS + timeout_seconds)) + + until curl --fail --silent --show-error --max-time 2 "$url" >/dev/null 2>&1; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "Timed out waiting for $service at $url" >&2 + docker ps -a >&2 + if [ "$service" = "LNbits" ]; then + docker exec lnbits_nwcprovider_ext_lnbits_test tail -n 100 /tmp/lnbits.log >&2 || true + else + docker logs --tail 100 lnbits_nwcprovider_ext_nostr_test >&2 || true + fi + return 1 + fi + sleep 1 + done + } + + # LNbits may need a few minutes for a fresh database migration. + wait_for_http "nostr relay" "http://localhost:7777" 180 + wait_for_http "LNbits" "http://localhost:5002" 180 +else + docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits" +fi diff --git a/tests/integration/test_all.py b/tests/integration/test_all.py index fb33c1f..2752e01 100644 --- a/tests/integration/test_all.py +++ b/tests/integration/test_all.py @@ -12,6 +12,10 @@ from loguru import logger from pynostr.key import PrivateKey from websockets.legacy.client import connect +SERVICE_STARTUP_TIMEOUT_SECONDS = 180 +NWC_CONNECTION_TIMEOUT_SECONDS = 60 +NWC_RESPONSE_TIMEOUT_SECONDS = 60 + wallets = { "wallet1": { "name": "wallet1", @@ -41,35 +45,29 @@ wallets = { async def check_services(): - # wait for http server in localhost:7777 - while True: + async def wait_for_service(name: str, url: str): + deadline = time.monotonic() + SERVICE_STARTUP_TIMEOUT_SECONDS try: async with httpx.AsyncClient() as client: - resp = await client.get("http://localhost:7777") - assert resp.status_code == 200 - break - except Exception: - logger.info("Waiting for nostr relay @ http://localhost:7777") - logger.info( - """Please start the required services by running\ - `bash start.sh` if you haven't already""" - ) - await asyncio.sleep(1) + while True: + try: + resp = await client.get(url) + if resp.status_code == 200: + return + except httpx.HTTPError: + pass + if time.monotonic() >= deadline: + raise RuntimeError( + f"Timed out waiting for {name} at {url}. " + "Start the integration services with `bash start.sh`." + ) + logger.info(f"Waiting for {name} @ {url}") + await asyncio.sleep(1) + except httpx.HTTPError as exc: + raise RuntimeError(f"Unable to check {name} at {url}: {exc}") from exc - # wait lnbits @ localhost:5000 - while True: - try: - async with httpx.AsyncClient() as client: - resp = await client.get("http://localhost:5002") - assert resp.status_code == 200 - break - except Exception: - logger.info("Waiting for lnbits @ http://localhost:5002") - logger.info( - """Please start the required services by running\ - `bash start.sh` if you haven't already""" - ) - await asyncio.sleep(1) + await wait_for_service("nostr relay", "http://localhost:7777") + await wait_for_service("LNbits", "http://localhost:5002") async def get_wallet_balance(w: str): @@ -179,15 +177,21 @@ class NWCWallet: async def _wait_for_connection(self): while not self.connected: - try: - await asyncio.sleep(0.2) - except asyncio.CancelledError: - logger.debug("Connection wait cancelled") - return + await asyncio.sleep(0.2) async def start(self): self.task = asyncio.create_task(self._run()) - await self._wait_for_connection() + try: + await asyncio.wait_for( + self._wait_for_connection(), timeout=NWC_CONNECTION_TIMEOUT_SECONDS + ) + except asyncio.TimeoutError as exc: + self.task.cancel() + await asyncio.gather(self.task, return_exceptions=True) + self.task = None + raise RuntimeError( + f"Timed out connecting to NWC relay {self.relay}" + ) from exc def _is_shutting_down(self): return self.shutdown @@ -304,7 +308,11 @@ class NWCWallet: await self.ws.send(self._json_dumps(["EVENT", event])) async def wait_for( - self, result_type, callback=None, on_error_callback=None, timeout=60000 + self, + result_type, + callback=None, + on_error_callback=None, + timeout=NWC_RESPONSE_TIMEOUT_SECONDS, ): now = time.time() while True: diff --git a/tests/unit/test_nwcp.py b/tests/unit/test_nwcp.py index 800e853..a631037 100644 --- a/tests/unit/test_nwcp.py +++ b/tests/unit/test_nwcp.py @@ -2,6 +2,7 @@ import asyncio import json import random import string +import time import pytest from loguru import logger @@ -75,6 +76,18 @@ def test_signverify(nwc_service_provider, nwc_service_provider2): assert nwc_service_provider2._verify_event(signed) +def test_default_event_max_age(nwc_service_provider): + assert nwc_service_provider.event_max_age == 5 * 60 + assert ( + NWCServiceProvider( + "d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58", + "", + handle_missed_events=123, + ).event_max_age + == 123 + ) + + @pytest.mark.asyncio async def test_handle(nwc_service_provider, nwc_service_provider2): content = nwc_service_provider._json_dumps( @@ -87,7 +100,7 @@ async def test_handle(nwc_service_provider, nwc_service_provider2): "kind": 23194, "content": content, "tags": [["p", nwc_service_provider2.public_key_hex]], - "created_at": 1234567890, + "created_at": int(time.time()), } signed = nwc_service_provider._sign_event(event) @@ -101,6 +114,7 @@ async def test_handle(nwc_service_provider, nwc_service_provider2): pass nwc_service_provider2._send = _send_pass + nwc_service_provider2._create_subscription() nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice) sent_events = await nwc_service_provider2._handle_request(signed) assert len(sent_events) == 1 @@ -128,6 +142,111 @@ async def test_handle(nwc_service_provider, nwc_service_provider2): assert p_tag[0][1] == nwc_service_provider.public_key_hex +@pytest.mark.asyncio +async def test_handle_rejects_same_event_replay( + nwc_service_provider, nwc_service_provider2 +): + content = nwc_service_provider._json_dumps( + {"method": "pay_invoice", "params": {"invoice": "abc"}} + ) + content = nwc_service_provider.private_key.encrypt_message( + content, nwc_service_provider2.public_key_hex + ) + event = { + "kind": 23194, + "content": content, + "tags": [["p", nwc_service_provider2.public_key_hex]], + "created_at": int(time.time()), + } + signed = nwc_service_provider._sign_event(event) + calls = 0 + + async def _handle_pay_invoice(provider, pubkey, content): + nonlocal calls + calls += 1 + return [({"preimage": "00000"}, None, [])] + + async def _send_pass(obj): + pass + + nwc_service_provider2._send = _send_pass + nwc_service_provider2._create_subscription() + nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice) + + await nwc_service_provider2._handle_request(signed) + + with pytest.raises(Exception, match="already handled"): + await nwc_service_provider2._handle_request(signed) + + assert calls == 1 + + +@pytest.mark.asyncio +async def test_relay_dispatches_requests_without_waiting_for_previous_request( + nwc_service_provider, monkeypatch +): + sub = nwc_service_provider._create_subscription() + sub.requests_sub_id = "requests" + sub.requests_eose = True + sub.responses_eose = True + monkeypatch.setattr(nwc_service_provider, "_verify_event", lambda event: True) + + first_request_finished = asyncio.Event() + second_request_finished = asyncio.Event() + + async def _handle_request(event): + if event["id"] == "first": + await first_request_finished.wait() + else: + second_request_finished.set() + return [] + + monkeypatch.setattr(nwc_service_provider, "_handle_request", _handle_request) + + def request(event_id): + return json.dumps( + [ + "EVENT", + sub.requests_sub_id, + { + "id": event_id, + "kind": 23194, + "pubkey": "a" * 64, + "content": "", + "tags": [["p", nwc_service_provider.public_key_hex]], + "created_at": int(time.time()), + }, + ] + ) + + await nwc_service_provider._on_message(None, request("first")) + await nwc_service_provider._on_message(None, request("second")) + + await asyncio.wait_for(second_request_finished.wait(), timeout=1) + assert not first_request_finished.is_set() + + first_request_finished.set() + await asyncio.gather(*list(nwc_service_provider.request_tasks)) + + +@pytest.mark.asyncio +async def test_cleanup_cancels_pending_request_tasks(nwc_service_provider, monkeypatch): + request_started = asyncio.Event() + + async def _handle_request(event): + request_started.set() + await asyncio.Event().wait() + return [] + + monkeypatch.setattr(nwc_service_provider, "_handle_request", _handle_request) + nwc_service_provider._dispatch_request({"id": "pending"}) + await request_started.wait() + + await nwc_service_provider.cleanup() + + assert not nwc_service_provider.request_tasks + + @pytest.mark.asyncio async def test_send_info_event(nwc_service_provider): """_send_info_event should publish a signed kind-13194 event.""" diff --git a/tests/unit/test_tasks.py b/tests/unit/test_tasks.py index cca51bb..6dfeb55 100644 --- a/tests/unit/test_tasks.py +++ b/tests/unit/test_tasks.py @@ -29,3 +29,126 @@ async def test_process_invoice_returns_payment_failed_on_failed_status(monkeypat assert result["error"]["code"] == "PAYMENT_FAILED" assert result["error"]["message"] == "Payment failed." assert result["in_budget"] is True + + +@pytest.mark.asyncio +async def test_process_invoice_backs_off_pending_payment_polling_to_configured_max( + monkeypatch, +): + async def fake_tracked_spend_nwc(*args, **kwargs): + return True, "a" * 64 + + pending = SimpleNamespace(success=False, failed=False) + statuses = iter( + [pending] * 8 + + [ + SimpleNamespace( + success=True, + failed=False, + preimage="b" * 64, + fee_msat=10, + paid=True, + ) + ] + ) + + async def fake_check_transaction_status(wallet_id: str, payment_hash: str): + return next(statuses) + + sleep_calls: list[float] = [] + + async def fake_sleep(delay: float): + sleep_calls.append(delay) + + monkeypatch.setattr(tasks, "tracked_spend_nwc", fake_tracked_spend_nwc) + monkeypatch.setattr( + tasks, "check_transaction_status", fake_check_transaction_status + ) + monkeypatch.setattr(tasks.asyncio, "sleep", fake_sleep) + + result = await tasks._process_invoice( + wallet_id="wallet123", + pubkey="a" * 64, + invoice="lnbc1example", + amount_msats=1000, + description="test", + ) + + assert sleep_calls == [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 60.0, 60.0] + assert result["preimage"] == "b" * 64 + assert result["fee_msats"] == 10 + assert result["paid"] is True + + +def _stub_make_invoice(monkeypatch, captured: dict): + async def fake_get_nwc(*args, **kwargs): + return SimpleNamespace(wallet="wallet123") + + async def fake_check(nwc, method): + return None + + async def fake_create_invoice(**kwargs): + captured.update(kwargs) + return SimpleNamespace(payment_hash="b" * 64, bolt11="lnbc1example") + + async def fake_check_transaction_status(wallet_id: str, payment_hash: str): + return SimpleNamespace(preimage=None) + + monkeypatch.setattr(tasks, "get_nwc", fake_get_nwc) + monkeypatch.setattr(tasks, "_check", fake_check) + monkeypatch.setattr(tasks, "create_invoice", fake_create_invoice) + monkeypatch.setattr( + tasks, "check_transaction_status", fake_check_transaction_status + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "params", + [ + # rust-nostr, Alby JS SDK: unused optional fields are omitted + {"amount": 21000}, + # Amethyst: unused optional fields are sent as explicit null + { + "amount": 21000, + "description": None, + "description_hash": None, + "expiry": None, + }, + ], +) +async def test_make_invoice_accepts_absent_or_null_optional_params(monkeypatch, params): + captured: dict = {} + _stub_make_invoice(monkeypatch, captured) + + [(result, error, _)] = await tasks._on_make_invoice( + SimpleNamespace(), "a" * 64, {"params": params} + ) + + assert error is None + assert result["invoice"] == "lnbc1example" + assert result["description"] == "" + assert "expires_at" not in result + assert captured["memo"] == "" + assert captured["description_hash"] is None + assert captured["unhashed_description"] is None + assert captured["expiry"] is None + + +@pytest.mark.asyncio +async def test_make_invoice_passes_description_and_expiry_through(monkeypatch): + captured: dict = {} + _stub_make_invoice(monkeypatch, captured) + + [(result, error, _)] = await tasks._on_make_invoice( + SimpleNamespace(), + "a" * 64, + {"params": {"amount": 21000, "description": "coffee", "expiry": 600}}, + ) + + assert error is None + assert captured["memo"] == "coffee" + assert captured["unhashed_description"] == b"coffee" + assert captured["expiry"] == 600 + assert result["description"] == "coffee" + assert "expires_at" in result