diff --git a/.devcontainer/pre-setup.sh b/.devcontainer/pre-setup.sh index a6e490a..6a2b99e 100644 --- a/.devcontainer/pre-setup.sh +++ b/.devcontainer/pre-setup.sh @@ -1,11 +1,6 @@ #!/bin/bash set -e -# workaround for devimage -sudo find /etc/apt/sources.list.d -maxdepth 1 -type f -exec \ - sh -c 'grep -q "dl.yarnpkg.com/debian" "$1" && rm -f "$1" || true' _ {} \; -sudo sed -i '/dl.yarnpkg.com\/debian/d' /etc/apt/sources.list || true - sudo apt update -y sudo apt install -y curl sudo apt-get install -y docker.io diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index 04053c0..261f66b 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -3,11 +3,6 @@ echo $PYTHONPATH CONTAINER_WORKSPACE_FOLDER=$1 cd $CONTAINER_WORKSPACE_FOLDER -# workaround for devimage -sudo find /etc/apt/sources.list.d -maxdepth 1 -type f -exec \ - sh -c 'grep -q "dl.yarnpkg.com/debian" "$1" && rm -f "$1" || true' _ {} \; -sudo sed -i '/dl.yarnpkg.com\/debian/d' /etc/apt/sources.list || true - cd $HOME echo $PWD if [ ! -d ./lnbits ] ; then @@ -30,4 +25,3 @@ cd $CONTAINER_WORKSPACE_FOLDER poetry install --no-interaction npm i prettier npm i pyright -pip install uv 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..035ccbc 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.0", "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/models.py b/models.py index fa52baa..a84c46f 100644 --- a/models.py +++ b/models.py @@ -39,7 +39,7 @@ class NWCBudget(BaseModel): c = int(time.time()) if self.refresh_window <= 0: # never refresh # return a timestamp in the future - return self.created_at, c + 21000000 + return c, c + 21000000 # calculate the next refresh timestamp elapsed = c - self.created_at passed_cycles = elapsed // self.refresh_window diff --git a/nwcp.py b/nwcp.py index bb3afa6..26eeab3 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,12 +49,11 @@ 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()) deleted_ids = [] - for event_id, event in list(self.events.items()): + for [event_id, event] in self.events.items(): if event_id in self.responses: if now - event["created_at"] > expire: del self.events[event_id] @@ -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 @@ -128,14 +121,8 @@ class NWCServiceProvider: # Garbage collection loop self.gc_task = None - # 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 +139,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) @@ -200,7 +185,6 @@ class NWCServiceProvider: """ self.reconnect_task = asyncio.create_task(self._connect_to_relay()) self.gc_task = asyncio.create_task(self._gc_loop()) - self.info_event_task = asyncio.create_task(self._info_event_loop()) def _json_dumps(self, data: Union[dict, list]) -> str: """ @@ -282,16 +266,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 +278,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, _): """ @@ -317,14 +296,6 @@ class NWCServiceProvider: methods and subscribe to nip67 events. """ # Send info event - await self._send_info_event() - # Resubscribe to nwc events - await self._subscribe() - - async def _send_info_event(self): - """ - Build and publish the NWC service info event (kind 13194). - """ event = { "kind": 13194, "content": " ".join(self.supported_methods), @@ -333,38 +304,13 @@ class NWCServiceProvider: } self._sign_event(event) await self._send(["EVENT", event]) - - async def _info_event_loop(self): - """ - Periodically resend the service info event (kind 13194) so that the - provider can recover if the relay silently dropped the event without - closing the WebSocket connection. - """ - while not self._is_shutting_down(): - await asyncio.sleep(60) - if self.connected and not self._is_shutting_down(): - try: - await self._send_info_event() - except Exception as e: - logger.warning("Error resending info event: " + str(e)) + # Resubscribe to nwc events + await self._subscribe() async def _handle_request(self, event: dict) -> list[dict]: """ 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 @@ -392,7 +338,7 @@ class NWCServiceProvider: for result in results: r = result[0] e = result[1] - t = result[2] if len(result) > 2 else [] + t = result[2] if len(result) > 2 else None out = {"result": r, "error": e, "tags": t} outs.append(out) except Exception as e: @@ -406,13 +352,11 @@ class NWCServiceProvider: content["result"] = out["result"] if "error" in out: content["error"] = out["error"] - raw_tags = out.get("tags") - tags = list(raw_tags) if isinstance(raw_tags, list) else [] # Prepare response event res: dict = { "kind": 23195, "created_at": int(time.time()), - "tags": tags, + "tags": out.get("tags", []), "content": self._json_dumps(content), } # Reference request @@ -434,30 +378,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: - try: - if tag[0] == "expiration" and len(tag) > 1: - expiration = int(tag[1]) - break - except Exception: - pass - return expiration - async def _on_event_message(self, msg): if not self.sub: return @@ -467,7 +387,7 @@ class NWCServiceProvider: if not self._verify_event(event): raise Exception("Invalid event signature") tags = event["tags"] - expiration = self._extract_expiration_from_tags(tags) + expiration = int(next((tag for tag in tags if tag[0] == "expiration"), -1)) # Handle event expiration if the relay doesn't support nip 40 if expiration > 0 and expiration < int(time.time()): logger.debug("Event expired") @@ -486,7 +406,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 +434,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: @@ -667,17 +587,6 @@ class NWCServiceProvider: self.gc_task.cancel() except Exception as e: logger.warning("Error closing gc loop: " + str(e)) - try: - if self.info_event_task: - 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/package-lock.json b/package-lock.json index 11f3af5..06a81d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "prettier": "^3.8.1", - "pyright": "^1.1.408" + "prettier": "^3.2.5", + "pyright": "^1.1.358" } }, "node_modules/fsevents": { @@ -28,9 +28,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -43,9 +43,9 @@ } }, "node_modules/pyright": { - "version": "1.1.408", - "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.408.tgz", - "integrity": "sha512-N61pxaLLCsPcUuPPHMNIrGoZgGBgrbjBX5UqkaT5UV8NVZdL7ExsO6N3ectv1DzAUsLOzdlyqoYtX76u8eF4YA==", + "version": "1.1.405", + "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.405.tgz", + "integrity": "sha512-hgy12kLZ1oAMtl9LTsByHftg3AD6Pouwu5rBsQlqYQqCCdGBgaQm9XDAPDap7ayWe9W+NWrUwO7Zy1K7uXoE2A==", "license": "MIT", "bin": { "pyright": "index.js", diff --git a/package.json b/package.json index 1ff8b61..97f219d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "author": "", "license": "ISC", "dependencies": { - "prettier": "^3.8.1", - "pyright": "^1.1.408" + "prettier": "^3.2.5", + "pyright": "^1.1.358" } } diff --git a/paranoia.py b/paranoia.py index 205debe..5ebca7c 100644 --- a/paranoia.py +++ b/paranoia.py @@ -2,12 +2,6 @@ from loguru import logger ENABLE_HARDENING = True -WHITELISTED_NON_PRINTABLE_CHARS = { - "\n", # newline - "\r", # carriage return - "\t", # tab - "\xa0", # non-breaking space ( ) -} def panic(reason: str): @@ -23,17 +17,8 @@ def assert_printable(v: str): return if not isinstance(v, str): panic("not a string " + str(v)) - for ch in v: - # check if printable - if ch.isprintable(): - continue - - # check if whitelisted non-printable - if ch in WHITELISTED_NON_PRINTABLE_CHARS: - continue - - # Anything else is rejected - panic(f"string contains non-printable character: (0x{ord(ch):04X})") + if not v.isprintable(): + panic("string contains non-printable characters") # Check if number is valid int and not NaN diff --git a/pyproject.toml b/pyproject.toml index dc659bf..42556ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,8 +83,8 @@ classmethod-decorators = [ # [tool.ruff.lint.extend-per-file-ignores] # "views_api.py" = ["F401"] -[tool.ruff.lint.mccabe] -max-complexity = 11 +# [tool.ruff.lint.mccabe] +# max-complexity = 10 [tool.ruff.lint.flake8-bugbear] # Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. 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/static/js/admin.js b/static/js/admin.js index e9a405c..2e18b9b 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -4,7 +4,7 @@ window.app = Vue.createApp({ delimiters: ['${', '}'], data: function () { return { - config: {}, + entries: [], columns: [ { name: 'key', @@ -30,10 +30,21 @@ window.app = Vue.createApp({ fetchConfig() { this.entries = [] LNbits.api - .request('GET', '/nwcprovider/api/v1/config') + .request( + 'GET', + '/nwcprovider/api/v1/config', + this.g.user.wallets[0].adminkey + ) .then(response => { - this.config = response.data - console.log('Config fetched:', this.config) + const newEntries = [] + for (const [key, value] of Object.entries(response.data)) { + newEntries.push({ + key: key, + value: value + }) + } + this.entries.length = 0 + this.entries.push(...newEntries) }) .catch(function (error) { console.error('Error fetching config:', error) @@ -41,15 +52,14 @@ window.app = Vue.createApp({ }, async saveConfig() { const data = {} - for (const [key, value] of Object.entries(this.config)) { - data[key] = value + for (const entry of this.entries) { + data[entry.key] = entry.value } - console.log('Saving config:', data) try { const response = await LNbits.api.request( 'POST', '/nwcprovider/api/v1/config', - null, + this.g.user.wallets[0].adminkey, data ) Quasar.Notify.create({ diff --git a/static/routes.json b/static/routes.json deleted file mode 100644 index e69de29..0000000 diff --git a/tasks.py b/tasks.py index 76aab58..a19a0d8 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,24 +110,11 @@ 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: break - if payment_status.failed: - return { - "error": { - "code": "PAYMENT_FAILED", - "message": "Payment failed.", - }, - "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 +223,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 +246,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 +266,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 +376,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 +417,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 +428,6 @@ async def _on_list_transactions( "metadata": {}, } ) - await asyncio.sleep(0) # await log_nwc(pubkey, payload) return [({"transactions": transactions}, None, [])] diff --git a/templates/nwcprovider/admin.html b/templates/nwcprovider/admin.html index 81576b4..171a6d0 100644 --- a/templates/nwcprovider/admin.html +++ b/templates/nwcprovider/admin.html @@ -6,56 +6,21 @@
-
+
- NWC Service Provider Configuration + NWC Service Provider - Config
- + - + - - + ${entry.key} - - - - - - - - - - - - - + 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..56d9717 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: @@ -830,77 +822,6 @@ async def test_budget_refresh(): await wallet1.close() -@pytest.mark.asyncio -async def test_never_refresh_budget_counts_previous_spend(): - await check_services() - nwc1 = await create_nwc( - "wallet1", - "test_never_refresh_budget_counts_previous_spend", - ["invoice"], - [], - 0, - ) - nwc3 = await create_nwc( - "wallet3", - "test_never_refresh_budget_counts_previous_spend", - ["pay"], - [ - { - "budget_msats": 100000, - "refresh_window": 0, - "created_at": int(time.time()), - } - ], - 0, - ) - wallet1 = NWCWallet(nwc1["pairing"]) - wallet3 = NWCWallet(nwc3["pairing"]) - - try: - await wallet1.start() - await wallet3.start() - - await wallet1.send_event( - "make_invoice", {"amount": 60000, "description": "Within lifetime budget"} - ) - result, _, error = await wallet1.wait_for("make_invoice") - assert not error - - await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]}) - _, _, error = await wallet3.wait_for("pay_invoice") - assert not error - - await asyncio.sleep(2) - - async with httpx.AsyncClient() as client: - resp = await client.get( - "http://localhost:5002/nwcprovider/api/v1/nwc" - "?calculate_spent_budget=true", - headers={"X-Api-Key": wallets["wallet3"]["admin_key"]}, - ) - assert resp.status_code == 200 - payer_nwc = next( - item for item in resp.json() if item["data"]["pubkey"] == nwc3["pubkey"] - ) - assert payer_nwc["budgets"][0]["used_budget_msats"] == 60000 - - await wallet1.send_event( - "make_invoice", {"amount": 50000, "description": "Exceeds lifetime budget"} - ) - result, _, error = await wallet1.wait_for("make_invoice") - assert not error - - await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]}) - _, _, error = await wallet3.wait_for("pay_invoice") - assert error - assert error["code"] == "QUOTA_EXCEEDED" - finally: - if wallet3.ws: - await wallet3.close() - if wallet1.ws: - await wallet1.close() - - # Mostly AI generated pentests diff --git a/tests/unit/test_nwcp.py b/tests/unit/test_nwcp.py index a631037..3e23eb6 100644 --- a/tests/unit/test_nwcp.py +++ b/tests/unit/test_nwcp.py @@ -1,8 +1,6 @@ -import asyncio import json import random import string -import time import pytest from loguru import logger @@ -76,18 +74,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 +86,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 +100,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 @@ -140,187 +125,3 @@ async def test_handle(nwc_service_provider, nwc_service_provider2): p_tag = [tag for tag in tags if tag[0] == "p"] assert len(p_tag) == 1 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.""" - nwc_service_provider.add_request_listener( - "pay_invoice", lambda *args, **kwargs: None # type: ignore[arg-type] - ) - - sent: list[list] = [] - - async def _send_capture(obj): - sent.append(obj) - - nwc_service_provider._send = _send_capture - nwc_service_provider.connected = True - - await nwc_service_provider._send_info_event() - - assert len(sent) == 1 - msg = sent[0] - assert msg[0] == "EVENT" - event = msg[1] - assert event["kind"] == 13194 - assert "pay_invoice" in event["content"] - assert nwc_service_provider._verify_event(event) - - -@pytest.mark.asyncio -async def test_info_event_loop_resends(nwc_service_provider): - """_info_event_loop should resend the info event while connected.""" - sent: list[list] = [] - - async def _send_capture(obj): - sent.append(obj) - - nwc_service_provider._send = _send_capture - nwc_service_provider.connected = True - - loop_task = asyncio.create_task(nwc_service_provider._info_event_loop()) - # Allow the loop to run through one sleep cycle (patched to near-zero). - # We drive it by cancelling right after the first send opportunity. - await asyncio.sleep(0) # yield to let the task start - # Manually trigger a resend call to verify the helper works correctly. - await nwc_service_provider._send_info_event() - loop_task.cancel() - try: - await loop_task - except asyncio.CancelledError: - pass - - # At least the manual call went through. - assert len(sent) >= 1 - for msg in sent: - assert msg[0] == "EVENT" - assert msg[1]["kind"] == 13194 - - -@pytest.mark.asyncio -async def test_info_event_loop_skips_when_disconnected(nwc_service_provider): - """_info_event_loop should not send the info event while disconnected.""" - sent: list[list] = [] - - async def _send_capture(obj): - sent.append(obj) - - nwc_service_provider._send = _send_capture - nwc_service_provider.connected = False # not connected - - loop_task = asyncio.create_task(nwc_service_provider._info_event_loop()) - await asyncio.sleep(0) - loop_task.cancel() - try: - await loop_task - except asyncio.CancelledError: - pass - - # Nothing should have been sent because connected=False. - assert len(sent) == 0 diff --git a/tests/unit/test_tasks.py b/tests/unit/test_tasks.py deleted file mode 100644 index 6dfeb55..0000000 --- a/tests/unit/test_tasks.py +++ /dev/null @@ -1,154 +0,0 @@ -from types import SimpleNamespace - -import pytest - -from ... import tasks - - -@pytest.mark.asyncio -async def test_process_invoice_returns_payment_failed_on_failed_status(monkeypatch): - async def fake_tracked_spend_nwc(*args, **kwargs): - return True, "a" * 64 - - async def fake_check_transaction_status(wallet_id: str, payment_hash: str): - return SimpleNamespace(success=False, failed=True) - - monkeypatch.setattr(tasks, "tracked_spend_nwc", fake_tracked_spend_nwc) - monkeypatch.setattr( - tasks, "check_transaction_status", fake_check_transaction_status - ) - - result = await tasks._process_invoice( - wallet_id="wallet123", - pubkey="a" * 64, - invoice="lnbc1example", - amount_msats=1000, - description="test", - ) - - 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