diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 054d6ec..7fa81f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,6 @@ on: jobs: test: runs-on: ubuntu-latest - timeout-minutes: 15 steps: - name: Checkout code @@ -51,16 +50,3 @@ 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 1265fed..b222469 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,3 @@ - - - - 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/). @@ -33,7 +23,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. Navigate to the **NWC Service Provider admin page** at `/nwcprovider/admin` (requires LNbits admin privileges). +2. Open the **NWC Service Provider settings** (gear icon in the top-right corner). 1. Enter your chosen relay URL in the **Nostr Relay URL** field (e.g. `wss://relay.nostrconnect.com`). 2. Click **Save**. @@ -60,7 +50,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 is available at `/nwcprovider/admin` and requires LNbits admin privileges. +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. ### Configuration Options: @@ -78,10 +68,3 @@ The configuration page of the NWC Service Provider extension is available at `/n > 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 187cb5d..e74cc86 100644 --- a/config.json +++ b/config.json @@ -1,11 +1,8 @@ { - "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.3-aio.1", + "version": "1.1.2", "min_lnbits_version": "1.4.0", "contributors": [ { @@ -25,10 +22,6 @@ } ], "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" }, @@ -41,9 +34,5 @@ ], "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", - "paid_features": "", - "tags": ["Nostr", "Wallet"], - "donate": "", - "hidden": false + "license": "MIT" } diff --git a/description.md b/description.md index edb14d1..bc65726 100644 --- a/description.md +++ b/description.md @@ -1,10 +1,3 @@ -Connect your LNbits wallet to apps using the Nostr Wallet Connect protocol. +NWC Service Provider Extension for https://github.com/lnbits/lnbits -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. +Easily connect your LNbits wallets via https://nwc.dev/ diff --git a/nwcp.py b/nwcp.py index bb3afa6..efa5279 100644 --- a/nwcp.py +++ b/nwcp.py @@ -27,7 +27,6 @@ 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]: """ @@ -50,7 +49,6 @@ 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()) @@ -67,11 +65,6 @@ 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 @@ -131,11 +124,8 @@ 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: MainSubscription | None = None + self.sub = None self.rate_limit: dict[str, RateLimit] = {} # websocket connection @@ -152,8 +142,6 @@ 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) @@ -282,16 +270,11 @@ 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 """ - sub = self._create_subscription() + self.sub = MainSubscription() # Create requests subscription req_filter = { "kinds": [23194], @@ -299,17 +282,17 @@ class NWCServiceProvider: # Since the last handle_missed_events seconds (handles reboots) "since": int(time.time()) - self.handle_missed_events, } - sub.requests_sub_id = self._get_new_subid() + self.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, } - sub.responses_sub_id = self._get_new_subid() + self.sub.responses_sub_id = self._get_new_subid() # Subscribe - await self._send(["REQ", sub.requests_sub_id, req_filter]) - await self._send(["REQ", sub.responses_sub_id, res_filter]) + await self._send(["REQ", self.sub.requests_sub_id, req_filter]) + await self._send(["REQ", self.sub.responses_sub_id, res_filter]) async def _on_connection(self, _): """ @@ -352,19 +335,6 @@ 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 @@ -434,19 +404,6 @@ 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: @@ -486,7 +443,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: - self._dispatch_request(event) + await self._handle_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: @@ -514,7 +471,7 @@ class NWCServiceProvider: if self.sub.requests_eose and self.sub.responses_eose: stales = self.sub.get_stale() for stale in stales: - self._dispatch_request(stale) + await self._handle_request(stale) async def _on_closed_message(self, msg): if not self.sub: @@ -672,12 +629,6 @@ 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 deleted file mode 100644 index 31b1c7d..0000000 Binary files a/static/image/nwc_thumbnail.png and /dev/null differ diff --git a/tasks.py b/tasks.py index 76aab58..70f3205 100644 --- a/tasks.py +++ b/tasks.py @@ -34,10 +34,6 @@ 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 @@ -114,7 +110,6 @@ 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: @@ -127,11 +122,7 @@ async def _process_invoice( }, "in_budget": in_budget, } - await asyncio.sleep(poll_interval) - poll_interval = min( - poll_interval * PAYMENT_STATUS_POLL_BACKOFF_MULTIPLIER, - PAYMENT_STATUS_POLL_MAX_INTERVAL_SECONDS, - ) + await asyncio.sleep(0.05) if not payment_status: raise Exception("Payment status not found") return { @@ -240,7 +231,6 @@ 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 @@ -264,11 +254,9 @@ async def _on_make_invoice( # Ensures amount is provided if not amount_msats: raise Exception("Missing amount") - # 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 + description = params.get("description", "") + description_hash = params.get("description_hash", None) + expiry = params.get("expiry", None) # hardening # assert_valid_msats(amount_msats) @@ -286,7 +274,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") if description else None, + unhashed_description=description.encode("utf-8"), expiry=expiry, ) payment_hash = payment.payment_hash @@ -396,12 +384,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") 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 "" + 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", "") # hardening # assert_valid_positive_int(tfrom) @@ -437,11 +425,7 @@ async def _on_list_transactions( { "type": "outgoing" if p.is_out else "incoming", "invoice": p.bolt11, - # 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": invoice_data.description, "description_hash": invoice_data.description_hash, "preimage": p.preimage if is_settled or p.is_in else None, "payment_hash": p.payment_hash, @@ -452,7 +436,6 @@ 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 1fe75bd..05905b6 100644 --- a/tests/integration/.env +++ b/tests/integration/.env @@ -199,9 +199,7 @@ 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. -# 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="" +LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos" # Database: to use SQLite, specify LNBITS_DATA_FOLDER # to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://... @@ -251,4 +249,4 @@ LOG_ROTATION="100 MB" LOG_RETENTION="3 months" # for database cleanup commands -# CLEANUP_WALLETS_DAYS=90 +# CLEANUP_WALLETS_DAYS=90 \ No newline at end of file diff --git a/tests/integration/start.sh b/tests/integration/start.sh index 2a669c1..825b90e 100644 --- a/tests/integration/start.sh +++ b/tests/integration/start.sh @@ -15,16 +15,10 @@ if [ "`cat .v039fk_lnbits_integration_test_folder`" != "yes v039fk_lnbits_integr exit 1 fi -# 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 +# Start nostr Relay 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 \ @@ -34,18 +28,9 @@ ghcr.io/hoytech/strfry:latest rm -Rf lnbits_itest_data unzip data.zip -# 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 +id=$(id -u) +gid=$(id -g) -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 \ @@ -60,50 +45,24 @@ 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" -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 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 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 - # 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" - - 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" + ARGS="-d" fi + +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" + diff --git a/tests/integration/test_all.py b/tests/integration/test_all.py index 2752e01..fb33c1f 100644 --- a/tests/integration/test_all.py +++ b/tests/integration/test_all.py @@ -12,10 +12,6 @@ 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", @@ -45,29 +41,35 @@ wallets = { async def check_services(): - async def wait_for_service(name: str, url: str): - deadline = time.monotonic() + SERVICE_STARTUP_TIMEOUT_SECONDS + # wait for http server in localhost:7777 + while True: try: async with httpx.AsyncClient() as client: - 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 + 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) - await wait_for_service("nostr relay", "http://localhost:7777") - await wait_for_service("LNbits", "http://localhost:5002") + # 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) async def get_wallet_balance(w: str): @@ -177,21 +179,15 @@ class NWCWallet: async def _wait_for_connection(self): while not self.connected: - await asyncio.sleep(0.2) + try: + await asyncio.sleep(0.2) + except asyncio.CancelledError: + logger.debug("Connection wait cancelled") + return async def start(self): self.task = asyncio.create_task(self._run()) - 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 + await self._wait_for_connection() def _is_shutting_down(self): return self.shutdown @@ -308,11 +304,7 @@ 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=NWC_RESPONSE_TIMEOUT_SECONDS, + self, result_type, callback=None, on_error_callback=None, timeout=60000 ): now = time.time() while True: diff --git a/tests/unit/test_nwcp.py b/tests/unit/test_nwcp.py index a631037..800e853 100644 --- a/tests/unit/test_nwcp.py +++ b/tests/unit/test_nwcp.py @@ -2,7 +2,6 @@ import asyncio import json import random import string -import time import pytest from loguru import logger @@ -76,18 +75,6 @@ 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( @@ -100,7 +87,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": int(time.time()), + "created_at": 1234567890, } signed = nwc_service_provider._sign_event(event) @@ -114,7 +101,6 @@ 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 @@ -142,111 +128,6 @@ 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 6dfeb55..cca51bb 100644 --- a/tests/unit/test_tasks.py +++ b/tests/unit/test_tasks.py @@ -29,126 +29,3 @@ 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