Compare commits

...

29 commits

Author SHA1 Message Date
9c36689506 Merge pull request 'fix: accept null optional params in make_invoice and list_transactions' (#1) from fix/null-optional-params into main
Some checks failed
lint.yml / Merge pull request 'fix: accept null optional params in make_invoice and list_transactions' (#1) from fix/null-optional-params into main (push) Failing after 0s
Tests / test (push) Has been cancelled
Reviewed-on: #1
2026-09-14 20:35:05 +00:00
5f4dde6b53 chore: bump version to 1.1.3-aio.1
Some checks failed
lint.yml / chore: bump version to 1.1.3-aio.1 (pull_request) Failing after 0s
Tests / test (push) Has been cancelled
Tests / test (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt
2026-09-14 22:34:27 +02:00
Patrick Mulligan
bf55b46f76 fix: accept null optional params in make_invoice and list_transactions
Some checks failed
Tests / test (push) Has been cancelled
Clients may send optional params as an explicit JSON null rather than
omitting them; Amethyst does for description, description_hash and
expiry. dict.get only applies its default for a missing key, so
description arrived as None and make_invoice failed with
"'NoneType' object has no attribute 'encode'".

Coerce with `or` instead, pass no unhashed_description when the
description is blank, and apply the same to list_transactions' paging
params, which had the same exposure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt
2026-09-14 22:25:35 +02:00
Riccardo Balbo
b3500f63d9
Update version number to 1.1.3 in config.json
Some checks failed
lint.yml / Update version number to 1.1.3 in config.json (push) Failing after 0s
Tests / test (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2026-08-27 18:29:46 +02:00
Riccardo Balbo
9362bf53ae fix: run integration relay as runner user 2026-08-26 21:54:44 +02:00
Riccardo Balbo
5af8335fdd fix: make integration services fail fast 2026-08-26 20:47:25 +02:00
Riccardo Balbo
e9d3c48aec feat: back off pending payment polling 2026-08-26 16:15:51 +02:00
Riccardo Balbo
f4b96107fa fix: reduce pending payment polling rate 2026-08-26 13:06:39 +02:00
Riccardo Balbo
d432d74c88 fix: dispatch NWC requests concurrently 2026-08-26 13:06:39 +02:00
Riccardo Balbo
8f7aa33d4f
feat: track seen events until they expire (#47)
* yield event loop when processing multiple invoices
* track seen events until they expire
2026-06-26 13:28:12 +02:00
Riccardo Balbo
9674110d04
yield event loop when processing multiple invoices (#46) 2026-06-19 01:31:56 +02:00
Richard
11cfbe8772
fix(docs): correct NWC configuration page location (#36)
* fix(docs): correct NWC configuration page location

The README referenced a "gear icon in the top-right corner" for
accessing relay settings. This UI element does not exist in LNbits
1.5.x. The configuration page is actually at /nwcprovider/admin
and requires LNbits admin privileges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert example relay URL to original

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Richard Taylor <RT@MacBook-RT.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-17 13:56:32 +01:00
DoktorShift
14a96271ad
Changes to more pages (#29)
* Changes to more pages

* fix: correct thumbnail URL in config.json

Changed image URI from lnbits/tpos to lnbits/nwcprovider.

* Fix lint after main merge (tasks.py)

- remove trailing whitespace (ruff W291)
- collapse description chain (black)
2026-06-03 11:47:38 +02:00
Richard
0204f8ff85
list_transactions: fall back to extra.comment / memo when BOLT11 description is empty (#43) 2026-06-02 10:36:40 +01:00
Riccardo Balbo
16a0ec8ab2 bump version to 1.1.2
Some checks failed
/ release (push) Has been cancelled
Tests / test (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2026-05-19 15:00:35 +00:00
Riccardo Balbo
5fecaca268 make linter happy 2026-05-19 14:36:13 +00:00
Riccardo Balbo
bb94f38c2f break wait loop when payment fails 2026-05-19 14:16:16 +00:00
Riccardo Balbo
16dd9015c4 format 2026-05-19 13:35:03 +00:00
Riccardo Balbo
1d001d6e10 fix budget refresh logic for "Never" 2026-05-19 15:22:45 +02:00
Copilot
9367688802
Periodically resend NWC service info event (kind 13194) (#34) 2026-03-25 18:16:46 +01:00
Riccardo Balbo
5d70b9f427 Strengthen types for linter 2026-03-25 17:43:34 +01:00
Riccardo Balbo
08070257f8
fix expiration handling (#32) 2026-03-25 17:38:03 +01:00
Riccardo Balbo
3c52d5d49d copy tags returned by listeners instead of mutating them in place 2026-03-25 17:37:38 +01:00
Riccardo Balbo
75107ff04d default with empty tag list for listeners that return None 2026-03-25 17:33:53 +01:00
Riccardo Balbo
60e18c88f0 make sure gc never iterate over a mutating dict 2026-03-25 17:29:47 +01:00
Riccardo Balbo
ace75fdd3d
update dev env and delete stale yarnpkg repo (#33) 2026-03-25 17:04:57 +01:00
blackcoffeexbt
2b322863ca
Fix configuration page errors (#24)
Some checks failed
/ release (push) Has been cancelled
Tests / test (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2026-01-12 11:35:43 +00:00
Arc
65da39e0b4
Revert "Revert "Improve configuration form for readability and UX"" (#11)
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
2026-01-12 11:16:37 +00:00
Riccardo Balbo
8c14c6fac8
relax printable checks (#21)
* relax printable checks

* format
2025-12-23 10:15:30 +01:00
22 changed files with 811 additions and 120 deletions

View file

@ -1,6 +1,11 @@
#!/bin/bash #!/bin/bash
set -e 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 update -y
sudo apt install -y curl sudo apt install -y curl
sudo apt-get install -y docker.io sudo apt-get install -y docker.io

View file

@ -3,6 +3,11 @@ echo $PYTHONPATH
CONTAINER_WORKSPACE_FOLDER=$1 CONTAINER_WORKSPACE_FOLDER=$1
cd $CONTAINER_WORKSPACE_FOLDER 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 cd $HOME
echo $PWD echo $PWD
if [ ! -d ./lnbits ] ; then if [ ! -d ./lnbits ] ; then
@ -25,3 +30,4 @@ cd $CONTAINER_WORKSPACE_FOLDER
poetry install --no-interaction poetry install --no-interaction
npm i prettier npm i prettier
npm i pyright npm i pyright
pip install uv

View file

@ -7,6 +7,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- name: Checkout code - name: Checkout code
@ -50,3 +51,16 @@ jobs:
pip install pytest-asyncio pip install pytest-asyncio
cd $cdir cd $cdir
pytest tests/integration/*.py -s 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

View file

@ -1,3 +1,13 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:280px">
</picture>
</a>
[![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) # NWC Service Provider Extension for [LNbits](https://github.com/lnbits/lnbits)
Easily connect your LNbits wallets via [NWC](https://nwc.dev/). 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. 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. 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`). 1. Enter your chosen relay URL in the **Nostr Relay URL** field (e.g. `wss://relay.nostrconnect.com`).
2. Click **Save**. 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 # 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: ### 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 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`. > 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)

View file

@ -1,8 +1,11 @@
{ {
"id": "nwcprovider",
"name": "NWC Service Provider", "name": "NWC Service Provider",
"repo": "https://github.com/lnbits/nwcprovider",
"short_description": "A NWC service provider for LNbits", "short_description": "A NWC service provider for LNbits",
"description": "",
"tile": "/nwcprovider/static/image/nwcprovider.png", "tile": "/nwcprovider/static/image/nwcprovider.png",
"version": "1.1.0", "version": "1.1.3-aio.1",
"min_lnbits_version": "1.4.0", "min_lnbits_version": "1.4.0",
"contributors": [ "contributors": [
{ {
@ -22,6 +25,10 @@
} }
], ],
"images": [ "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" "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", "description_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/toc.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
} }

View file

@ -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.

View file

@ -39,7 +39,7 @@ class NWCBudget(BaseModel):
c = int(time.time()) c = int(time.time())
if self.refresh_window <= 0: # never refresh if self.refresh_window <= 0: # never refresh
# return a timestamp in the future # return a timestamp in the future
return c, c + 21000000 return self.created_at, c + 21000000
# calculate the next refresh timestamp # calculate the next refresh timestamp
elapsed = c - self.created_at elapsed = c - self.created_at
passed_cycles = elapsed // self.refresh_window passed_cycles = elapsed // self.refresh_window

119
nwcp.py
View file

@ -27,6 +27,7 @@ class MainSubscription:
self.responses_eose = False self.responses_eose = False
self.events: dict[str, dict] = {} self.events: dict[str, dict] = {}
self.responses: list[str] = [] self.responses: list[str] = []
self.seen_requests: dict[str, int] = {}
def get_stale(self) -> list[dict]: def get_stale(self) -> list[dict]:
""" """
@ -49,11 +50,12 @@ class MainSubscription:
""" """
Garbage collection, remove all the events that have a response older Garbage collection, remove all the events that have a response older
than expire seconds (defaults to 1 hour if 0 or None) than expire seconds (defaults to 1 hour if 0 or None)
and all seen requests that are expired
""" """
expire = expire or 1 * 60 * 60 expire = expire or 1 * 60 * 60
now = int(time.time()) now = int(time.time())
deleted_ids = [] deleted_ids = []
for [event_id, event] in self.events.items(): for event_id, event in list(self.events.items()):
if event_id in self.responses: if event_id in self.responses:
if now - event["created_at"] > expire: if now - event["created_at"] > expire:
del self.events[event_id] del self.events[event_id]
@ -65,6 +67,11 @@ class MainSubscription:
if len(deleted_ids) > 0: if len(deleted_ids) > 0:
logger.debug("Garbage collected " + str(len(deleted_ids)) + " events") 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: class Config:
arbitrary_types_allowed = True arbitrary_types_allowed = True
@ -121,8 +128,14 @@ class NWCServiceProvider:
# Garbage collection loop # Garbage collection loop
self.gc_task = None 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 # Subscription
self.sub = None self.sub: MainSubscription | None = None
self.rate_limit: dict[str, RateLimit] = {} self.rate_limit: dict[str, RateLimit] = {}
# websocket connection # websocket connection
@ -139,6 +152,8 @@ class NWCServiceProvider:
# (handles reboots) # (handles reboots)
self.handle_missed_events = handle_missed_events self.handle_missed_events = handle_missed_events
self.event_max_age = self.handle_missed_events or 5 * 60
logger.info( logger.info(
"NWC Service is ready. relay: " "NWC Service is ready. relay: "
+ str(self.relay) + str(self.relay)
@ -185,6 +200,7 @@ class NWCServiceProvider:
""" """
self.reconnect_task = asyncio.create_task(self._connect_to_relay()) self.reconnect_task = asyncio.create_task(self._connect_to_relay())
self.gc_task = asyncio.create_task(self._gc_loop()) 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: def _json_dumps(self, data: Union[dict, list]) -> str:
""" """
@ -266,11 +282,16 @@ class NWCServiceProvider:
await asyncio.sleep(limit.backoff) await asyncio.sleep(limit.backoff)
limit.last_attempt_time = int(time.time()) limit.last_attempt_time = int(time.time())
def _create_subscription(self) -> MainSubscription:
sub = MainSubscription()
self.sub = sub
return sub
async def _subscribe(self): async def _subscribe(self):
""" """
[Re]Subscribe to receive nip 47 requests and responses from the relay [Re]Subscribe to receive nip 47 requests and responses from the relay
""" """
self.sub = MainSubscription() sub = self._create_subscription()
# Create requests subscription # Create requests subscription
req_filter = { req_filter = {
"kinds": [23194], "kinds": [23194],
@ -278,17 +299,17 @@ class NWCServiceProvider:
# Since the last handle_missed_events seconds (handles reboots) # Since the last handle_missed_events seconds (handles reboots)
"since": int(time.time()) - self.handle_missed_events, "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) # Create responses subscription (needed to track previosly responded requests)
res_filter = { res_filter = {
"kinds": [23195], "kinds": [23195],
"authors": [self.public_key_hex], "authors": [self.public_key_hex],
"since": int(time.time()) - self.handle_missed_events, "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 # Subscribe
await self._send(["REQ", self.sub.requests_sub_id, req_filter]) await self._send(["REQ", sub.requests_sub_id, req_filter])
await self._send(["REQ", self.sub.responses_sub_id, res_filter]) await self._send(["REQ", sub.responses_sub_id, res_filter])
async def _on_connection(self, _): async def _on_connection(self, _):
""" """
@ -296,6 +317,14 @@ class NWCServiceProvider:
methods and subscribe to nip67 events. methods and subscribe to nip67 events.
""" """
# Send info event # 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 = { event = {
"kind": 13194, "kind": 13194,
"content": " ".join(self.supported_methods), "content": " ".join(self.supported_methods),
@ -304,13 +333,38 @@ class NWCServiceProvider:
} }
self._sign_event(event) self._sign_event(event)
await self._send(["EVENT", event]) await self._send(["EVENT", event])
# Resubscribe to nwc events
await self._subscribe() 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))
async def _handle_request(self, event: dict) -> list[dict]: async def _handle_request(self, event: dict) -> list[dict]:
""" """
Handle a nwc request 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"] nwc_pubkey = event["pubkey"]
content = event["content"] content = event["content"]
# Decrypt the content # Decrypt the content
@ -338,7 +392,7 @@ class NWCServiceProvider:
for result in results: for result in results:
r = result[0] r = result[0]
e = result[1] e = result[1]
t = result[2] if len(result) > 2 else None t = result[2] if len(result) > 2 else []
out = {"result": r, "error": e, "tags": t} out = {"result": r, "error": e, "tags": t}
outs.append(out) outs.append(out)
except Exception as e: except Exception as e:
@ -352,11 +406,13 @@ class NWCServiceProvider:
content["result"] = out["result"] content["result"] = out["result"]
if "error" in out: if "error" in out:
content["error"] = out["error"] content["error"] = out["error"]
raw_tags = out.get("tags")
tags = list(raw_tags) if isinstance(raw_tags, list) else []
# Prepare response event # Prepare response event
res: dict = { res: dict = {
"kind": 23195, "kind": 23195,
"created_at": int(time.time()), "created_at": int(time.time()),
"tags": out.get("tags", []), "tags": tags,
"content": self._json_dumps(content), "content": self._json_dumps(content),
} }
# Reference request # Reference request
@ -378,6 +434,30 @@ class NWCServiceProvider:
sent_events.append(res) sent_events.append(res)
return sent_events 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): async def _on_event_message(self, msg):
if not self.sub: if not self.sub:
return return
@ -387,7 +467,7 @@ class NWCServiceProvider:
if not self._verify_event(event): if not self._verify_event(event):
raise Exception("Invalid event signature") raise Exception("Invalid event signature")
tags = event["tags"] tags = event["tags"]
expiration = int(next((tag for tag in tags if tag[0] == "expiration"), -1)) expiration = self._extract_expiration_from_tags(tags)
# Handle event expiration if the relay doesn't support nip 40 # Handle event expiration if the relay doesn't support nip 40
if expiration > 0 and expiration < int(time.time()): if expiration > 0 and expiration < int(time.time()):
logger.debug("Event expired") logger.debug("Event expired")
@ -406,7 +486,7 @@ class NWCServiceProvider:
# already handled or stale, all stale requests will be handled # already handled or stale, all stale requests will be handled
# later when eose is received # later when eose is received
if self.sub.requests_eose and self.sub.responses_eose: 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: elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
# Ensure the response is from this service provider # Ensure the response is from this service provider
if event["pubkey"] != self.public_key_hex: if event["pubkey"] != self.public_key_hex:
@ -434,7 +514,7 @@ class NWCServiceProvider:
if self.sub.requests_eose and self.sub.responses_eose: if self.sub.requests_eose and self.sub.responses_eose:
stales = self.sub.get_stale() stales = self.sub.get_stale()
for stale in stales: for stale in stales:
await self._handle_request(stale) self._dispatch_request(stale)
async def _on_closed_message(self, msg): async def _on_closed_message(self, msg):
if not self.sub: if not self.sub:
@ -587,6 +667,17 @@ class NWCServiceProvider:
self.gc_task.cancel() self.gc_task.cancel()
except Exception as e: except Exception as e:
logger.warning("Error closing gc loop: " + str(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 # close the websocket
try: try:
if self.ws: if self.ws:

16
package-lock.json generated
View file

@ -9,8 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"prettier": "^3.2.5", "prettier": "^3.8.1",
"pyright": "^1.1.358" "pyright": "^1.1.408"
} }
}, },
"node_modules/fsevents": { "node_modules/fsevents": {
@ -28,9 +28,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.6.2", "version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
@ -43,9 +43,9 @@
} }
}, },
"node_modules/pyright": { "node_modules/pyright": {
"version": "1.1.405", "version": "1.1.408",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.405.tgz", "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.408.tgz",
"integrity": "sha512-hgy12kLZ1oAMtl9LTsByHftg3AD6Pouwu5rBsQlqYQqCCdGBgaQm9XDAPDap7ayWe9W+NWrUwO7Zy1K7uXoE2A==", "integrity": "sha512-N61pxaLLCsPcUuPPHMNIrGoZgGBgrbjBX5UqkaT5UV8NVZdL7ExsO6N3ectv1DzAUsLOzdlyqoYtX76u8eF4YA==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"pyright": "index.js", "pyright": "index.js",

View file

@ -9,7 +9,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"prettier": "^3.2.5", "prettier": "^3.8.1",
"pyright": "^1.1.358" "pyright": "^1.1.408"
} }
} }

View file

@ -2,6 +2,12 @@
from loguru import logger from loguru import logger
ENABLE_HARDENING = True ENABLE_HARDENING = True
WHITELISTED_NON_PRINTABLE_CHARS = {
"\n", # newline
"\r", # carriage return
"\t", # tab
"\xa0", # non-breaking space (&nbsp;)
}
def panic(reason: str): def panic(reason: str):
@ -17,8 +23,17 @@ def assert_printable(v: str):
return return
if not isinstance(v, str): if not isinstance(v, str):
panic("not a string " + str(v)) panic("not a string " + str(v))
if not v.isprintable(): for ch in v:
panic("string contains non-printable characters") # 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})")
# Check if number is valid int and not NaN # Check if number is valid int and not NaN

View file

@ -83,8 +83,8 @@ classmethod-decorators = [
# [tool.ruff.lint.extend-per-file-ignores] # [tool.ruff.lint.extend-per-file-ignores]
# "views_api.py" = ["F401"] # "views_api.py" = ["F401"]
# [tool.ruff.lint.mccabe] [tool.ruff.lint.mccabe]
# max-complexity = 10 max-complexity = 11
[tool.ruff.lint.flake8-bugbear] [tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. # Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

View file

@ -4,7 +4,7 @@ window.app = Vue.createApp({
delimiters: ['${', '}'], delimiters: ['${', '}'],
data: function () { data: function () {
return { return {
entries: [], config: {},
columns: [ columns: [
{ {
name: 'key', name: 'key',
@ -30,21 +30,10 @@ window.app = Vue.createApp({
fetchConfig() { fetchConfig() {
this.entries = [] this.entries = []
LNbits.api LNbits.api
.request( .request('GET', '/nwcprovider/api/v1/config')
'GET',
'/nwcprovider/api/v1/config',
this.g.user.wallets[0].adminkey
)
.then(response => { .then(response => {
const newEntries = [] this.config = response.data
for (const [key, value] of Object.entries(response.data)) { console.log('Config fetched:', this.config)
newEntries.push({
key: key,
value: value
})
}
this.entries.length = 0
this.entries.push(...newEntries)
}) })
.catch(function (error) { .catch(function (error) {
console.error('Error fetching config:', error) console.error('Error fetching config:', error)
@ -52,14 +41,15 @@ window.app = Vue.createApp({
}, },
async saveConfig() { async saveConfig() {
const data = {} const data = {}
for (const entry of this.entries) { for (const [key, value] of Object.entries(this.config)) {
data[entry.key] = entry.value data[key] = value
} }
console.log('Saving config:', data)
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'POST', 'POST',
'/nwcprovider/api/v1/config', '/nwcprovider/api/v1/config',
this.g.user.wallets[0].adminkey, null,
data data
) )
Quasar.Notify.create({ Quasar.Notify.create({

0
static/routes.json Normal file
View file

View file

@ -34,6 +34,10 @@ from .paranoia import (
) )
from .permission import nwc_permissions 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: async def _check(nwc: NWCKey | None, method: str) -> dict | None:
# check # check
@ -110,11 +114,24 @@ async def _process_invoice(
True # currently required by nip 47 specs, might change in future True # currently required by nip 47 specs, might change in future
) )
payment_status: PaymentStatus | None = None payment_status: PaymentStatus | None = None
poll_interval = PAYMENT_STATUS_POLL_INITIAL_INTERVAL_SECONDS
while wait_for_preimage: while wait_for_preimage:
payment_status = await check_transaction_status(wallet_id, payment_hash) payment_status = await check_transaction_status(wallet_id, payment_hash)
if payment_status.success: if payment_status.success:
break break
await asyncio.sleep(0.05) 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,
)
if not payment_status: if not payment_status:
raise Exception("Payment status not found") raise Exception("Payment status not found")
return { return {
@ -223,6 +240,7 @@ async def _on_multi_pay_invoice(
results.append(r) results.append(r)
except Exception as e: except Exception as e:
results.append((None, {"code": "INTERNAL", "message": str(e)}, [])) results.append((None, {"code": "INTERNAL", "message": str(e)}, []))
await asyncio.sleep(0)
# await log_nwc(pubkey, payload) # await log_nwc(pubkey, payload)
return results return results
@ -246,9 +264,11 @@ async def _on_make_invoice(
# Ensures amount is provided # Ensures amount is provided
if not amount_msats: if not amount_msats:
raise Exception("Missing amount") raise Exception("Missing amount")
description = params.get("description", "") # Optional params may arrive as explicit JSON null, which dict.get does
description_hash = params.get("description_hash", None) # not default, so coerce here rather than trusting the fallback.
expiry = params.get("expiry", None) description = params.get("description") or ""
description_hash = params.get("description_hash") or None
expiry = params.get("expiry") or None
# hardening # # hardening #
assert_valid_msats(amount_msats) assert_valid_msats(amount_msats)
@ -266,7 +286,7 @@ async def _on_make_invoice(
currency="sat", currency="sat",
memo=description, memo=description,
description_hash=bytes.fromhex(description_hash) if description_hash else None, 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, expiry=expiry,
) )
payment_hash = payment.payment_hash payment_hash = payment.payment_hash
@ -376,12 +396,12 @@ async def _on_list_transactions(
if not nwc: if not nwc:
raise Exception("Pubkey has no associated wallet") raise Exception("Pubkey has no associated wallet")
params = payload.get("params", 0) params = payload.get("params", 0)
tfrom = params.get("from", 0) tfrom = params.get("from") or 0
tuntil = params.get("until", int(time.time())) tuntil = params.get("until") or int(time.time())
limit = params.get("limit", 10) limit = params.get("limit") or 10
offset = params.get("offset", 0) offset = params.get("offset") or 0
unpaid = params.get("unpaid", False) unpaid = params.get("unpaid") or False
tx_type = params.get("type", "") tx_type = params.get("type") or ""
# hardening # # hardening #
assert_valid_positive_int(tfrom) assert_valid_positive_int(tfrom)
@ -417,7 +437,11 @@ async def _on_list_transactions(
{ {
"type": "outgoing" if p.is_out else "incoming", "type": "outgoing" if p.is_out else "incoming",
"invoice": p.bolt11, "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, "description_hash": invoice_data.description_hash,
"preimage": p.preimage if is_settled or p.is_in else None, "preimage": p.preimage if is_settled or p.is_in else None,
"payment_hash": p.payment_hash, "payment_hash": p.payment_hash,
@ -428,6 +452,7 @@ async def _on_list_transactions(
"metadata": {}, "metadata": {},
} }
) )
await asyncio.sleep(0)
# await log_nwc(pubkey, payload) # await log_nwc(pubkey, payload)
return [({"transactions": transactions}, None, [])] return [({"transactions": transactions}, None, [])]

View file

@ -6,21 +6,56 @@
<div class="col-12 q-gutter-y-md"> <div class="col-12 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center wrap q-mb-md">
<div class="col"> <div class="col">
<h5 class="text-subtitle1 q-my-none"> <h5 class="text-subtitle1 q-my-none">
NWC Service Provider - Config NWC Service Provider Configuration
</h5> </h5>
</div> </div>
</div> </div>
<q-markup-table flat> <q-markup-table flat wrap-cells="true">
<tbody> <tbody>
<q-tr v-for="entry in entries" :key="entry.key"> <q-tr>
<q-td> <q-td>
<span> ${entry.key} </span> <q-input
v-model="config.relay"
label="Nostr Relay URL"
filled
wrap
:hint="'URL of the Nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to use the Nostr Client extension'"
>
</q-input>
</q-td> </q-td>
</q-tr>
<q-tr>
<q-td> <q-td>
<q-input v-model="entry.value" /> <q-input
filled
label="Relay Alias"
v-model="config.relay_alias"
:hint="'Relay URL to display in pairing URLs. If your relay has a different public URL than the one set in \'Nostr Relay URL\' set it here.'"
/>
</q-td>
</q-tr>
<q-tr>
<q-td>
<q-input
filled
label="NWC Provider Secret Key "
v-model="config.provider_key"
:hint="'The secret key for the NWC Service Provider. You don\'t need to change this unless your key has been compromised.'"
/>
</q-td>
</q-tr>
<q-tr>
<q-td>
<q-input
filled
label="Time Period To Handle Missed Events"
v-model="config.handle_missed_events"
type="number"
:hint="'Number of seconds to look back for processing events missed while offline. Setting it to 0 disables this functionality.'"
/>
</q-td> </q-td>
</q-tr> </q-tr>
</tbody> </tbody>

View file

@ -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. # 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 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 # Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://... # to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
@ -249,4 +251,4 @@ LOG_ROTATION="100 MB"
LOG_RETENTION="3 months" LOG_RETENTION="3 months"
# for database cleanup commands # for database cleanup commands
# CLEANUP_WALLETS_DAYS=90 # CLEANUP_WALLETS_DAYS=90

View file

@ -15,10 +15,16 @@ if [ "`cat .v039fk_lnbits_integration_test_folder`" != "yes v039fk_lnbits_integr
exit 1 exit 1
fi 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 \ docker run --name=lnbits_nwcprovider_ext_nostr_test \
-d \ -d \
--rm \ --rm \
--user $id:$gid \
-v $PWD/strfry.conf:/etc/strfry.conf:Z \ -v $PWD/strfry.conf:/etc/strfry.conf:Z \
-v $PWD/strfry-data:/app/strfry-db:Z \ -v $PWD/strfry-data:/app/strfry-db:Z \
-p 7777:7777 \ -p 7777:7777 \
@ -28,9 +34,18 @@ ghcr.io/hoytech/strfry:latest
rm -Rf lnbits_itest_data rm -Rf lnbits_itest_data
unzip data.zip unzip data.zip
id=$(id -u) # The fixture was created with the standalone tpos extension installed. tpos
gid=$(id -g) # 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 \ docker run --name=lnbits_nwcprovider_ext_lnbits_test \
-d \ -d \
@ -45,24 +60,50 @@ docker run --name=lnbits_nwcprovider_ext_lnbits_test \
-v ${PWD}/../../.devcontainer/pre-setup.sh:/pre-setup.sh:ro \ -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" 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 if ! docker network inspect lnbits_nwcprovider_ext_test_network >/dev/null 2>&1; then
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr|| true docker network create lnbits_nwcprovider_ext_test_network
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits|| true 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 "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 -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 -" 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 "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" docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "ln -s /app/.env \$HOME/lnbits/.env"
ARGS=""
if [ "$HEADLESS" != "" ]; if [ "$HEADLESS" != "" ];
then then
ARGS="-d" # Keep the server log inside the container so a failed health check can
fi # 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

View file

@ -12,6 +12,10 @@ from loguru import logger
from pynostr.key import PrivateKey from pynostr.key import PrivateKey
from websockets.legacy.client import connect from websockets.legacy.client import connect
SERVICE_STARTUP_TIMEOUT_SECONDS = 180
NWC_CONNECTION_TIMEOUT_SECONDS = 60
NWC_RESPONSE_TIMEOUT_SECONDS = 60
wallets = { wallets = {
"wallet1": { "wallet1": {
"name": "wallet1", "name": "wallet1",
@ -41,35 +45,29 @@ wallets = {
async def check_services(): async def check_services():
# wait for http server in localhost:7777 async def wait_for_service(name: str, url: str):
while True: deadline = time.monotonic() + SERVICE_STARTUP_TIMEOUT_SECONDS
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get("http://localhost:7777") while True:
assert resp.status_code == 200 try:
break resp = await client.get(url)
except Exception: if resp.status_code == 200:
logger.info("Waiting for nostr relay @ http://localhost:7777") return
logger.info( except httpx.HTTPError:
"""Please start the required services by running\ pass
`bash start.sh` if you haven't already""" if time.monotonic() >= deadline:
) raise RuntimeError(
await asyncio.sleep(1) 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 await wait_for_service("nostr relay", "http://localhost:7777")
while True: await wait_for_service("LNbits", "http://localhost:5002")
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): async def get_wallet_balance(w: str):
@ -179,15 +177,21 @@ class NWCWallet:
async def _wait_for_connection(self): async def _wait_for_connection(self):
while not self.connected: while not self.connected:
try: await asyncio.sleep(0.2)
await asyncio.sleep(0.2)
except asyncio.CancelledError:
logger.debug("Connection wait cancelled")
return
async def start(self): async def start(self):
self.task = asyncio.create_task(self._run()) 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): def _is_shutting_down(self):
return self.shutdown return self.shutdown
@ -304,7 +308,11 @@ class NWCWallet:
await self.ws.send(self._json_dumps(["EVENT", event])) await self.ws.send(self._json_dumps(["EVENT", event]))
async def wait_for( 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() now = time.time()
while True: while True:
@ -822,6 +830,77 @@ async def test_budget_refresh():
await wallet1.close() 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 # Mostly AI generated pentests

View file

@ -1,6 +1,8 @@
import asyncio
import json import json
import random import random
import string import string
import time
import pytest import pytest
from loguru import logger from loguru import logger
@ -74,6 +76,18 @@ def test_signverify(nwc_service_provider, nwc_service_provider2):
assert nwc_service_provider2._verify_event(signed) 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 @pytest.mark.asyncio
async def test_handle(nwc_service_provider, nwc_service_provider2): async def test_handle(nwc_service_provider, nwc_service_provider2):
content = nwc_service_provider._json_dumps( content = nwc_service_provider._json_dumps(
@ -86,7 +100,7 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
"kind": 23194, "kind": 23194,
"content": content, "content": content,
"tags": [["p", nwc_service_provider2.public_key_hex]], "tags": [["p", nwc_service_provider2.public_key_hex]],
"created_at": 1234567890, "created_at": int(time.time()),
} }
signed = nwc_service_provider._sign_event(event) signed = nwc_service_provider._sign_event(event)
@ -100,6 +114,7 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
pass pass
nwc_service_provider2._send = _send_pass nwc_service_provider2._send = _send_pass
nwc_service_provider2._create_subscription()
nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice) nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
sent_events = await nwc_service_provider2._handle_request(signed) sent_events = await nwc_service_provider2._handle_request(signed)
assert len(sent_events) == 1 assert len(sent_events) == 1
@ -125,3 +140,187 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
p_tag = [tag for tag in tags if tag[0] == "p"] p_tag = [tag for tag in tags if tag[0] == "p"]
assert len(p_tag) == 1 assert len(p_tag) == 1
assert p_tag[0][1] == nwc_service_provider.public_key_hex 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

154
tests/unit/test_tasks.py Normal file
View file

@ -0,0 +1,154 @@
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