Compare commits

..

32 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
dni ⚡
53d1e0d5df
chore: update to v1.1.0 (#19)
Some checks failed
/ release (push) Has been cancelled
Tests / test (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-12-02 21:06:06 +01:00
dni ⚡
667a4b0528
refactor: use pynostr/coincurve instead of secp256k1 (#18)
---------

Co-authored-by: Riccardo Balbo <os@rblb.it>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-02 21:04:55 +01:00
blackcoffeexbt
9d97745e70
Improved user documentation (#7)
Some checks failed
/ release (push) Has been cancelled
Tests / test (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-11-04 11:55:21 +00:00
27 changed files with 2949 additions and 1372 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,25 +3,31 @@ 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
git clone https://github.com/lnbits/lnbits.git lnbits git clone https://github.com/lnbits/lnbits.git lnbits
fi fi
cd lnbits cd lnbits
echo $PWD echo $PWD
git checkout v1.0.0-rc7 git checkout dev
poetry env use 3.12 poetry env use 3.12
POETRY_PYTHON_PATH=$(poetry env info -p)/bin/python POETRY_PYTHON_PATH=$(poetry env info -p)/bin/python
ln -sf $POETRY_PYTHON_PATH /home/vscode/python ln -sf $POETRY_PYTHON_PATH /home/vscode/python
make bundle make bundle
poetry install --no-interaction poetry install --no-interaction
mkdir -p lnbits/extensions/ mkdir -p lnbits/extensions/
if [ ! -d lnbits/extensions/nwcprovider ] ; then if [ ! -d lnbits/extensions/nwcprovider ] ; then
ln -s $CONTAINER_WORKSPACE_FOLDER lnbits/extensions/nwcprovider ln -s $CONTAINER_WORKSPACE_FOLDER lnbits/extensions/nwcprovider
fi fi
cd $CONTAINER_WORKSPACE_FOLDER 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/).
@ -6,9 +16,51 @@ Easily connect your LNbits wallets via [NWC](https://nwc.dev/).
Install the extension via the .env file or through the admin UI on your LNbits server. More details can be found [here](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions). Install the extension via the .env file or through the admin UI on your LNbits server. More details can be found [here](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions).
## Configuration # Configuration
Configure the extension from the "Settings" page in the top right menu when logged in as admin inside the extension page. The **LNbits NWC Service Provider** requires a one-time setup before it can be used.
It relies on a Nostr relay, which can be either:
- The **LNbits Nostrclient** browser extension
- A **third-party Nostr relay** of your choice
## Relay Configuration
Before you can start using the extension, you need to configure a Nostr relay.
### Option 1: Use a third-party Nostr relay (recommended)
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).
1. Enter your chosen relay URL in the **Nostr Relay URL** field (e.g. `wss://relay.nostrconnect.com`).
2. Click **Save**.
### Option 2: Use the LNbits Nostrclient extension
> **Note:** This option only works if your LNbits instance is publicly accessible on the internet. Refer to the [nostrclient documentation](https://github.com/lnbits/nostrclient) for more information.
1. Install the **Nostrclient** extension in your browser.
2. Open the extension.
1. Add at least one relay (e.g. `wss://relay.nostrconnect.com` is a good choice for NWC connections).
2. Open **Settings** and enable **Expose Public WebSocket**.
---
## Connecting a NWC App
1. In the **NWC Service Provider** extension, select the wallet you want to connect.
2. Click the **+** button to add a new connection.
3. Enter a description, expiry date (optional), permissions, and limits.
4. Click **Connect** to create the connection.
5. Use the generated **pairing URL** or **QR code** to connect your chosen app.
---
# Extension Configuration
The configuration page of the NWC Service Provider extension is available at `/nwcprovider/admin` and requires LNbits admin privileges.
### Configuration Options: ### Configuration Options:
@ -27,17 +79,9 @@ Configure the extension from the "Settings" page in the top right menu when logg
> >
> 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`.
### Using Nostrclient ## Powered by LNbits
The extension is preconfigured to connect to the nostrclient extension. Install it on the same LNbits instance and configure it to expose public websocket endpoints. Refer to the [nostrclient documentation](https://github.com/lnbits/nostrclient) for more information. [LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
### Using a Custom Relay [![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)
To use a custom relay, set the `relay` key to the relay URL (e.g., `wss://nostr.wine`) in the extension's Settings page.
## Usage
1. Go to the extension page.
2. Select a wallet and click the plus button to create a new NWC connection.
3. Configure expiration, limits, and permissions.
4. A pairing URL will be generated for you to open, copy, or scan with the NWC app. Note that the pairing URL is shown only once, but you can delete and recreate the connection to get a new one.

View file

@ -1,8 +1,12 @@
{ {
"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",
"min_lnbits_version": "1.0.0", "version": "1.1.3-aio.1",
"min_lnbits_version": "1.4.0",
"contributors": [ "contributors": [
{ {
"name": "Riccardo Balbo", "name": "Riccardo Balbo",
@ -21,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"
}, },
@ -33,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

@ -1,4 +1,4 @@
import secp256k1 from coincurve import PrivateKey
async def m001_initial(db): async def m001_initial(db):
@ -72,14 +72,14 @@ async def m003_default_config(db):
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
""" """
) )
new_private_key = bytes.hex(secp256k1._gen_private_key()) private_key = PrivateKey()
await db.execute( await db.execute(
""" """
INSERT INTO nwcprovider.config (key, value) INSERT INTO nwcprovider.config (key, value)
VALUES ('provider_key', :provider_key) VALUES ('provider_key', :provider_key)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
""", """,
{"provider_key": new_private_key}, {"provider_key": private_key.to_hex()},
) )

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

220
nwcp.py
View file

@ -1,5 +1,4 @@
import asyncio import asyncio
import base64
import hashlib import hashlib
import json import json
import random import random
@ -7,13 +6,11 @@ import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any, Union from typing import Any, Union
import secp256k1 from coincurve import PublicKeyXOnly
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
from lnbits.helpers import encrypt_internal_message from lnbits.helpers import encrypt_internal_message
from lnbits.settings import settings from lnbits.settings import settings
from loguru import logger from loguru import logger
from pynostr.key import PrivateKey
from websockets.legacy.client import connect from websockets.legacy.client import connect
@ -30,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]:
""" """
@ -52,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]
@ -68,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
@ -75,7 +79,7 @@ class MainSubscription:
class NWCServiceProvider: class NWCServiceProvider:
def __init__( def __init__(
self, self,
private_key: str | None = None, private_key_hex: str | None = None,
relay: str | None = None, relay: str | None = None,
handle_missed_events: int = 0, handle_missed_events: int = 0,
): ):
@ -90,15 +94,18 @@ class NWCServiceProvider:
) )
self.relay = relay self.relay = relay
if not private_key: # Create random key if not private_key_hex: # Create random key
private_key = bytes.hex(secp256k1._gen_private_key()) self.private_key = PrivateKey()
self.private_key_hex = self.private_key.hex()
else:
self.private_key = PrivateKey.from_hex(private_key_hex)
self.private_key_hex = private_key_hex
self.private_key = secp256k1.PrivateKey(bytes.fromhex(private_key)) self.public_key = self.private_key.public_key
self.private_key_hex = private_key
self.public_key = self.private_key.pubkey
if not self.public_key: if not self.public_key:
raise Exception("Invalid public key") raise Exception("Invalid public key")
self.public_key_hex = self.public_key.serialize().hex()[2:]
self.public_key_hex = self.public_key.hex()
# List of supported methods # List of supported methods
self.supported_methods: list[str] = [] self.supported_methods: list[str] = []
@ -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,17 +333,42 @@ 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
content = self._decrypt_content(content, nwc_pubkey) content = self.private_key.decrypt_message(content, nwc_pubkey)
# Deserialize content # Deserialize content
content = json.loads(content) content = json.loads(content)
# Handle request # Handle request
@ -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
@ -364,7 +420,9 @@ class NWCServiceProvider:
# Reference user # Reference user
res["tags"].append(["p", nwc_pubkey]) res["tags"].append(["p", nwc_pubkey])
# Finalize response event # Finalize response event
res["content"] = self._encrypt_content(res["content"], nwc_pubkey) res["content"] = self.private_key.encrypt_message(
res["content"], nwc_pubkey
)
self._sign_event(res) self._sign_event(res)
# Register response for this request, so we knows it is not stale # Register response for this request, so we knows it is not stale
@ -376,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
@ -385,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")
@ -404,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:
@ -432,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:
@ -513,65 +595,6 @@ class NWCServiceProvider:
logger.debug("Reconnecting to NWC relay...") logger.debug("Reconnecting to NWC relay...")
await self._ratelimit("connecting") await self._ratelimit("connecting")
def _encrypt_content(
self, content: str, pubkey_hex: str, iv_seed: int | None = None
) -> str:
"""
Encrypts the content for the given public key
Args:
content (str): The content to be encrypted.
pubkey_hex (str): The public key in hex format.
Returns:
str: The encrypted content.
"""
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
# random iv (16B)
if not iv_seed:
iv = Random.new().read(AES.block_size)
else:
iv = hashlib.sha256(iv_seed.to_bytes(32, byteorder="big")).digest()
iv = iv[: AES.block_size]
aes = AES.new(shared, AES.MODE_CBC, iv)
content_bytes = content.encode("utf-8")
# padding
content_bytes = pad(content_bytes, AES.block_size)
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
iv_b64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
return encrypted_content
def _decrypt_content(self, content: str, pubkey_hex: str) -> str:
"""
Decrypts the content for the given public key
Args:
content (str): The encrypted content.
pubkey_hex (str): The public key in hex format.
Returns:
str: The decrypted content.
"""
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
# extract iv and content
(encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii"))
# Decrypt
aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted_bytes = aes.decrypt(encrypted_content)
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
decrypted = decrypted_bytes.decode("utf-8")
return decrypted
def _verify_event(self, event: dict) -> bool: def _verify_event(self, event: dict) -> bool:
""" """
Verify the event signature Verify the event signature
@ -596,10 +619,8 @@ class NWCServiceProvider:
if event_id != event["id"]: # Invalid event id if event_id != event["id"]: # Invalid event id
return False return False
pubkey_hex = event["pubkey"] pubkey_hex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True) pubkey = PublicKeyXOnly(bytes.fromhex(pubkey_hex))
if not pubkey.schnorr_verify( if not pubkey.verify(bytes.fromhex(event["sig"]), bytes.fromhex(event_id)):
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
):
return False return False
return True return True
@ -628,10 +649,8 @@ class NWCServiceProvider:
event["id"] = event_id event["id"] = event_id
event["pubkey"] = self.public_key_hex event["pubkey"] = self.public_key_hex
signature = ( signature = self.private_key.sign(bytes.fromhex(event_id))
self.private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True) event["sig"] = signature.hex() # type: ignore
).hex()
event["sig"] = signature
return event return event
async def cleanup(self): async def cleanup(self):
@ -648,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

3054
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,11 +7,8 @@ authors = [{ name = "Riccardo Balbo", email = "oc@rblb.it" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nwcprovider" } urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nwcprovider" }
dependencies = [ "lnbits>1" ] dependencies = [ "lnbits>1" ]
[tool.poetry] [dependency-groups]
package-mode = false dev = [
[tool.uv]
dev-dependencies = [
"black", "black",
"pytest-asyncio", "pytest-asyncio",
"pytest", "pytest",
@ -21,12 +18,15 @@ dev-dependencies = [
"pytest-md", "pytest-md",
] ]
[tool.poetry]
package-mode = false
[tool.mypy] [tool.mypy]
plugins = ["pydantic.mypy"] plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = [ module = [
"secp256k1.*", "pynostr.*",
] ]
ignore_missing_imports = "True" ignore_missing_imports = "True"
@ -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

@ -99,12 +99,51 @@
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md"> <div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none">NWC Service provider</h6> <h6 class="text-subtitle1 q-my-none">NWC Service Provider</h6>
<p> <p>
Nostr Wallet Connect (NWC) is an open protocol to connect lightning Nostr Wallet Connect (NWC) is an open protocol to connect Lightning
wallets to apps wallets to apps. This extension allows you to use your LNbits wallet
with
<a
href="https://github.com/getAlby/awesome-nwc#nwc-wallets"
title="NWC wallets"
target="_blank"
>any NWC compatible app</a
>.
</p> </p>
<p>
Before you can use this extension, you need to configure it.
<a
href="https://github.com/lnbits/nwcprovider#configuration"
title="NWC Service Provider User Guide"
target="_blank"
>Read the User Guide</a
>
to get started.
</p>
<h6 class="text-subtitle2 q-my-none">Connecting a NWC App</h6>
<p>
Once you have configured the extension, you can connect a NWC
compatible app by following these steps:
</p>
<ol class="q-pl-md q-mt-sm">
<li class="q-mb-sm">
In the <strong>NWC Service Provider</strong> extension, select the
wallet you want to connect.
</li>
<li class="q-mb-sm">Click the "+" button to add a new connection.</li>
<li class="q-mb-sm">
Enter a description, expiry date (optional), permissions, and
limits.
</li>
<li class="q-mb-sm">Click "Connect" to create the connection.</li>
<li class="q-mb-sm">
Use the generated pairing URL or QR code to connect your chosen app.
</li>
</ol>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>
<q-list> <q-list>

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,12 +15,18 @@ 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 \
-v $PWD/strfry.conf:/etc/strfry.conf \ --user $id:$gid \
-v $PWD/strfry-data:/app/strfry-db \ -v $PWD/strfry.conf:/etc/strfry.conf:Z \
-v $PWD/strfry-data:/app/strfry-db:Z \
-p 7777:7777 \ -p 7777:7777 \
ghcr.io/hoytech/strfry:latest ghcr.io/hoytech/strfry:latest
@ -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

@ -47,7 +47,7 @@ relay {
port = 7777 port = 7777
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required) # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
nofiles = 1000000 nofiles = 0
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case) # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
realIpHeader = "" realIpHeader = ""

View file

@ -1,5 +1,4 @@
import asyncio import asyncio
import base64
import hashlib import hashlib
import json import json
import random import random
@ -9,13 +8,14 @@ from typing import Union
import bolt11 import bolt11
import httpx import httpx
import pytest import pytest
import secp256k1
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
from loguru import logger from loguru import logger
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",
@ -45,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):
@ -95,12 +89,12 @@ async def refresh_wallet_balances():
def gen_keypair(): def gen_keypair():
private_key_hex = bytes.hex(secp256k1._gen_private_key()) private_key = PrivateKey()
private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex)) private_key_hex = private_key.hex()
public_key = private_key.pubkey public_key = private_key.public_key
if not public_key: if not public_key:
raise Exception("Error generating pubkey") raise Exception("Error generating pubkey")
public_key_hex = public_key.serialize().hex()[2:] public_key_hex = public_key.hex()
return {"priv": private_key_hex, "pub": public_key_hex} return {"priv": private_key_hex, "pub": public_key_hex}
@ -164,12 +158,12 @@ class NWCWallet:
self.event_queue = [] self.event_queue = []
self.subscriptions_count = 0 self.subscriptions_count = 0
self.sub_id = "" self.sub_id = ""
self.private_key = secp256k1.PrivateKey(bytes.fromhex(self.secret)) self.private_key = PrivateKey.from_hex(self.secret)
self.private_key_hex = self.secret self.private_key_hex = self.secret
self.public_key = self.private_key.pubkey self.public_key = self.private_key.public_key
if not self.public_key: if not self.public_key:
raise Exception("Error generating pubkey") raise Exception("Error generating pubkey")
self.public_key_hex = self.public_key.serialize().hex()[2:] self.public_key_hex = self.public_key.hex()
self.task = None self.task = None
async def close(self): async def close(self):
@ -183,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
@ -243,43 +243,13 @@ class NWCWallet:
else: else:
break break
def _encrypt_content(
self, content: str, pubkey_hex: str, iv_seed: int | None = None
) -> str:
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
if not iv_seed:
iv = Random.new().read(AES.block_size)
else:
iv = hashlib.sha256(iv_seed.to_bytes(32, byteorder="big")).digest()
iv = iv[: AES.block_size]
aes = AES.new(shared, AES.MODE_CBC, iv)
content_bytes = content.encode("utf-8")
content_bytes = pad(content_bytes, AES.block_size)
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
iv_b64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
return encrypted_content
def _decrypt_content(self, content: str, pubkey_hex: str) -> str:
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
(encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii"))
aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted_bytes = aes.decrypt(encrypted_content)
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
decrypted = decrypted_bytes.decode("utf-8")
return decrypted
async def _on_message(self, _, message: str): async def _on_message(self, _, message: str):
logger.debug("Received message: " + message) logger.debug("Received message: " + message)
msg = json.loads(message) msg = json.loads(message)
if msg[0] == "EVENT": # Event message if msg[0] == "EVENT": # Event message
event = msg[2] event = msg[2]
nwc_pubkey = event["pubkey"] nwc_pubkey = event["pubkey"]
content = self._decrypt_content(event["content"], nwc_pubkey) content = self.private_key.decrypt_message(event["content"], nwc_pubkey)
content = json.loads(content) content = json.loads(content)
self.event_queue.append( self.event_queue.append(
{ {
@ -312,10 +282,9 @@ class NWCWallet:
event_id = hashlib.sha256(signature_data.encode()).hexdigest() event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id event["id"] = event_id
event["pubkey"] = self.public_key_hex event["pubkey"] = self.public_key_hex
signature = ( signature = self.private_key.sign(bytes.fromhex(event_id))
self.private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True) # type error? returns str but is bytes
).hex() event["sig"] = signature.hex() # type: ignore
event["sig"] = signature
return event return event
async def send_event(self, method, params): async def send_event(self, method, params):
@ -331,7 +300,7 @@ class NWCWallet:
"content": json.dumps({"method": method, "params": params}), "content": json.dumps({"method": method, "params": params}),
} }
logger.debug("Sending event: " + str(event)) logger.debug("Sending event: " + str(event))
event["content"] = self._encrypt_content( event["content"] = self.private_key.encrypt_message(
event["content"], self.provider_pub_hex event["content"], self.provider_pub_hex
) )
self._sign_event(event) self._sign_event(event)
@ -339,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:
@ -857,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
@ -888,7 +932,7 @@ async def test_idor_vulnerability():
f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}", f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}",
headers={"X-Api-Key": wallets["wallet2"]["admin_key"]}, headers={"X-Api-Key": wallets["wallet2"]["admin_key"]},
) )
assert resp.status_code == 500 assert resp.status_code == 400
assert "Pubkey has no associated wallet" in resp.text assert "Pubkey has no associated wallet" in resp.text

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
@ -33,25 +35,22 @@ def test_supported_methods(nwc_service_provider):
def test_encrytdecrypt(nwc_service_provider, nwc_service_provider2): def test_encrytdecrypt(nwc_service_provider, nwc_service_provider2):
content = "Hello World" content = "Hello World"
expected_enc = "qVurNVISSl/9CfREIhk5Lg==?iv=QpCo5dI9gUcoLsSMLA7o7Q==" enc_a = nwc_service_provider.private_key.encrypt_message(
enc_a = nwc_service_provider._encrypt_content( content, nwc_service_provider2.public_key_hex
content, nwc_service_provider2.public_key_hex, 21
) )
enc_b = nwc_service_provider2._encrypt_content( enc_b = nwc_service_provider2.private_key.encrypt_message(
content, nwc_service_provider.public_key_hex, 21 content, nwc_service_provider.public_key_hex
) )
dec_a = nwc_service_provider2._decrypt_content( dec_a = nwc_service_provider2.private_key.decrypt_message(
enc_a, nwc_service_provider.public_key_hex enc_a, nwc_service_provider.public_key_hex
) )
dec_b = nwc_service_provider._decrypt_content( dec_b = nwc_service_provider.private_key.decrypt_message(
enc_b, nwc_service_provider2.public_key_hex enc_b, nwc_service_provider2.public_key_hex
) )
assert dec_a == content assert dec_a == content
assert dec_b == content assert dec_b == content
assert enc_a == expected_enc
assert enc_b == expected_enc
def test_signverify(nwc_service_provider, nwc_service_provider2): def test_signverify(nwc_service_provider, nwc_service_provider2):
@ -77,19 +76,31 @@ 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(
{"method": "pay_invoice", "params": {"invoice": "abc"}} {"method": "pay_invoice", "params": {"invoice": "abc"}}
) )
content = nwc_service_provider._encrypt_content( content = nwc_service_provider.private_key.encrypt_message(
content, nwc_service_provider2.public_key_hex, 21 content, nwc_service_provider2.public_key_hex
) )
event = { event = {
"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)
@ -103,12 +114,13 @@ 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
for revent in sent_events: for revent in sent_events:
assert nwc_service_provider2._verify_event(revent) assert nwc_service_provider2._verify_event(revent)
content = nwc_service_provider2._decrypt_content( content = nwc_service_provider2.private_key.decrypt_message(
revent["content"], nwc_service_provider.public_key_hex revent["content"], nwc_service_provider.public_key_hex
) )
logger.debug(event) logger.debug(event)
@ -128,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

View file

@ -1,10 +1,10 @@
from http import HTTPStatus from http import HTTPStatus
import secp256k1
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import check_admin, require_admin_key from lnbits.decorators import check_admin, require_admin_key
from pynostr.key import PrivateKey
from .crud import ( from .crud import (
create_nwc, create_nwc,
@ -88,11 +88,13 @@ async def api_get_nwc(
nwc = await get_nwc( nwc = await get_nwc(
GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired) GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired)
) )
if not nwc: if not nwc:
raise Exception("Pubkey has no associated wallet") raise ValueError("Pubkey has no associated wallet")
res = NWCGetResponse( res = NWCGetResponse(
data=nwc, budgets=await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey)) data=nwc, budgets=await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
) )
return res return res
@ -123,11 +125,11 @@ async def api_get_pairing_url(req: Request, secret: str) -> str:
scheme = "wss" scheme = "wss"
netloc += "/nostrclient/api/v1/relay" netloc += "/nostrclient/api/v1/relay"
relay = f"{scheme}://{netloc}" relay = f"{scheme}://{netloc}"
psk = secp256k1.PrivateKey(bytes.fromhex(pprivkey)) psk = PrivateKey.from_hex(pprivkey)
ppk = psk.pubkey ppk = psk.public_key
if not ppk: if not ppk:
raise Exception("Error generating pubkey") raise Exception("Error generating pubkey")
ppubkey = ppk.serialize().hex()[2:] ppubkey = ppk.hex()
url = "nostr+walletconnect://" url = "nostr+walletconnect://"
url += ppubkey url += ppubkey
url += "?relay=" + relay url += "?relay=" + relay